Probes & lifecycle¶
A probe is the code that looks at activations. It receives the
ActivationRecords that match its extraction point,
can answer each one with a ProbeSignal (carry on, flag, or abort), and
produces one ProbeResult (its verdict) when the request ends.
The simplest probe: a function¶
For a stateless check that only needs to look at one activation at a time, a
plain function decorated with @probe(...) is enough. It takes the record and
returns a score (float), a decision (bool), a full ProbeSignal, or None.
Undercurrent wraps it in a probe class for you.
Write a custom probe covers the decorator and how
return values map to signals.
Use a class, as described below, when the probe needs per-request state (a running score, a history of activations) or custom setup and teardown.
The lifecycle¶
A probe class subclasses undercurrent.core.Probe and implements three
methods. For each request, the router calls them in this order:
probe = MyProbe.spawn(request_id, extraction_point_name) # a fresh instance
probe.on_start(request_ctx) # once
for record in matching_activations: # zero or more times
signal = probe.on_activation(record) # -> ProbeSignal | None
result = probe.on_end(request_ctx) # once, always -> ProbeResult
| Method | Called | Returns |
|---|---|---|
on_start(request_ctx) |
Once, before any activation | nothing |
on_activation(record) |
Once per matching activation, in order | a ProbeSignal, or None if there's nothing to say |
on_end(request_ctx) |
Exactly once, when generation ends or is aborted | a ProbeResult |
on_end must return a ProbeResult even if on_activation was never called,
for example because the extraction point never matched a token.
Here is a complete class probe, driven by hand with a synthetic activation:
from undercurrent.core import (
ActivationRecord,
Probe,
ProbeAction,
ProbeResult,
ProbeSignal,
RequestContext,
)
class NormProbe(Probe):
"""Flags an activation whose L2 norm exceeds a threshold."""
probe_kind = "single_shot"
def __init__(self, threshold: float = 1.0) -> None:
super().__init__() # required: sets up request_id / extraction_point_name
self.threshold = threshold
self.norm = None
self.signals = []
def on_start(self, request_ctx: RequestContext) -> None:
pass
def on_activation(self, record: ActivationRecord):
self.norm = sum(x * x for x in record.tensor) ** 0.5
if self.norm <= self.threshold:
return None
signal = ProbeSignal(action=ProbeAction.FLAG, confidence=self.norm, metadata={"norm": self.norm})
self.signals.append(signal)
return signal
def on_end(self, request_ctx: RequestContext) -> ProbeResult:
return ProbeResult(
request_id=self.request_id,
extraction_point_name=self.extraction_point_name,
verdict={"norm": self.norm, "flagged": bool(self.signals)},
signal_history=self.signals,
)
ctx = RequestContext(request_id="req-1", prompt_metadata={}, extraction_point_config=None)
probe = NormProbe.spawn("req-1", "norm_check", threshold=1.0)
probe.on_start(ctx)
signal = probe.on_activation(
ActivationRecord(
request_id="req-1",
extraction_point_name="norm_check",
layer=3,
token_pos=7,
tensor_type="residual_stream",
tensor=[0.6, 0.8, 0.9],
is_generated=False,
)
)
assert signal.action is ProbeAction.FLAG
result = probe.on_end(ctx)
assert result.verdict["flagged"]
In normal use you never call these methods yourself. You register the class
with a router (or with ProbedModel), and the router spawns and drives one
instance per request.
Isolation model¶
The class is a reusable factory, registered once and shared by every
request. An instance is cheap and single-use: one is spawned for each
(request_id, extraction_point_name) pair, used for that one request, and
thrown away. Instances are never shared across requests, extraction points or
threads, so per-request state on self is safe without locks.
Probe.spawn(request_id, extraction_point_name, **probe_kwargs) is the way to
create an instance. It passes probe_kwargs to your __init__ and stamps the
instance with its request and extraction point. An instance created by calling
the class directly raises RuntimeError when you read .request_id.
Undercurrent enforces the isolation rule: defining a mutable class attribute
(list, dict, set, bytearray) on a probe subclass raises TypeError at
class-definition time, because every instance would silently share it.
try:
class LeakyProbe(Probe):
probe_kind = "trajectory"
seen = [] # shared by every instance -- rejected
except TypeError as exc:
print(exc)
Put mutable state on self in __init__ or on_start instead.
ProbeFactory(probe_cls, probe_kwargs) pairs a probe class with fixed
constructor arguments. It is what a router's probe_registry maps each
probe_type to:
from undercurrent.core import ProbeFactory
factory = ProbeFactory(NormProbe, {"threshold": 2.5})
instance = factory.spawn("req-2", "norm_check")
assert instance.threshold == 2.5 and instance.request_id == "req-2"
An extraction point's probe_args override the factory's arguments for the
same keys, so one registered probe can run with different settings at
different points.
Single-shot and trajectory probes¶
Every probe class declares a probe_kind, and every extraction point repeats
it as probe_kind in the spec:
probe_kind |
Looks at | State | Typical position | Execution modes |
|---|---|---|---|---|
single_shot |
One activation | None to speak of: decides from that one activation | A single token, e.g. prompt[-1] |
inline only |
trajectory |
A stream of activations over the generation | Accumulated across calls (running score, history) | Continuous, e.g. generated[*] |
inline or async |
A single-shot probe puts its decision logic in on_activation; on_end
just packages what it computed (with a sensible empty verdict if it never ran).
The content-safety demo's SingleTokenSafetyProbe is this shape: score one
activation with a classifier head and flag it above a threshold.
A trajectory probe updates its state on every activation and can intervene
part-way through, for example by returning abort the first time a running
score crosses a threshold. Its verdict is only final in on_end. Because the
router feeds an instance its activations one at a time and in order (for async
points too), the probe can rely on that order.
Only trajectory probes may run async: an async probe works through a queue
of activations in the background, which only makes sense for a probe that
accumulates state. The spec parser and the router both reject async with
single_shot.
undercurrent.core.examples has two small reference probes:
MLPClassifierProbe (single-shot) and TrajectoryScoreProbe (trajectory, a
running mean with abort-on-threshold). Both use deterministic placeholder
scoring rather than trained models:
from undercurrent.core.examples import TrajectoryScoreProbe
traj = TrajectoryScoreProbe.spawn("req-3", "running_mean", threshold=0.5)
traj.on_start(ctx)
signals = []
for pos, values in enumerate([[0.1, 0.2], [0.4, 0.6], [0.9, 1.0]]):
record = ActivationRecord("req-3", "running_mean", 4, 10 + pos, "mlp_out", values, True)
signals.append(traj.on_activation(record))
assert signals[0] is None # running mean still below 0.5
assert signals[2].action is ProbeAction.ABORT # crossed the threshold
print(traj.on_end(ctx).verdict) # {'final_mean': ..., 'count': 3, 'aborted': True}
The full content-safety demo, with single-shot and trajectory probes backed by
small PyTorch heads, lives in examples/content_safety/ in the repository.
Core types¶
ActivationRecord (undercurrent.spec, re-exported from
undercurrent.core): one captured activation.
| Field | Meaning |
|---|---|
request_id |
The request it belongs to |
extraction_point_name |
The extraction point that matched |
layer |
The single layer it came from (one record per layer) |
token_pos |
Absolute token index in prompt + generated sequence |
tensor_type |
e.g. "residual_stream" (a plain string) |
tensor |
The activation itself. The shipped adapters pass a 1-D CPU torch.Tensor of size hidden_dim; synthetic records can use any array-like. |
is_generated |
False for prompt tokens |
timestamp |
Capture time (seconds since the epoch) |
record.metadata() returns every field except tensor as a dict, which is
handy for logging.
ProbeSignal (undercurrent.core): what on_activation may return.
| Field | Meaning |
|---|---|
action |
ProbeAction.CONTINUE (default), FLAG or ABORT |
metadata |
Probe-defined dict: scores, intermediate values |
confidence |
Optional float, meaning probe-defined |
timestamp |
Creation time |
ABORT from an inline extraction point stops generation (see
Interventions). FLAG and CONTINUE never change
generation; they are there to be logged and inspected.
ProbeResult (undercurrent.core): what on_end returns, exactly once.
| Field | Meaning |
|---|---|
request_id, extraction_point_name |
Which request and point it belongs to |
verdict |
The probe's conclusion. Any shape; document yours. |
signal_history |
The signals the probe chose to record, in order (the probe fills this in) |
metadata |
Extra probe-defined data (timing, debug info) |
RequestContext (undercurrent.core): passed to on_start and on_end.
It is frozen, so it can't be used to stash state.
| Field | Meaning |
|---|---|
request_id |
The request |
prompt_metadata |
Adapter-defined dict. The shipped adapters include model, prompt and prompt_len. |
extraction_point_config |
Optional; the resolved extraction point when the caller supplies it (the shipped adapters pass None) |
Failures¶
A probe that raises doesn't take generation down with it:
- Async points: the exception is logged, counted in the
metrics as a probe error, and turned into a
ProbeSignal(action=CONTINUE)whose metadata hasrouter_error: Trueand the error message, which is forwarded to the log sink. The worker moves on to the next activation. - Inline points under
intervention.mode: block_until_signal: the exception is treated like a timeout and replaced by theon_timeoutfallback signal (see Intervention policies). - Inline points under the default
rejectmode: the exception propagates out ofrouter.route()to the engine adapter. Catch your own errors inon_activationif you want to fail open.