← experiment guide v0.1 release notes source
ember / experiments / reference

hook contract

nine lifecycle points, three immutable context types, and one non-owning tensor view. this page defines what each boundary means in ember v0.1.

API reference unstable v0.1 single active experiment
successful generation
on_model_loaded()
before_prefill()

prefill:
  layer hooks
  logits hooks

each decode evaluation:
  layer hooks
  logits hooks

on_generation_complete()
ordered and synchronous
contract status

public experiment items are marked experimental and use #[non_exhaustive] where appropriate. names and fields may change before this API stabilizes.

hooks describe semantic stages, not interchangeable implementation steps. ember invokes them in a fixed order, but each model family reaches that boundary through its own numerically explicit path.

lifecycle

a successful ordinary generation run has one model-load callback, one prefill callback, one hooked prefill evaluation, zero or more hooked decode evaluations, and one completion callback.

scope hook exposed value access
model on_model_loaded ModelContext metadata
setup before_prefill ExecutionContext metadata
each layer before_layer incoming hidden state mutable view
each layer after_attention attention residual contribution mutable view
each layer after_mlp MLP residual contribution mutable view
each layer after_layer completed layer hidden state mutable view
evaluation before_logits final-normalized last-token hidden state mutable view
evaluation after_logits returned logits after family postprocessing mutable view
generation on_generation_complete GenerationContext metadata

the six tensor-bearing hooks repeat first with phase=Prefill, then with phase=Decode for each single-token evaluation. layer indices are zero-based and increase in model execution order.

trait surface

only name is required. every lifecycle method defaults to a successful no-op. a built-in experiment implements only the stages it needs.

pub trait Experiment: Send {
    fn name(&self) -> &'static str;

    fn on_model_loaded(
        &mut self,
        ctx: &ModelContext<'_>,
    ) -> Result<(), ExperimentError> { Ok(()) }

    fn before_prefill(
        &mut self,
        ctx: &ExecutionContext<'_>,
    ) -> Result<(), ExperimentError> { Ok(()) }

    fn before_layer(
        &mut self,
        ctx: &LayerContext<'_>,
        hidden: &mut TensorAccess<'_>,
    ) -> Result<(), ExperimentError> { Ok(()) }

    fn after_attention(
        &mut self,
        ctx: &LayerContext<'_>,
        attention_output: &mut TensorAccess<'_>,
    ) -> Result<(), ExperimentError> { Ok(()) }

    fn after_mlp(
        &mut self,
        ctx: &LayerContext<'_>,
        mlp_output: &mut TensorAccess<'_>,
    ) -> Result<(), ExperimentError> { Ok(()) }

    fn after_layer(
        &mut self,
        ctx: &LayerContext<'_>,
        hidden: &mut TensorAccess<'_>,
    ) -> Result<(), ExperimentError> { Ok(()) }

    fn before_logits(
        &mut self,
        ctx: &ExecutionContext<'_>,
        hidden: &mut TensorAccess<'_>,
    ) -> Result<(), ExperimentError> { Ok(()) }

    fn after_logits(
        &mut self,
        ctx: &ExecutionContext<'_>,
        logits: &mut TensorAccess<'_>,
    ) -> Result<(), ExperimentError> { Ok(()) }

    fn on_generation_complete(
        &mut self,
        ctx: &GenerationContext<'_>,
    ) -> Result<(), ExperimentError> { Ok(()) }
}

context types

contexts are lightweight immutable values. model strings are borrowed; token buffers, weight handles, full GGUF metadata, caches, and backend internals are not exposed.

context fields used by
ModelContext family, optional identifier, architecture, layer count, hidden size model load; embedded in other contexts
ExecutionContext model, phase, start position, input token count, resulting sequence length, tracing state prefill, layer, and logits hooks
LayerContext execution context plus zero-based layer index four per-layer tensor hooks
GenerationContext model, prompt tokens, generated tokens, decode evaluation count, tracing state successful completion

execution position

sequence_length is start_position + input_token_count. token_position() returns an absolute position only when the evaluation contains one token, which is the ordinary decode case.

small enums

ModelFamily
Llama, Qwen3, Gemma4
ExecutionPhase
Prefill, Decode
TracingState
Disabled, Enabled
TensorDType
F32 in v0.1

TensorAccess

TensorAccess is a narrow view over an existing contiguous two-dimensional f32 activation. it stores a fixed [rows, columns] shape and borrows the model-owned value slice.

permitted
  • read the fixed 2D shape;
  • read the exposed f32 values;
  • mutate values in the existing slice;
  • replace every value with zero.
not permitted
  • resize or reallocate storage;
  • change shape or dtype;
  • transfer ownership or replace the buffer;
  • reach weights, caches, scratch, or token data.
let shape: &[usize; 2] = tensor.shape();
let dtype: TensorDType = tensor.dtype();
let observed: &[f32] = tensor.values();

// explicit intervention on the same allocation:
for value in tensor.values_mut() {
    *value *= 0.5;
}

// or replace existing values with zero:
tensor.zero();

ember does not create an activation copy for a hook. mutation is possible only because these points already own a mutable intermediate. observation is a convention enforced by the experiment implementation: reading values() preserves execution; calling values_mut() or zero() does not.

model-family semantics

the shared names identify genuinely equivalent semantic stages. they do not make the underlying blocks identical.

boundary LLaMA / Qwen3 Gemma 4
after_attention after O projection, before residual add after O projection and post-attention RMS norm, before residual add
after_mlp after down projection, before residual add after down projection and post-FFN RMS norm, before residual add
after_layer after the MLP residual add after residual work, PLE, and layer-output scaling
before_logits final-normalized last-token hidden state final-normalized last-token hidden state
after_logits LM-head output LM-head output after final logit softcap
Qwen remains explicit

Qwen3 uses the LLaMA-family block implementation while retaining split-half RoPE, QK normalization, and its ordering rules. the hook surface does not collapse those numerical differences.

LLaMA's active single-token hooks operate directly on its preallocated decode workspace. they do not force the generic tensor path. Gemma's packed versus generic MLP dispatch is also unchanged.

disabled path

normal generation never builds an ExperimentRunner. model methods instantiate the zero-sized DisabledHooks adapter, whose methods are #[inline(always)] no-ops. hooked model functions are generic over the adapter type.

pub(crate) struct DisabledHooks;

impl<T, E> LayerHooks<T, E> for DisabledHooks {
    #[inline(always)]
    fn after_attention(
        &mut self,
        _layer_index: usize,
        _tensor: &mut T,
    ) -> Result<(), E> {
        Ok(())
    }
    // the other disabled methods have the same shape.
}

the v0.1 validation inspected optimized code for a representative LLaMA layer and found no remaining disabled experiment dispatch. warmed allocation tests found no per-layer allocation from the absent path, and controlled prefill/decode A/B runs stayed inside measurement noise.

failure propagation

hook implementations return ExperimentError. ExperimentRunner wraps it in ExperimentFailure before ember's normal error path sees it.

field included when available
experiment name always
hook name always
execution phase prefill, layer, and logits failures
layer index per-layer failures
underlying message always
experiment 'my-experiment' failed in after_attention
(phase=decode, layer=7): expected a finite activation

hooks are synchronous. a failure stops the current generation; completion is not reported as successful and later hooks do not run.

inspection-surface boundary

active experiment values are not dump values

in v0.1, active experiments do not participate in hidden-state extraction, probing, --dump-layers, or --dump-logits. those option combinations are rejected rather than exposing an ambiguous mix of pre- and post-intervention representations.

normal no-experiment tracing, hidden-state extraction, layer dumps, logits dumps, benchmark reporting, and packed kernel dispatch remain unchanged. structured tracing can be active during an experiment, and its state is reported in the immutable execution context, but hooks do not replace or redefine the trace stream.

source map

src/experiments/mod.rs : trait, runner, failure context, active/disabled adapters
src/experiments/context.rs : contexts, enums, TensorAccess
src/experiments/activation_stats.rs : observation reference implementation
src/experiments/zero_layer_output.rs : intervention reference implementation
src/llama.rs : LLaMA/Qwen prefill and fast-decode boundaries
src/gemma4.rs : Gemma 4 family-specific boundaries
src/main.rs : CLI construction, lifecycle, provenance