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
F32in 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.
- read the fixed 2D shape;
- read the exposed f32 values;
- mutate values in the existing slice;
- replace every value with zero.
- 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 |
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
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.