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:
| Session | Segment |
|---|---|
| Day 1 Morning | Patterns for reading and steering Rust |
| Day 1 Afternoon | Project A: canscan, a CLI built with your agent |
| Day 2 Morning | Project B: framecache, a library built skeleton-first |
| Day 2 Afternoon | Project 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
| Block | Duration |
|---|---|
| Morning session | 4 hours 30 minutes |
| Lunch | 1 hour |
| Afternoon session | 3 hours 30 minutes |
Day 1 Morning schedule
Including a 10 minute break, this session should take about 4 hours and 30 minutes. It contains:
| Segment | Duration |
|---|---|
| Roles: Architect, Author, Reviewer | 15 minutes |
| Rust Refresher for Readers | 30 minutes |
| Build Your Own Guardrails | 20 minutes |
| Signature-Driven Design | 70 minutes |
| Break | 10 minutes |
| Types That Enforce | 45 minutes |
| Errors as a Review Signal | 25 minutes |
| The Smell Catalog | 55 minutes |
Day 1 Afternoon schedule
Including a 10 minute break, this session should take about 3 hours and 30 minutes. It contains:
| Segment | Duration |
|---|---|
Project A brief: canscan | 10 minutes |
| Project work (pairs), part one | 1 hour 15 minutes |
| Break | 10 minutes |
| Project work (pairs), part two | 1 hour 15 minutes |
| Debrief | 40 minutes |
Roles: Architect, Author, Reviewer
An agent-assisted workflow has three roles, and the agent only fills one of them:
| Role | Who | Main artifact |
|---|---|---|
| Architect | You | Types, signatures, module boundaries, the spec |
| Author | The agent | Function bodies, boilerplate, first-draft tests |
| Reviewer | You | The 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 see | It 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 Typesays the type has the capability. A boundT: Traitsays the caller needs it.dyn Traitselects the concrete type at runtime. Generics select it at compile time.
Two questions for any Rust file
- Who owns which data, and for how long? Look at the types: which ones hold data and which ones only reference it.
- 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:
- A review catches a problem: an
unwrap()on input, a dependency nobody vetted, a test that asserts nothing. - You fix the instance.
- 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.tomlwithdisallowed-methodsfor 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), aclippy.tomlwithdisallowed-methodsanddisallowed-typesfor calls your review banned, and--deny warningsin 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
unsafeappears. Itâs also cheap to run when none does.cargo +nightly miri teston 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 / Exercise | Duration |
|---|---|
| The Skeleton Is the Spec | 10 minutes |
| Getting to a Skeleton: The Design Interview | 10 minutes |
| Reading a Signature as a Contract | 3 minutes |
| The Promises in a Signature | 5 minutes |
| Ownership in Signatures | 10 minutes |
| Fallibility in Signatures | 7 minutes |
| Flexibility in Signatures | 10 minutes |
| Traits as Extension Points | 5 minutes |
| Exercise: One Task, Two Skeletons | 10 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.
Resultforces the question of what happens on bad input. - Treating an unknown frame as an error or a crash.
Optiondeclares it a normal outcome. - Mutating the decoder while decoding.
&selfforbids it, which also keeps decoding shareable across threads. - An improvised error strategy.
DbcParseErroris 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.
- You write the skeleton.
- The agent fills in the bodies.
- You review the bodies.
- 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 asks | You answer | The 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>
| Element | The promise |
|---|---|
pub | This is API. Callers you canât see may depend on it. |
&mut self | Arming changes this object and needs exclusive access. |
Result<..> | Arming can fail, and the caller must handle that. |
ArmedToken | Success produces a value that later operations will require. |
ArmError | The failure modes are enumerated in a type. |
Method receivers
| Receiver | Contract |
|---|---|
&self | Observation. No visible state change. Shareable. |
&mut self | In-place modification with exclusive access. |
self | Consumption. 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 takes | Itâ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
StringorVec<T>: the function produces owned data. - Returning
&strborrowed 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:
| Shape | Meaning |
|---|---|
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:
| Mechanism | Dispatch | Set of types | Reads as |
|---|---|---|---|
enum | match | Closed: you list the variants | âThese, and only these.â |
Generics <T: Trait> | Static | Open, fixed at compile time | âAnything with this capability, known now.â |
dyn Trait | Dynamic | Open, chosen at runtime | âAnything with this capability, chosen later.â |
| Composition | None | Not polymorphism | âThis is made of parts.â |
A decision procedure, usable in review and in prompts:
- 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. - Do downstream users need to plug in their own types? Use a trait.
Prefer generics; use
dynfor heterogeneous collections or to keep type parameters out of a public signature. - 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 perV. - An associated type (
trait Store { type V; }) allows one implementation with one choice ofV. 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:
| Slide | Duration |
|---|---|
| Newtypes: Parse, Donât Validate | 12 minutes |
| Enums: Illegal States, Unrepresentable | 12 minutes |
| Typestate: State Machines That Compile | 12 minutes |
| RAII, Guards, and Drop Bombs | 9 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 when | Use 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 stable | The graph is large or data-driven |
| Compile-time proof is worth some API weight | Mixed 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:
-
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
openandclosecalls. -
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
}
- 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:
| Slide | Duration |
|---|---|
| Designing Errors | 13 minutes |
| Thirty-Second Error Review | 12 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 see | Likely conclusion |
|---|---|
Result<T, ModuleError> with a dedicated enum | The failure modes were designed. |
| An opaque error type in application code | A reasonable default. Check that context is attached at boundaries. |
| An opaque error type in a library API | Callers canât match on failures. Flag it. |
Result<T, String> | The error path wasnât designed. |
-> T with unwrap() or expect() inside | Read 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 crate | Modules 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 / Exercise | Duration |
|---|---|
| Clone Confetti | 7 minutes |
| Arc<Mutex<Everything>> | 7 minutes |
| Stringly-Typed Code | 5 minutes |
| unwrap() on External Input | 5 minutes |
| Over-Abstraction | 7 minutes |
| Lifetime Smells | 12 minutes |
| Exercise: Smell Catalog v1 | 12 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:
- Can one owner mutate, with others reading through short borrows? Use
plain
&mut. - Is this a pipeline? Move data through channels. Ownership transfers and nothing is shared.
- Is it read-mostly configuration? Use
Arc<T>without a lock. Immutable sharing needs no synchronization. - 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:
&strandString,PathandPathBuf,BorrowedFdandOwnedFd. 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 violated | The prompt that fixes it |
|---|---|---|
Rules:
- Work in triage order, not file order: error types, then parameter
types, then flag and
Optioncounts, then a grep pass forclone,unwrap, and<'a>. Bodies last, if at all. - A finding without a prompt is incomplete. Write the sentence you would actually send to the agent.
- 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:
| Segment | Duration |
|---|---|
| Brief | 10 minutes |
| Project work, part one | 1 hour 15 minutes |
| Break | 10 minutes |
| Project work, part two | 1 hour 15 minutes |
| Debrief | 40 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.
clippyandrustcwill 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
- Every command supports
--format text|json. JSON output must be stable enough to consume from a script. - Exit code 0 on success, nonzero with a clear message on failure.
summaryon the well-formedlogs/big.log(4,590,000,000 bytes) must complete in under 60 seconds on the classroom machine.- 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:
| Milestone | Budget |
|---|---|
| M0: skeleton, by hand | 30 minutes |
M1: summary text/JSON on small.log | 25 minutes |
M2: summary on medium.log | 40 minutes |
M3: top, filter, JSON, big.log | 40 minutes |
| M4 (stretch): JSON schema policy | 15 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.tomlwithdisallowed-methodscovering the calls we just banned, set the lint levels inmain.rs, and makecargo clippy -- --deny warningspass.
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:
- One smell you caught: show the code, name the principle, read the prompt that fixed it. Did the fix work?
- One thing you kept: a construct that looked like a smell and survived review with a written justification.
- 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
| Block | Duration |
|---|---|
| Morning session | 3 hours 45 minutes |
| Lunch | 1 hour |
| Afternoon session | 3 hours 45 minutes |
Day 2 Morning schedule
Including a 10 minute break, this session should take about 3 hours and 45 minutes. It contains:
| Segment | Duration |
|---|---|
| Warm-up: three review calls | 15 minutes |
Project B brief: framecache | 15 minutes |
| Skeleton workshop + project work, part one | 1 hour 15 minutes |
| Break | 10 minutes |
| Skeleton workshop + project work, part two | 1 hour 10 minutes |
| Debrief | 40 minutes |
Day 2 Afternoon schedule
Including a 10 minute break, this session should take about 3 hours and 45 minutes. It contains:
| Segment | Duration |
|---|---|
Project C brief: guardian | 15 minutes |
| Validating a stochastic system | 25 minutes |
| Project work, part one | 1 hour 5 minutes |
| Break | 10 minutes |
| Project work, part two | 1 hour 5 minutes |
| Debrief and course retrospective | 45 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)
- (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.
- (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.
- (Platform) The receive buffer is a fixed ring. Buffer slots are recycled as new frames arrive.
- (Diagnostics) Readers may subscribe to a signal and iterate values as they arrive.
- (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:
| Phase | Budget |
|---|---|
| Phase 1: skeleton workshop | 45 minutes |
| Phase 2: cross-review | 15 minutes |
| Phase 3: generation under contract | 1 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âsdisallowed-methods, the lint levels at the crate root), and requirecargo clippy -- --deny warningsgreen before any fill is accepted. - If any
unsafeappears in a fill, it needs two things before acceptance: acargo +nightly miri testrun 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, runmutest-rson 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:
- 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.
- 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
f64everywhere 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:
| Stage | Budget |
|---|---|
| Skeleton, by hand (both channels) | 30 minutes |
| Arbiter and metrics, first working pass | 45 minutes |
| Measure, revise, re-measure | 40 minutes |
| SAFETY-NOTES | 15 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.
-
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. â©
-
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:
Brakewhen collision is imminent (time-to-collision below a hard threshold).Warnwhen a collision is plausible soon (time to collision, TTC, below a soft threshold).NoActionotherwise.
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)
| Metric | Target |
|---|---|
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:
- Ask the agent to translate the protocol cells into
ScriptedLeadvalues. 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?). - Ask it for a grid runner: N seeded trials per cell, median TTC at
first
Warnper cell, measured against noiseless ground truth. - Add the acceptance row: median TTC at first warn >= 1.70 s on every CCRb cell. This row has a published origin; cite it.
- 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.
-
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. â©
-
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-rsover 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
WarnorBrakeâ 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 generatetemplate 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
- Specify architecture in types and signatures, by hand. Bodies are generated; skeletons are written. The skeleton is the spec, and the compiler enforces it.
- Recognize design problems quickly. Triage by signatures, types, and smells before bodies. A mix of conventions is a finding on its own.
- 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?
&strto read,Stringto keep,&mutrare and deliberate. - Fallibility honest?
Resultwhere failure exists; a-> Tthat can panic does so only on documented invariant violations, never on input;Optiononly where absence is normal. - Receivers meaningful?
selfby value for transitions,&selffor observation, interior mutability justified. - Any
<'a>in apubsignature: 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 andOptions; 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 withDrop.
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/expectonly 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
PathandPathBufpattern.
Restructure to avoid shared mutable state: single owner with borrowed access, channel-passed ownership for the pipeline,
Arcwithout a lock for immutable configuration. Any remainingMutexdocuments the invariant it protects.
Types
statusis a closed set. Model it as an enum with data on the variants, and letmatchbe exhaustive, with no_arm.
Introduce
CanIdas a newtype with a fallible constructor. Rawu16must 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_maintenanceandend_maintenanceare a paired-call hazard. Return a guard frombeginwhoseDropperformsend, and remove the manualend.
Errors
Define one
DecodeErrorenum for this module, implementingstd::error::Errorwith asource()chain. Convert allStringandBox<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
guardianarbitrates 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
OptionandResultup 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
- Safety-Critical Rust Consortium: develops coding guidelines and related material for Rust in safety-critical systems.
- Ferrocene Language Specification: describes the Rust language normatively, for when the meaning of a construct needs a precise answer.