How-to Guides#

This guide describes the behavior implemented by Observable Library M2. It is written for package users; future Roadmap capabilities are called out explicitly.

Use these recipes after completing the Quickstart. Every example stays inside the 0.1.0 public boundary.

What generate() Produces#

generate(model, reductions=..., transforms=...) scans every entry returned by model.named_parameters(). It produces one observable for every parameter and requested reduction. The same ordered transform chain is attached to each one.

M2 does not expose a sources= argument and does not generate activation.*, grad.*, or loss observables. Filtering the returned list reduces Runtime work, although parameter metadata has already been enumerated:

generated = ol.generate(model, reductions=["l2_norm"])
wanted = {"param.0.weight", "param.2.weight"}
selected = [item for item in generated if item.spec.source in wanted]

M3 owns generation-stage template filters for parameters, gradients, and activations. Its exact source-selection API is not yet part of the public contract.

Transform Order And Validation#

Transforms run from left to right. For example, transforms=["center", "normalize"] executes:

normalize(center(tensor)) -> reduction

No subsets, reverse order, or permutations are generated. Every transform must be registered before generate() is called, and duplicate names are rejected. The registry validates names, not tensor contracts: incompatible rank, shape, dtype, or device assumptions fail when the observable runs.

Generated observables capture the resolved transform and reduction callables. Later registry changes therefore cannot silently change an existing observable’s computation.

Online Source Lifetime#

HookSource.get("param.*", step) reads the current model parameter directly. Parameter-only generated observables do not need hooks, so attach() is optional for that case.

activation.* and grad.* values are produced during forward and backward. Call source.attach() before that work and source.detach() when finished. Observe gradients after loss.backward() and before optimizer.step().

Current attach() behavior is broad: it installs activation hooks on all top-level children and gradient hooks on all trainable parameters. It stores the latest captured tensor and does not track freshness per step. Run the matching forward/backward before every observe() call; otherwise a previous value may remain cached. Activation ids use activation.<top-level-child-name> and gradient ids use grad.<parameter-name>.

Loss is not discovered automatically. To use a source="loss" observable, call source.record_loss(loss, step) first.

Within one observe() call, Runtime caches each requested source, so several reductions over the same tensor perform one source lookup.

Mix Generated And Custom Observables#

generate() returns list[Observable]. Add advanced custom observables before constructing a Pack:

generated = ol.generate(model, reductions=["l2_norm"])
gradient_spec = ol.ObservableSpec(
    source="grad.0.weight",
    selector="all",
    reduction="l2_norm",
    budget_hint={"compute_ms": 0.01},
)
gradient = ol.Observable(
    spec=gradient_spec,
    compute=lambda tensors, _context: tensors[gradient_spec.source].norm(),
)
runtime = ol.Runtime(ol.Pack([*generated, gradient]), source=source)

Runtime requires every observable id to be unique. A custom observable whose spec is identical to a generated one is rejected. When a Budget is active, custom observables should provide a realistic budget_hint; a missing estimate counts as zero.

User-Defined Filters#

M2 provides the Filter base class and & / | composition, but no built-in BySource or ByReduction filters. A user-defined filter acts after generation:

class ByReduction(ol.Filter):
    def __init__(self, name: str) -> None:
        self.name = name

    def apply(self, observables):
        return [
            item for item in observables if item.spec.reduction == self.name
        ]

keep = ByReduction("l2_norm") | ByReduction("mean")
selected = keep(observables)

This prevents unselected observables from running, but does not prevent their initial generation. M3 plans true generation-stage template filters.

Identity, Results, And Query#

There is no separate user-facing name field. observable.spec.id is a deterministic 16-character hash derived from the complete spec, including its source, selector, ordered transforms, reduction, temporal settings, frequency, and budget hint. Changing any identity field can change the id.

Runtime.observe() returns dict[observable_id, value]. LocalStorage stores metadata in SQLite and array payloads in NumPy NPZ files; it and query() use the same exact id and step:

observable = observables[0]
values = runtime.observe(step=7)
value = values[observable.spec.id]
stored = ol.query(storage, observable.spec.id, step=7)

For logs or user interfaces, derive a display label without treating it as the storage key:

spec = observable.spec
chain = " -> ".join((*spec.transforms, spec.reduction))
label = f"{spec.source} | {chain}"

M2 supports exact id/step readback only. Query by source, reduction, tag, or display label belongs to later analysis tooling.