Traits, Generics, and Capability Boundaries
A trait describes behavior a type can provide. In an AI system, well-designed traits stop provider SDKs, storage clients, HTTP frameworks, and local runtimes from leaking into product policy.
The goal is not abstraction everywhere. It is a small, honest seam at a source of variability, nondeterminism, or external effects.
Learning objectives
Section titled “Learning objectives”After this chapter, you will be able to:
- define and implement traits with methods, associated types, and bounds;
- read generic bounds as capability requirements;
- distinguish trait bounds from inheritance-style class hierarchies;
- design narrow model, verifier, trace, tool, and storage capabilities;
- use static dispatch in a generic harness;
- choose associated types versus generic method/type parameters;
- avoid giant optional-method provider traits;
- test an orchestrator with a scripted implementation;
- run the companion’s real generic
Harness<M, V, T>.
Dependencies and downstream use
Section titled “Dependencies and downstream use”This chapter depends on structs, ownership, borrowing, closures, and error types. The next chapter compares generics with runtime trait objects. The workspace chapter then places capability traits across crate boundaries.
The companion’s first vertical slice has this shape:
Harness<M, V, T>├── M: Model probabilistic/external generation├── V: Verifier deterministic output checks└── T: TraceSink observable durable-or-test trace boundaryThe harness owns policy: budgets, repair count, terminal status, and event order. Implementations own mechanism.
A trait is a required behavior
Section titled “A trait is a required behavior”trait Verifier { fn verify( &self, task: &Task, content: &str, ) -> Result<VerifiedOutput, Vec<Violation>>;}An implementation supplies that behavior:
struct JsonContractVerifier;
impl Verifier for JsonContractVerifier { fn verify( &self, task: &Task, content: &str, ) -> Result<VerifiedOutput, Vec<Violation>> { // parse, enforce size and fields, return precise violations }}The trait does not say JsonContractVerifier “is a” base verifier. It says the type satisfies a
behavioral contract.
Document semantics beyond the signature:
- whether calls may have external effects;
- accepted input size;
- deterministic behavior;
- cancellation and timeout ownership;
- ordering of returned violations;
- thread-safety;
- version identity.
Types alone cannot express every operational promise.
Generic bounds are capability requirements
Section titled “Generic bounds are capability requirements”The companion declares:
pub struct Harness<M, V, T> { model: M, verifier: V, trace: T,}
impl<M, V, T> Harness<M, V, T>where M: Model, V: Verifier, T: TraceSink,{ pub async fn run( &self, run_id: RunId, task: Task, ) -> Result<RunSummary, RunError> { // ... }}Read the where clause as: run exists for combinations whose components supply these three
capabilities.
The compiler creates concrete code for used type combinations. This is static dispatch, often called monomorphization. Method targets are known at compile time and may be inlined.
Generic parameters do not require heap allocation or vtables. They can increase compile time and binary size when many combinations instantiate large functions.
Design the trait from the consumer
Section titled “Design the trait from the consumer”Start with what the harness needs, not everything a provider SDK offers:
#[async_trait::async_trait]trait Model: Send + Sync { async fn infer( &self, request: ModelRequest, ) -> Result<ModelResponse, ModelError>;}The request and response use application vocabulary. A vendor adapter translates:
ModelRequest │ adapter mapping ▼Provider SDK request ──network──▶ Provider response │ normalize stop reason, usage, content, errors ▼ModelResponseThis keeps:
- provider authentication out of domain types;
- stop-reason quirks out of the harness;
- replay artifacts portable;
- scripted models small;
- product policy stable when an SDK changes.
Do not copy the entire provider interface into your trait. That preserves coupling under new names.
Capability traits instead of one giant model
Section titled “Capability traits instead of one giant model”Text generation, embeddings, image generation, transcription, and video understanding have different request/response and resource contracts.
trait TextGeneration { /* text/chat output */ }trait Embedding { /* vector output, dimensions */ }trait ImageUnderstanding { /* regions and visual evidence */ }trait SpeechToText { /* timed transcript */ }trait ImageGeneration { /* generated artifact candidates */ }One adapter may implement several. A task/router should require exactly the capability it uses.
A giant trait with optional methods:
trait EverythingModel { fn embed(...) -> Option<...>; fn generate_image(...) -> Option<...>; fn transcribe(...) -> Option<...>;}creates types that compile while supporting invalid combinations. Option then means both
“capability unsupported” and perhaps “no output,” which are different.
Capability metadata still matters. Two TextGeneration implementations can support different
context lengths, structured decoding, media inputs, or streaming. Represent those as validated
metadata and route before invoking.
Required methods and default methods
Section titled “Required methods and default methods”Traits can provide shared behavior:
trait TraceSink { fn record(&self, event: &TraceEvent) -> Result<(), TraceSinkError>;
fn record_all( &self, events: &[TraceEvent], ) -> Result<(), TraceSinkError> { for event in events { self.record(event)?; } Ok(()) }}Default methods should derive safely from required primitives. Do not add a default external-write implementation that silently weakens idempotency or authorization.
An implementation can override a default method when batching is semantically equivalent. Document
atomicity: record_all above may partially write before an error.
Associated types versus generic parameters
Section titled “Associated types versus generic parameters”An associated type says one implementation chooses one related type:
trait Decoder { type Output; type Error;
fn decode(&self, bytes: &[u8]) -> Result<Self::Output, Self::Error>;}This is useful when a decoder implementation has one natural output/error pair.
A generic trait parameter allows the same type to implement behavior for several inputs:
trait Grade<T> { fn grade(&self, expected: &Expected, actual: &T) -> GradeResult;}A generic method chooses its type per call:
trait ArtifactStore { fn put<R: std::io::Read>(&self, reader: R) -> Result<ArtifactId, StoreError>;}Generic methods can make a trait non-object-safe. If runtime trait objects are needed, accept an object-safe erased input such as a byte stream trait object or redesign the boundary. The next chapter derives this tradeoff.
Supertraits and auto traits
Section titled “Supertraits and auto traits”The companion model trait requires:
trait Model: Send + SyncSend means the value can move across thread boundaries. Sync means &Model can be shared across
threads safely. They are auto traits derived from fields in safe code.
This is appropriate because a shared harness may invoke a model adapter from async worker threads. It does not promise that the provider accepts unlimited concurrent calls. Rate limits and semaphores are separate runtime policy.
Do not add unsafe Send/Sync implementations around a native model runtime without proving its
threading contract. Wrap non-thread-safe runtimes behind one owning worker when needed.
Async methods in traits
Section titled “Async methods in traits”The companion currently uses async-trait:
#[async_trait::async_trait]trait Model: Send + Sync { async fn infer(&self, request: ModelRequest) -> Result<ModelResponse, ModelError>;}The macro transforms the method into an object-safe boxed future with lifetime bounds. This is ergonomic for runtime-selected adapters but introduces allocation/dynamic future dispatch per call. Beside network or inference latency, that overhead is often irrelevant.
Native async functions in traits are available for many static-dispatch designs, but object safety,
public API ergonomics, and send bounds require deliberate choices. Do not select syntax without
checking whether the trait must be stored as dyn Trait.
Record the design reason in the trait’s documentation.
Blanket implementations and adapters
Section titled “Blanket implementations and adapters”The companion implements Model for Arc<M>:
#[async_trait::async_trait]impl<M> Model for Arc<M>where M: Model + ?Sized,{ async fn infer( &self, request: ModelRequest, ) -> Result<ModelResponse, ModelError> { (**self).infer(request).await }}This lets a shared Arc<dyn Model> satisfy the generic harness in the next chapter. ?Sized permits
the dynamically sized trait object behind Arc.
Blanket implementations are powerful public API commitments. They can conflict with future downstream implementations under Rust’s coherence rules. Add them when they encode a stable, obvious delegation.
Coherence and the orphan rule
Section titled “Coherence and the orphan rule”An implementation is generally allowed when either the trait or the type is local to your crate. This prevents two crates from defining conflicting behavior for the same foreign trait/type pair.
If you need custom behavior for two foreign items, introduce a local newtype:
struct ProviderModelId(external_sdk::ModelName);
impl Display for ProviderModelId { // local type permits the implementation}The newtype can also enforce validation and prevent vendor types from spreading into domain APIs.
Testing at the capability boundary
Section titled “Testing at the capability boundary”The companion ScriptedModel owns a queue of:
enum ScriptedStep { Response(ModelResponse), Failure(String),}It deterministically tests:
- immediate valid output;
- invalid output followed by repair;
- exhausted repair budget;
- provider failure;
- trace ordering.
Fake the external/nondeterministic boundary, not every internal helper. A mock-heavy test that asserts call choreography but never validates the terminal artifact can preserve a broken design.
The scripted implementation is production-quality test infrastructure: it uses the same Model
trait and request types as real adapters.
Trait laws and contract tests
Section titled “Trait laws and contract tests”Some traits have laws not enforced by signatures:
- a content-addressed store returns the same ID for identical bytes;
- replay does not call a live provider;
- a verifier returns stable violation order;
- a cancellation-safe sink does not record a false completion;
- model usage units are nondecreasing/nonnegative under a stated provider mapping.
Write a reusable contract-test suite that every implementation runs:
async fn model_contract<M: Model>(model: M) { // common request mapping, bounded output, error classification checks}Provider-specific tests add fixture mapping and optional live tests. Never make ordinary CI depend on billable nondeterministic inference.
Runnable companion implementation
Section titled “Runnable companion implementation”Run the actual generic harness:
cd rust/rust-ai-engineeringcargo run -q -p mosaic-harness --example generic_harnesscargo test --workspace --examplesExpected output:
model calls: 1; trace events: 4The example is
crates/mosaic-harness/examples/generic_harness.rs. It constructs:
ScriptedModelasM;JsonContractVerifierasV;MemoryTraceasT;- a validated domain task and response;
- one real
Harness::run.
Its async test proves one model call produces the four-event successful trace. The harness code is the same implementation Forge uses.
Failure investigation: adding embeddings breaks every adapter
Section titled “Failure investigation: adding embeddings breaks every adapter”Symptom: a new embedding method makes text-only adapters add dummy unsupported implementations.
Root cause: the model trait grouped unrelated capabilities.
Investigation:
- list each implementation’s real supported operations;
- find methods returning “unsupported,”
None, or placeholder values; - identify call sites that require only one capability;
- split traits around consumer needs;
- add explicit capability metadata to routing;
- compile each adapter against only honest traits.
Fix: use separate TextGeneration and Embedding traits. Compose at the adapter/router level.
Do not preserve source compatibility by preserving invalid states.
Failure investigation: generic type errors explode
Section titled “Failure investigation: generic type errors explode”Symptom: a public function exposes deeply nested bounds and unreadable errors.
Actions:
- name domain concepts with traits/newtypes;
- move bounds to a
whereclause; - split one function into typed stages;
- use a concrete application struct at the outer boundary;
- consider a trait object where runtime erasure matches actual requirements;
- avoid type aliases that merely hide important lifetime/authority behavior.
Generic precision is valuable inside the assembly. Users of the final CLI/API should not need to name every adapter type.
Common failure modes
Section titled “Common failure modes”Abstracting a stable pure helper
Section titled “Abstracting a stable pure helper”Ordinary functions are easier to read and test. Add a trait when substitution or capability boundaries are real.
Provider-shaped domain traits
Section titled “Provider-shaped domain traits”Define application requests first; translate provider data at the adapter.
Giant traits with optional methods
Section titled “Giant traits with optional methods”Split by capability so unsupported combinations do not type-check.
Traits without semantic documentation
Section titled “Traits without semantic documentation”Signatures do not define retries, effects, limits, or ordering.
Mocking internal implementation details
Section titled “Mocking internal implementation details”Use scripted boundary implementations and assert observable outputs/traces.
Assuming Send + Sync means unlimited concurrency
Section titled “Assuming Send + Sync means unlimited concurrency”It means memory/thread safety, not provider quota or GPU capacity.
Security and performance implications
Section titled “Security and performance implications”Security:
- trait methods should expose the minimum capability;
- effectful tools need separate authority/receipt contracts;
- domain values must not carry provider credentials;
- adapter errors must preserve operator sources while redacting public output;
- contract tests should include output bounds, cancellation, and malicious fixtures.
Performance:
- static dispatch may inline and specialize;
- monomorphization can increase build time/binary size;
- abstraction rarely dominates network/inference latency;
- generic tensor/media hot loops can benefit more from static dispatch;
- async-trait future boxing should be measured only where call rates make it relevant.
Do not couple the harness to a provider to avoid one virtual call. That trades negligible runtime cost for expensive architectural rigidity.
Exercises
Section titled “Exercises”Recall
Section titled “Recall”- What does a trait bound say about a generic type?
- When is an associated type preferable to a trait parameter?
- What do
SendandSyncguarantee—and not guarantee? - Why can a generic method affect object safety?
Implementation
Section titled “Implementation”- Add a
CancellationProbetrait with a scripted implementation. - Split text generation and embedding capabilities.
- Write a reusable verifier contract test with two implementations.
- Add an
Arc<M>delegation for another read-only capability. - Create a provider response mapper using
TryFrom.
Design
Section titled “Design”Design traits for text generation, image generation, transcription, embedding, artifact storage, and tracing. For every method, document:
- effect classification;
- maximum input/output;
- determinism;
- cancellation owner;
- retry owner;
- version identity;
- thread/concurrency constraints.
Decide which behaviors should remain ordinary functions.
Failure lab
Section titled “Failure lab”Create intentionally bad designs:
- an everything-provider trait;
- a storage trait that leaks SQL rows;
- a model trait that returns provider JSON;
Send + Syncforced with unsafe;- a fake that bypasses real validation.
Refactor each and record which coupling or invalid state was removed.
Solution guidance
Section titled “Solution guidance”CancellationProbe can be synchronous:
trait CancellationProbe { fn is_cancelled(&self) -> bool;}The harness should check at deterministic boundaries and emit a terminal event. Cancellation of the underlying provider call requires a separate adapter/runtime contract.
For TryFrom, map every provider stop reason explicitly and reject unknown values rather than
silently calling them success. Retain request ID and safe usage details.
Contract tests should accept a factory if an implementation is stateful. Keep external live tests opt-in and separate from the deterministic suite.
Check your understanding
Section titled “Check your understanding”- Does a trait automatically allocate or dynamically dispatch?
- Who should define a trait: the provider SDK or the consuming application?
- Why should embeddings not be an optional text-model method?
- Can
Arc<dyn Model>satisfyM: Modelwithout an appropriate implementation? - What does
?Sizedpermit in the blanketArc<M>implementation? - Which trait behaviors require prose or contract tests beyond types?
- When is a plain function more honest than a trait?
Completion checklist
Section titled “Completion checklist”- I derive traits from consumer capabilities.
- I keep domain requests independent of provider SDKs.
- I use generic bounds to assemble the deterministic shell.
- I split unrelated model modalities/capabilities.
- I document effects, limits, retry, cancellation, and ordering.
- I write contract tests for semantic laws.
- I do not force thread-safety with unsafe code.
- I ran the real generic harness example and its async test.