Skip to content

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.

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>.

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 boundary

The harness owns policy: budgets, repair count, terminal status, and event order. Implementations own mechanism.

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.

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
ModelResponse

This 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.

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.

The companion model trait requires:

trait Model: Send + Sync

Send 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.

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.

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.

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.

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.

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.

Run the actual generic harness:

Terminal window
cd rust/rust-ai-engineering
cargo run -q -p mosaic-harness --example generic_harness
cargo test --workspace --examples

Expected output:

model calls: 1; trace events: 4

The example is crates/mosaic-harness/examples/generic_harness.rs. It constructs:

  • ScriptedModel as M;
  • JsonContractVerifier as V;
  • MemoryTrace as T;
  • 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:

  1. list each implementation’s real supported operations;
  2. find methods returning “unsupported,” None, or placeholder values;
  3. identify call sites that require only one capability;
  4. split traits around consumer needs;
  5. add explicit capability metadata to routing;
  6. 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:

  1. name domain concepts with traits/newtypes;
  2. move bounds to a where clause;
  3. split one function into typed stages;
  4. use a concrete application struct at the outer boundary;
  5. consider a trait object where runtime erasure matches actual requirements;
  6. 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.

Ordinary functions are easier to read and test. Add a trait when substitution or capability boundaries are real.

Define application requests first; translate provider data at the adapter.

Split by capability so unsupported combinations do not type-check.

Signatures do not define retries, effects, limits, or ordering.

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:

  • 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.

  1. What does a trait bound say about a generic type?
  2. When is an associated type preferable to a trait parameter?
  3. What do Send and Sync guarantee—and not guarantee?
  4. Why can a generic method affect object safety?
  1. Add a CancellationProbe trait with a scripted implementation.
  2. Split text generation and embedding capabilities.
  3. Write a reusable verifier contract test with two implementations.
  4. Add an Arc<M> delegation for another read-only capability.
  5. Create a provider response mapper using TryFrom.

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.

Create intentionally bad designs:

  • an everything-provider trait;
  • a storage trait that leaks SQL rows;
  • a model trait that returns provider JSON;
  • Send + Sync forced with unsafe;
  • a fake that bypasses real validation.

Refactor each and record which coupling or invalid state was removed.

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.

  • 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> satisfy M: Model without an appropriate implementation?
  • What does ?Sized permit in the blanket Arc<M> implementation?
  • Which trait behaviors require prose or contract tests beyond types?
  • When is a plain function more honest than a trait?
  • 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.