Visual monitoring pipelines¶
The visual-language review defines the shared node, port, connection and state grammar, its current V1 execution profile, and the boundaries an extension must preserve.
The shared Pydantic definition is the public and persisted execution contract.
The control plane compiles topology, feature order, lineage, parameter defaults
and compatibility without importing numerical packages. Browser wire types are
generated by backend/scripts/generate_pipeline_types.py; the component catalog
also provides bounded field descriptors. An unsupported parameter schema fails
catalog generation instead of falling back to a JSON editor.
Each design has an optimistic-concurrency draft and immutable published revisions. A draft save commits its definition, layout, name and description under one expected version. The editor acknowledges the submitted snapshot while retaining edits made during the request as unsaved work. Publishing freezes that submitted snapshot; later local edits remain in the draft session. A run retains its executable definition, resolved input bindings, initial layout, service identity and predecessor independently of the live service record. Layout updates have their own version and do not change numerical compatibility. Runs also retain the resolved reading, entity and source labels, collection policy, catalogue revision and collection fingerprint. These presentation records do not change execution fingerprints. Historical legacy launches retain their original configuration; a reviewed compatibility normalization is used only when rendering their graph or preparing an explicit release restart.
Each component carries its implementation identity and contract version. Every
monitor runs a bound graph (launch version 2). The setup forms submit their
configuration, and TACTIC converts it with legacy_definition() into the
equivalent graph and activates it like a direct apply. Inputs follow the sorted
sensor-key order of the form, and a min-max step in front of a unit-interval
detector clips to its range. Monitors saved by the setup forms before they ran
as pipelines (launch version 1) are converted the same way at the next release:
each restarts as a pipeline and learns again, and one whose readings left the
catalogue is stopped instead, keeping its history.
Internal image pins and typed MQTT launch overrides are accepted only by trusted
activation/release paths, never public monitor creation.
Published revisions retain the catalog hash as provenance. Activation recompiles
the saved component contracts and requires the saved executable fingerprint to
match. Unrelated catalog descriptions or components do not invalidate a revision.
Creation workbench¶
?create=monitoring opens one setup page: the processing graph under a header
with the service name, an input summary that opens the Input step (entity and
reading bindings) and a learning shortcut that opens Service settings. A new
service opens at the Input step. Its main actions are Load template, Save
template and Apply monitoring. Users do not save drafts or publish
revisions to start a service. Service settings (learning counts and the
start-up timeline) and step parameters stay in the side panel; only advanced
input-slot editing is collapsed. useGraphMetadata checks every edit after
800 ms, and the status chip offers a retry when the check fails. A design has no
strategy to switch: each detector's settings choose its model, and with it how
it learns (see The editor without a lane). The
start-up estimate
(startup.ts) takes the slowest bound reading's typical interval, because RADAR
aligns observations across all readings. Entity selection includes configured
entities even when there is no recent traffic; collection and reading ownership
still gate Apply.
Standard setups show a Readings to monitor checklist directly below the entity: uncheck unwanted readings to monitor any subset, such as AC1 and AC3. Select all available and Clear support larger entities; a service accepts up to 100 readings. Selection changes the executable slots and bindings together and retains input IDs across removal, reselection and undo. Saved/custom templates keep explicit named input mappings, with Add reading and Remove on the setup page. Removing an input referenced by a processing component still requires fixing that component before validation and Apply can succeed.
Load template reads the latest saved definition and layout, independently of any
historical published revisions. Loading never changes the source template.
Template names and descriptions are separate from the service name. A template
can be saved without choosing readings or starting monitoring. Subsequent saves
use its expected version; a concurrent edit preserves the local work and offers
Save as new template. Applied service snapshots do not appear in this library.
Migration 013 adds monitoring_pipelines.is_template, preserving all existing
designs as templates.
Apply monitoring validates the graph and sends a typed
POST /v1/pipelines/apply request with its definition, layout, bindings,
service name and idempotency key. One server transaction creates the immutable
execution snapshot, resolves bindings and reserves the existing durable activation.
The normal preflight, source claims, capacity checks, worker heartbeat and
replacement controller are reused. Retrying the same request returns the same
operation. The browser retains uncertain requests across navigation and reload;
fields stay fixed until the start finishes or cancellation is acknowledged.
An explicit rejection permits corrections; a failed operation offers Return to setup.
Edit service opens its actual running definition, layout and bindings directly. Apply changes clearly explains interruption and fresh learning, then uses the same preflight and durable replacement path. Loading a template or editing settings does not affect the existing service until Apply. History remains immutable. Older library, revision and in-flight activation links remain supported.
Unsaved graphs infer feature metadata with POST /v1/pipelines/validate,
using the same typed, bounded compiler report, manage_monitors authorization and
gateway compilation rate budget as saved templates. This endpoint creates no rows.
Creation and templates share the same graph controller and account-scoped setup
session for bounded history, component insertion, arrangement and validation.
Template saves acknowledge only their submitted snapshot and preserve later edits.
Leaving for Sources keeps a save alive without navigating the detached screen.
Returning joins that operation and restores the setup. A remembered login restored
after reload retains the same member's drafts; logout and account changes clear
them, and late responses cannot repopulate cleared state.
The shared canvas uses a 16-unit snapping grid, fine blueprint lines and stronger cross markers in both themes. Visible lines become coarser at distant zoom levels while remaining aligned with the underlying snap positions. The magnet control switches drop, drag and keyboard positioning between snapping and free movement. This is a presentation preference: toggling it never rewrites saved positions or execution semantics. Existing layouts move only through explicit user actions.
Creation, saved designs and running graphs share a canvas-first workbench. Add component opens a searchable drag-and-drop menu; service and component settings share one collapsible inspector. Both panels start closed. Mobile overlays the inspector instead of narrowing the graph. Cards use larger titles, stage colors and typed ports; descriptions appear in component settings. Opening and arranging retain readable text size, while Fit entire graph explicitly requests an overview.
Detectors are the one step kind a design may repeat outside the add-step menu.
addDetector() copies the selected detector, model and settings, as
detector_N, connects it to the same input and appends it to the calibration
step, up to PIPELINE_LIMITS.detectors, which the type generator exports from
MAX_DETECTORS. occupiedInput() and connectNodes() treat the calibration
step like a merge (takesSeveral), so another detector's connection is added
instead of replacing one, and detectorIds() orders detectors by node ID as
RADAR does. Combine detectors has its own control (FUSION_CHOICES) on the
calibration step once there are two detectors; changing the calibration method
keeps it, and the calibration card leads with Strongest of 2 detectors.
featuresView() takes the detector to show, and detectorsAfter() lets a row's
menu say when a Prepare step on a shared path also reaches other detectors. A
sampled observation of combined detectors carries detectors, each detector's
score and surprise (ObservedDetector); each detector's own preview outputs its
score. The live inspector lists them for the policy, and the chart tooltip lists
an evidence row's detector_results.
A block is a named chain of transform steps that the library keeps
(monitoring_pipeline_blocks, migration 031_pipeline_blocks.sql) and the
editor copies into a design. No design, revision or run refers to a block, so
the compiler, RADAR and activation know nothing of blocks, and deleting one
changes nothing that runs. PipelineBlockCreate bounds a block to
MAX_BLOCK_STEPS (16) steps and MAX_BLOCK_BYTES (16 KiB) of settings, and the
library holds MAX_BLOCKS (100), so GET /pipeline-blocks returns every block
and the menus need no paging. require_block_steps() in
models/pipeline_blocks.py accepts the components the catalog offers at the
saved contract version with kind transform; each takes features and gives
features, so any of them can follow any other. It does not check settings: a
block has no inputs to check them against, and the design's validation checks
the copied steps where they land. PipelineBlocks (TACTIC
services/pipelines/blocks.py) writes under begin_library_write(), the
guards the template library uses, so blocks are read-only while authoring is
off. Names are unique (uq_pipeline_block_name). A block is saved and deleted,
never updated.
In the editor, blocks.ts holds the operations. blockChain() gives the steps
a card's Save as block can hold and blockSteps() their settings and
titles without ids or positions. placeBlock() adds a block as a chain of its
own, insertBlock() on a connection and addBlockAfter() after a card: on the
card's one connection out when the block fits there, otherwise as a branch.
Each returns a BlockOutcome, the changed document with the id of the first
new step or the reason the block cannot be added, and blockChoices() pairs
every block with its outcome, so the menus list all blocks and say why one does
not fit. Steps are added with addComponent(..., exact = true), which keeps
the saved settings, feature names included, where a new step's names are made
unique. On a connection, a learned step that insertBlocker() refuses is
exchanged through counterpartBefore() for the counterpart that learns as the
detectors after the connection do, the exchange changeDetector() makes.
useBlocks() reads the library once per editor that can change its design; a
failed read leaves the menus without blocks. PIPELINE_LIMITS.blockSteps comes
from MAX_BLOCK_STEPS through the type generator.
Monitoring history¶
The service Evidence tab presents one timeline for its recorded inputs and joint detector result. Readings retain independent scales and units; the raw anomaly score (or logarithmic static evidence) appears once below them. A dashed line shows the threshold, red input lines mark service alarms, and score dots mark alarms from the selected detector. An alarm a Mute alarms step held back is an open dot, and the key names open dots only when the chart shows one. The view works with multivariate models such as online isolation forest and does not infer per-sensor attribution or a baseline.
Up to six input tracks may be displayed simultaneously. Changing that selection only changes the view: results still describe the service's complete input set. Shared hover uses each result's original input timestamps to retrieve its exact recorded readings. Missing input history is labelled unavailable, never replaced with a nearby observation or a synthetic zero. Zoom and keyboard navigation apply to every track and remain fixed during inspection as new data arrives.
Telemetry polls per visible reading, while evidence polls once per input entity
with a service filter applied before database pagination. This optional
service_id filter is propagated through the gateway chart-evidence endpoint;
existing unfiltered consumers and cursor ordering retain their contract. History
uses the signed-in workspace's bounded cache (2,000 retained points per displayed
input and 2,000 result rows). Ordinary reading charts remain independent of a
monitor; when monitoring is present their alarm markers and results share the
same service scope.
Numerical ownership¶
Aberrant 1.3 supplies the standalone aberrant.compose.GraphPipeline numerical
API. OFF-KEY compiles into that API and owns bindings, lifecycle, calibration,
alarm policy, persistence and presentation. Aberrant has no dependency on these
application services or wire formats. Existing linear Aberrant pipelines retain
their existing behavior.
Adaptive graphs learn each shared node once in topological order. Only ready
outputs reach successors, including on the observation that first establishes
readiness. Detector warm-up counts successful terminal updates. Calibration starts
at the next scoring opportunity after both that target and actual readiness are
satisfied. Calibration and monitoring score before learning. Retained-value
readiness can wait for suitable data without retaining an unbounded input buffer.
Generated constructor schemas also encode retained-window capacity constraints
from the pinned runtime's parameter-sensitive capabilities. For example, geometric
averages require two retained values, or three with absoluteValues=true, while
harmonic averages permit a one-value window. Catalog generation verifies these
rules without introducing numerical imports into the control plane.
Static graphs fit each preprocessing component on training observations only. They freeze before transforming calibration data, fitting conformal calibration, or monitoring. Fitted preprocessing, detector and policy publish together after background training. Static centered batch PCA and adaptive uncentered incremental PCA have different implementation identities. Scalers and random projections also have strategy-specific identities. Changing preprocessing coordinates online does not re-express geometry already retained by an adaptive detector.
Raw branches, selection and explicit renaming retain lineage. Ordered merges join branches from the same aligned observation. Name collisions fail validation; projection features are scoped by their node ID. Selection uses stable slot IDs, while resolved reading topics remain the evidence identity. Expected units must match exactly; the application performs no implicit conversion. Incomplete drafts still expose every knowable feature schema. An invalid node blocks inference for its dependent paths while independent branches and valid upstream outputs remain available to the inspector. Executable compilation still requires the entire graph to validate.
Scalers and projections take an optional scope (pipeline_scope.py): apply_to
names the features they use, unset for all, and result is replace or add.
Elementwise steps add <feature>_<suffix>; a replacing projection drops the
features it uses and appends <node>.component_i. Untouched features keep their
order and added ones follow in apply_to order. Scope settings at their defaults,
and a suffix when replacing, are left out of the normalized parameters, so
designs saved before scopes existed keep their fingerprints. The compiler reports
an unknown apply_to name, a repeated output name or more components than used
features with their parameter path. Static graphs fit a scoped step on its
columns only. Adaptive graphs wrap the transformer in ScopedStep, which forwards
readiness and learning transactions; unscoped steps keep their plain runtime
shape, so existing checkpoints load unchanged. Tests pin that a scoped step
computes exactly what select, the step and an ordered merge compute, under both
strategies. Robust scaling uses scikit-learn's RobustScaler on training data
and Aberrant's RollingRobustScaler online; both use linear quartiles and scale
by 1 when a feature's interquartile range is zero.
Expected value steps (static.expected_value and adaptive.expected_value,
EXPECTATIONS in pipeline_scope.py) are always scoped. target names the
feature to expect and apply_to the features it is expected from, unset for
every other feature. step_scope lists the target first and gives the
residual's name, name or <target>_residual, as the scope's added feature. A
replacing step drops the target and the features it is expected from and appends
the residual, whose lineage is the slots of all of them. The compiler reports an
unknown target, a target among apply_to, a step that only the target reaches
and a name that repeats a feature that stays, each with its parameter path, and
refuses a static step whose training window holds no more observations than the
fit has coefficients. ExpectedValue (pipelines/expected.py) solves centred
least squares with NumPy on the training window; inputs that are constant or
repeat each other get the minimum-norm weights, so a fit never fails.
OnlineExpectedValue wraps Aberrant's RLSRegressor, with one coefficient per
input and a constant. It is ready after one observation per coefficient and one
more, and its learning transaction restores a deep copy of the regressor when a
later step rejects the observation. Scoring reads the fit before the
observation is learned; in a learning pass Aberrant transforms after the update,
as for every online step, so the detector learns the residual of the updated
fit. forgetting defaults to 1, because the covariance of a fit that forgets
grows without bound while its inputs stop varying. STATE_ESTIMATORS counts a
weight and a centre per used feature for the static step and eight covariance
matrices for the online one.
Prepare steps (pipeline_prepare.py) are deterministic kernels that run once
per observation, in arrival order, before anything learns. stage_split() takes
every Prepare node and its ancestors, except the source, as the stage. Its
frontier is the stage nodes, and the source, whose features a later node uses, in
compiled order. RADAR's PrepareStage runs the stage on the bound observation and
passes the frontier's features, keyed <node>.<feature>, to the learned stage:
static graphs fit and score those columns, and adaptive graphs read them through
Prepared input nodes. With several detectors, each detector's graph gets the
Prepared nodes its own steps read across the cut (adaptive_graph), not every
frontier node among its ancestors: a detector behind a Prepare step reads that
step alone, also when another detector reads the source. Designs without
Prepare steps keep their runtime shape, so existing checkpoints load unchanged.
Each kernel advances an immutable state per feature and returns a value, or none
while its window fills. The stage commits an observation only when every step
succeeds. A step that cannot use a value raises PrepareRejected, an
ObservationRejected: every state stays as it was, the observation becomes a
pipeline_observation_rejected coverage gap, and the run continues. Rejections
are counted per node by reason (non_finite, out_of_domain, out_of_range,
out_of_order).
A kernel with lead L outputs from its (L+1)-th value, so the stage is ready at
one plus the largest sum of leads along a chain. Until then the detectors return
preparing results, the run stage reads N/M observations prepared and no
learned node runs. The static strategy keeps preparing while it trains in the
background, so windows never restart. Checkpoints carry the stage state
(prepare, version 1), and a restore refuses state that does not match the
design. A valid check reports preparation_observations, the observations before
the first prepared one, which the composer shows as Preparing before learning.
The compiler marks Prepare outputs as derived (raw=False) and propagates bounds
through them. It rejects a Prepare step downstream of a learned one (stage)
and limits all windows to 50,000 values across their features
(prepare_budget), counting Kernel.held for each feature a step uses: one
window, or two for a Rolling relation. Means use math.fsum, standard deviations divide
math.sumprod of the deviations by n − 1, and order statistics keep a sorted copy
updated by bisection; an even window's median averages the middle pair. Despike
compares against 1.4826 × MAD and leaves a flat window unchanged. Change and
Rolling statistic default to result: add, with suffixes named after their
settings, such as diff1 and mean60; scope settings at a step's own defaults
are left out of the normalized parameters. A rolling quantile's suffix holds
the quantile as a percentage, q90_60 for 0.9 over 60 values and q99p9_60
for 0.999, rounded to six decimals of a percent; defaultSuffix in
scope.ts derives the same name.
Rolling statistics also support sum, linearly interpolated quantiles, IQR,
unscaled MAD and least-squares slope per observation. zscore,
robust_zscore and rank place the latest value in the window, and rms,
skewness and kurtosis (excess, population moments) describe it. The three
moment statistics divide the deviations by the largest one first, so no power
overflows or vanishes, and are clamped to the limits no window of n values
exceeds: (n - 1) / sqrt(n) for a z-score, (n - 2) / sqrt(n - 1) for skewness
and n - 5 + 1 / (n - 1) for excess kurtosis. These limits are the features'
known ranges, and rank has [0, 1]. A flat window gives 0, and 0.5 for
rank. Deviations from the mean, also for std, come from _deviations():
a window on one side of 0 is measured from its smallest value before the mean
is taken, because the mean of large values that differ by a few floats rounds
to one of them. std and rms take their root in _root_mean_square() with
the largest magnitude standing for 1, and rms never falls below the
smallest magnitude in its window, which is the lower end of its known range. robust_zscore falls back from 1.4826 x MAD to sqrt(pi / 2) x the mean
absolute deviation where the MAD is 0. The quantile setting is
normalized away for every other statistic, preserving existing executable
fingerprints. MAD and Despike find the median distance by binary search in the
sorted window, since the values nearest the median lie side by side there, so
neither sorts deviations. Slope multiplies the window with positions centred
on it, computed once per step and counted in admission estimates.
prepare.accumulate keeps a running sum or extreme in constant-size immutable
state per selected feature. A sum keeps the rounding error of its additions in
a second number (Neumaier) and emits total plus error, so a long total does
not drift. It emits immediately, shares Prepare's commit and
rollback behavior, and resumes from the same checkpoint state. Its default is
to add <feature>_total, <feature>_running_min, <feature>_running_max or
<feature>_count. State is (seen, value, carry); _carried() reads the
(seen, value) of checkpoints from before the carry as a carry of 0.
Three per-feature steps also read the row they run on. A kernel cannot parse a
condition, because pipeline_formula.py imports pipeline_prepare.py, so
lane_context(component, params, names) in pipeline_combine.py returns a
function from the arriving row to the extra arguments of Kernel.advance, or
none for a step that reads only its own feature. PrepareStage evaluates it
once per step and observation and passes the result to every feature's
kernel; a PrepareFailure in it skips the observation like any other.
prepare.accumulatetakesreset_when, a condition (LANE_CONDITIONS). Where it holds, the kernel starts frominitial()before it takes the value, so that observation is the first of the new accumulation. The setting is left out of the normalized parameters when empty, so earlier designs keep their fingerprints.countemits the observations since.prepare.hold(Hold) takesupdate_whenand keeps(seen, value): it takes the value where the condition holds, and always on its first observation. It has no lead and adds<feature>_held.prepare.relation(Relation) receives the value ofagainstand keeps both windows. Deviations from each window's mean are divided by the largest one beforemath.sumprod, so products neither overflow nor vanish, and the scales return in the result:correlation(clamped to [-1, 1], its known range),covariance(n - 1),slopeof the feature onagainstandresidual, the latest value's distance from that line. A window that holds one value is flat by comparison of its values, not of its mean, sincefsum([0.1] * 3) / 3 != 0.1; a flat window on either side gives 0, and forresidualthe latest deviation from the feature's own mean. Suffixes arecorr60,cov60,beta60andresid60.step_scoperequiresagainstto arrive, selects every other feature by default and refusesagainstinsideapply_to; the feature's lineage includes the slots ofagainst.
Conditions are checked like a Gate's: names and kinds as formula issues on
the setting, and values warns where a condition is always or never true.
Two steps read the time an observation was measured at (reads_time()).
MessageProcessor passes the observation time of the aligned vector, the
latest of its input_timestamps, through DetectorHandle.process() and
process_data_point(..., timestamp) to PrepareStage.run(bound, diagnostics,
timestamp); Dojo's Replay passes the recorded time, and a caller without
one gets the worker's clock. The stage keeps times[step], when each such
step last used an observation, and hands the step a Clock(now, elapsed)
(pipeline_time.py). times commits and rolls back with the kernel states,
so an observation that is skipped, or that a Gate before the step holds back,
leaves it alone and the next elapsed spans it. An observation not later than
the step's previous one is rejected as out_of_order: alignment already
refuses regressing readings, so this only meets a checkpoint that is ahead of
the stream. elapsed is therefore above 0, and the compiler gives dt the
minimum POSITIVE, the smallest positive float, so a / dt draws no
division warning. rangeText() reads a bound that close to 0 as above 0.
prepare.time(TimeFeatures, a row step) appends the features its settings switch on, in the order ofTIME_FEATURES:dt;hourin [0, 24] andweekdayin [1, 7];hour_sin,hour_cos,weekday_sinandweekday_cosin [-1, 1]. The calendar followstimezonethroughzoneinfo, validated when the design is checked;UTCneeds no zone database. Withelapsedthe step has a lead of 1 (step_lead()), since the first observation has nodt. A name that already arrives is ascopeissue on the setting that would add it, and a step that adds nothing is refused.timezoneis left out of the normalized parameters while no calendar feature reads it. The features carry no slots, and the inspector lists them as From no reading.prepare.smoothwithmethod: time_constant(Decay) moves its level towards each value by 1 - exp(-elapsed / time_constant), computed withexpm1, and starts at the first value. State is(seen, level), the lead is 0 and the suffix names the seconds:smooth60s,smooth2p5s. Normalization dropswindow_sizefor this method andtime_constantfor the others, so earlier designs keep their fingerprints.
Prepare checkpoints stay at version 1 and gain times, which must name
exactly the time-reading steps that have used an observation; a checkpoint
from before has none and loads as long as the design has no such step that
already ran. PipelineTime.tsx is the editor of prepare.time: one checkbox
per setting in the classes of Applies to, the last one locked, and a
Time zone field with the browser's zone names as suggestions, shown once
the hour or the weekday is added.
prepare.constant, prepare.condition and prepare.set are stateless row
operations on the existing numeric feature stream. Constant and Condition append
a named feature and reject collisions; a condition emits 0.0 or 1.0 without
withholding the observation. Set replaces exactly its named column in place or
appends it, preserving all other columns. All expression references contribute
to lineage; constants have none. A Set whose expression reads the column it
replaces keeps that column's previous and extends its steps; otherwise the
column starts at the Set. A name Set adds must match the pattern of computed
names (scope issue on name). These operations retain the existing source,
feature DAG, detector, policy and evidence contracts.
prepare.calculate (Calculation) is a Set per line. formulas holds up to
32 (MAX_CALCULATION_LINES) lines of name = formula; blank lines and lines
that start with # are skipped. assignments() parses each line once per
text (lru_cache) and checks names top to bottom against the features that
arrive plus the names written above, so a line cannot read a later one. The
row step copies the row and writes each feature in order; a failure names the
feature it was computing. Columns follow Set: an existing name is replaced in
place and continues its trail when the line reads it, a new one is appended.
Issues carry parameter_path: ["formulas"], a message that starts with
Line N: and a position and fix counted in the whole text, so the editor
applies a fix to the right line. calculateLine() in formula.ts gives the
editor the names written above the caret and whether the caret is still in
the name, where only features are offered.
Aggregate, Formula, Gate and Mute alarms (pipeline_combine.py) work on the whole
row, keep no state and have output from the first observation. A recursive-descent parser in
pipeline_formula.py reads formulas and conditions, never eval. Its tree serves
the compiler, which checks names and kinds and infers ranges by interval
arithmetic, and RADAR, which compiles it into closures once per run. An
expression holds at most 512 characters, 32 nested levels and 100 levels of
operations. Problems raise FormulaError with a 1-based character position and
a difflib suggestion, reported as formula issues on the setting. Evaluation
raises PrepareFailure for a division by 0, a value outside a function's domain
or a result that is not finite, so the observation is skipped like any other
Prepare rejection; and and or short-circuit, so a guard such as
x > 0 and log(x) > 1 holds. The compiler's values check derives, from the
ranges of their inputs, the values a Math step, a percent Change, a Formula or a
Gate will skip: a warning when some are skipped and an error when every
observation would be, or when a condition is never true. It also warns about a
Condition whose flag never changes and a Set that reads no feature, and
constant_feature warns where a feature whose minimum equals its maximum
reaches a detector or a learned step that uses it. Seeded fuzz tests
check that any text parses or fails with FormulaError, that evaluation fails
only with PrepareFailure, that every value lies in the inferred range, also
behind guards of the shape the analysis narrows on, and that a value outside a
function's domain is never skipped without a warning.
Further functions take numbers only: sign, floor, ceil, round (a
half away from 0), mod (floored, with the divisor's sign; a divisor that can
be 0 warns like a division), sin, cos, atan2, hypot, mean, sum and
pi(). A bare pi is a feature name, and an unknown one offers pi(). Each
has a range: sin and cos [-1, 1], mod between 0 and its divisor, atan2
by the half-planes its point can lie in. Left of the origin a y of 0 gives pi
or -pi by its sign, and -0.0 counts as 0 in a range, so only a y that stays
off 0 narrows there.
The numeric if(condition, when_true, when_false) function evaluates only its
selected branch. Its first argument must be a condition and both remaining
arguments must be numeric. Compiler analysis visits reachable branches, narrows
simple guarded feature ranges, and keeps conditional domain failures as warnings
when another branch can succeed. All referenced feature names are still checked,
including names in a currently unreachable branch. The same short-circuit rules
apply to conditions used by Gate and Condition.
A Formula with apply_to is a formula for each feature (FormulaForEach): it
evaluates the expression once per chosen feature with x bound to that
feature's value, always against the incoming row, and either adds
<feature>_<suffix> (suffix formula by default) or replaces each value.
each_scope checks that the features arrive, that no incoming feature is
itself named x, and that the added names are free; name with apply_to is a
parameter error. The compiler analyses the expression per feature, so ranges,
lineage and values issues are per added feature, and it warns when x is
never read. A plain Formula that reads x without such a feature arriving is a
formula error at x. Formulas without apply_to normalize as before, so
their fingerprints are unchanged.
A Gate whose condition is false withholds the observation: the steps before it
commit their states, the steps after it do not run, and PrepareStage.run
returns Withheld("held_back", gate). Both detectors return a held_back result,
which is neither learned, scored nor persisted and is no coverage gap. The
activity tracker counts it as progress, so a Gate that stays closed does not read
as stalled inference. A rejection anywhere in the pass wins over a closed Gate.
Stage readiness follows each step's own update count, so observations held back
before a window do not fill it: a step needs the observations its predecessors
still need, then its own warm-up. The stage state counts held_back; checkpoints
without the count load it as 0.
A Mute alarms step (prepare.mute, Mute in pipeline_combine.py) passes the
row on unchanged and reads its condition. The compiler checks the condition like
a Gate's with the verdicts turned round: always true is an error, because no
alarm would ever be raised, and never true a warning. PrepareStage.run sets
muting when a step's condition holds and the observation reaches the learned
stage; an observation that is withheld has no alarm to mute. The stage state
counts such observations as muted, revert takes the count back, and
checkpoints without it load it as 0. A condition that cannot be read rejects the
observation as any step's failure does.
The detection rule sees a muted observation as any other, so calibration,
adapting thresholds and consecutive counts go on. Each service asks
_raised(fired) (PipelineExecutionMixin) where it decides an alarm: the
adaptive and mixed services on the score threshold and the boundary service on
its judgement. While the stage is muting, a fired alarm is not raised: the
result is no anomaly, the context carries alarm_muted, and the evidence row
keeps alarm false and muted true (migration 030_evidence_muted.sql). Those
rules judge every observation anew, so an alarm that still stands when the mute
ends is raised then. A martingale tracker fires once, where its statistic
crosses the threshold, so MartingaleTrackerController.update(p_value,
muted=True) holds a muted crossing in _held_alarm: it is raised at the first
observation that is not muted while the statistic is still above the threshold,
and dropped when the statistic falls back first. The tracker's result carries
alarm_muted at the crossing, and pickles from before the field load it as
false. A Dojo replay that compares calibration methods records no method alarm
for a muted observation. tests/pipelines/test_mute_alarms.py holds each
service to these rules.
A failure in the learned stage costs one observation too, when no learned state
changed. Aberrant's GraphPipeline.score_one only reads committed state, and
learn_one rolls every learner back when a step fails before the detector
learns; both raise GraphExecutionError. The adaptive detector turns that into
StepRejected(node, "error", message), the ObservationRejected that
PrepareRejected also extends. A GraphStateUncertainError, raised when a learner
without a transaction, such as the detector, may have changed, still fails the
run. In the static strategy BatchFeatureGraph raises NonFiniteFeatures when a
fitted step returns values that are not finite: fatal while fitting, and a
non_finite skip while scoring one observation, which only reads fitted state.
Before raising, _skip() calls PrepareStage.revert(), which restores the
states the observation's Prepare commit replaced, so a skipped observation
changes no state anywhere. The step's diagnostics count it under rejections.
Each FeatureDescription in a validation report says where it comes from.
previous names the feature it continues at the step before, as node.name, and
steps lists the steps that changed the feature or its name. Readings have no
previous, and neither have the features an Aggregate, a Formula or a projection
makes; their steps start with that node. Neither field changes a fingerprint,
and the report's budget pass drops them along with slots. The composer's
Features view follows previous back from the detector's input to draw one row
per feature. Its + places a step with rowPlacement(): a Prepare or Combine
step on the wire into the first learned node on the feature's path, or into the
detector when there is none, and a learned step on the detector's input wire,
each scoped with apply_to to that feature.
A valid report's ready_after maps each step of the Prepare stage to the
observation, counted from 1, that gives its first output: one plus the leads
along its longest chain. The inspector shows it as Ready after N observations,
and as at least N after a Gate, because held-back observations do not count;
the start-up timeline says at least for the same reason. A formula issue can
carry a fix: the characters start to end and the text that replaces them,
such as the closest known feature for an unknown one, written as a formula writes
it. The composer offers it as Use …. The formula editor suggests the features
that arrive at the step, the functions and, in a condition, and, or and not.
Validation issues carry a severity. Errors block compilation, publishing and Apply; warnings use the same shape, appear on the step and in the status chip, and do not. The inspector keeps the last checked features while an edit is checked, so feature choices do not jump between checks.
A design may combine up to four detectors (MAX_DETECTORS), each with one
input. The policy takes one connection from each, and its fusion setting,
Combine detectors in the composer, says how their scores make one: max
(Strongest, the default) or mean (Average).
PipelineValidation.detector_ids orders the detectors by node ID, detector_id
is the first of them and detector_inputs maps each to the node it reads; a
policy with several inputs has no features of its own. fusion is left out of
the normalized parameters at max, and with a single detector, so existing
designs keep their fingerprints. The compiler reports a design without a
detector, one with more than four and a detector the policy does not read.
derive_monitoring_config describes the first detector.
Schema 2: learning modes and calibration¶
Schema 2 (pipeline_versions.py) describes a design without a lane. Each component
says how it learns (PipelineComponent.learning: none, batch, online or
pretrained), and a calibration step sits between the detectors and the alarm
policy. Detectors give scores; the calibration step turns them into p_values
for the policy. calibrate.fixed_split is what both lanes did: the learned
steps freeze and the calibration window gives conformal ranks. It owns fusion;
the policy keeps its own parameters, including the quantile. Every other
calibration step keeps adapting (see below). A forecaster step gives forecasts,
which only a calibration step reads.
A forecaster step is a detector that scores one feature by how far it misses the
forecast made for it. FORECASTER_IDS in models/adaptive.py lists them; each
is ABERRANT's residual_detector around one forecaster (seasonal_naive,
holt_winters). A step's settings are the forecaster's own plus lead, how
many updates ahead it forecasts, and score. aberrant_model() nests them into
the residual detector's configuration, binds the step's single feature as the
target and sets the forecaster's horizon to the lead. Warm-up is two seasons plus
lead - 1 updates and must fit the training window. The composer lists them
under Forecaster (stepFamily), together with pretrained forecasters.
A forecaster step's port is forecasts: the score, and with it the forecast the
observation was scored against. Any calibration step reads it as it reads
scores (_check_ports, takes() in the editor), beside other detectors or
alone. A scored result then says what each forecaster step expected, in
context["adaptive_stream"]["forecasts"] (forecast_band.py): one entry per
step with detector_id, target, lead, expected (the forecast's point at
that lead) and slot, the reading the target is when no step changed its
values, or null. lower and upper are the calibrated interval, the target
values whose score stays at or below the boundary the observation met
(aberrant.forecast.scores.interval), so a value outside it is above the
boundary. They are null without a boundary in the step's own score units:
detectors combined into ranks, or a boundary that is still infinite. A run
reads the forecast after the step scored the observation and before it learns
it (_expect), because a pretrained step fetches a forecast its service missed
while it scores. The key is absent before a forecast exists and outside the
operational phase.
Evidence stores the entries as forecasts (migration 026;
_normalize_forecasts in result_records.py keeps an interval only whole), and
the catalogue API returns them with each evidence row. In the charts,
buildDetectorInputSeries puts each forecast on the line of the feature it was
made for, which it finds by detector_id and target among the row's
detector_inputs. The feature's pane then shows the forecast dashed in the
feature's colour and the interval as a band: two stacked lines, the lower bound
and the width above it, so only the space between the bounds is filled. A Dojo
replay does not store forecasts yet.
A pretrained forecaster is a forecast. component with learning pretrained.
Its weights live in the forecast service (off-key-forecast), never in a RADAR
worker, so it is not an ABERRANT component and not in adaptive_catalog.json:
PRETRAINED_FORECASTERS in models/adaptive.py describes it beside the
generated models, keyed by its component, and detector_component() gives the
component of either kind. The first is forecast.timestral, with a checkpoint
(TIMESTRAL_CHECKPOINTS, the ones the service offers), a context_length (how
many recent values the model reads), a minimum_context (how many values must
have arrived before the first forecast), and lead, score and min_scale as
for the other forecasters. It reads one feature and no step that learns. It
scores and then takes in every observation, as a detector that learns
continuously does, so it runs wherever one runs: alone or beside detectors of
either kind, under the fixed split with the calibrated score threshold or under
a calibration step that keeps adapting. The conformal alarm policy does not run
on it.
In RADAR, off_key_mqtt_radar/forecasting.py gives the service ABERRANT's
forecaster interface. ServiceForecaster keeps the recent values and asks the
service for quantile forecasts; forecast_from_quantiles() turns them into
ABERRANT's Forecast, with the median as the expected value and the
interquartile range as the spread. ServiceResidualDetector is ABERRANT's
residual detector around it, and resolve_service_detector() answers what
RADAR asks of a resolved ABERRANT detector: configuration, capabilities,
fingerprint and build(). A worker finds the service through
FORECAST_API_URL and FORECAST_API_TOKEN. TACTIC puts the address
(TACTIC_FORECAST_API_URL) into the environment of every RADAR worker and
preflight job, outside the configuration fingerprint; the token reaches a
production worker as a Swarm secret. Preflight builds the step without
calling the service, but a deployment that names no service fails it there,
on the step, with the issue forecast_service.
Forecast service describes the deployment,
the pinned weights and their sizes.
The HTTP client is shared: ForecastClient in
off_key_core/utils/forecast_client.py. TACTIC uses it for the composer, in
services/pipelines/forecast_models.py: GET /v1/forecast-models answers the
service's models with their download state, and whether the deployment has a
service at all (not_deployed) or one that does not answer (unreachable);
POST /v1/forecast-models/downloads starts a download and needs
manage_monitors. In the frontend, useForecastModels loads that list for the
workbench and asks again every three seconds while a checkpoint downloads.
Where the deployment has no service, withForecastService marks the
forecast. components unavailable, which addBlocker returns, so no menu
adds the step; a service that only does not answer leaves it on offer.
PipelineForecastModels lists the checkpoints in the step's settings.
The first value of a run asks the service to have the checkpoint ready, which
starts a download if the weights are not there yet. Until a forecast exists
for an observation, that observation is rejected (ObservationRejected): lost
coverage, not a normal score. A run does not learn a rejected observation, so
the adapter keeps the value itself and the forecaster's series has no gap. The
weights never change, so a forecast the service missed is made later from the
same values, and scores after an outage are the ones an undisturbed run gives.
A service that hangs is left alone for
15 seconds after a slow failure. A service that does not offer the checkpoint
ends the run (FatalExecutionError), because no later value could be scored.
Schema 1 designs are read as schema 2: upgrade() moves the policy's inputs onto
a calibrate.fixed_split step and its fusion onto that step, and drops the
stored strategy. A design holds 65 nodes and 129 edges, the calibration step and
its edge into the policy included; a schema 1 design is held to its own 64 and
128 before the step is added. The serializer writes downgrade(), the schema 1
form with the strategy its learned steps share, whenever a design has one: a
single fixed split in front of the policy and learned steps that all learn one
way. Stored designs and fingerprints therefore do not change. The editor reads
and writes schema 2: every response that carries a design for the editor
(EditorDefinition) serializes editor_form(), the upgraded design without a
strategy, and the catalog serves schema 2 components and templates, with the
calibration step, its ports and each calibration method's family, coverage
and reference. Validation reports list the calibration step like any other.
The compiler checks schema 2 designs by learning mode instead of by lane
(_validate_learning):
learning: a step reads a learned step that learns differently. A batch detector reads batch or stateless steps, an online one online or stateless steps, and a pretrained or rule detector stateless steps only.calibration: the martingale policy needs exact p-values, so no step in front of it may keep learning after calibration (no online step). A policy also has to fit its calibration step: the martingale and the score threshold need the fixed split, the exceedance rule a step that adapts.calibration_window(warning): the calibration window is too short for the alarm rate of a step that adapts.
The rules leave one policy for a design. A calibration step that adapts alarms
by the exceedance rule. Under the fixed split, detectors that are all fitted
once alarm with the martingale, and a design with a detector that takes in
every observation (one that learns continuously, a pretrained forecaster or
a rule detector) alarms on the score threshold; the compiler reports the threshold behind
detectors that are all fitted once as calibration.
A rule detector is the component rule.score (RULE_DETECTOR). It learns
nothing, so its learning is none, and it scores every observation as it
arrives, so streams() in pipeline_versions.py is true for it as for an
online or pretrained detector: the compiler checks its input like theirs,
derived_strategy() counts it towards the adaptive lane, and RADAR runs it
among the streaming detectors. Like a pretrained forecaster it is RADAR's own
and not in adaptive_catalog.json: RULE_DETECTORS in models/adaptive.py
describes it beside the generated models, with no settings and exactly one
feature, bound as key. resolved_detector_config() resolves it through
rules.resolve_rule_detector() to a FeatureScore, an Aberrant model whose
score_one returns that feature's value and whose learn_one does nothing,
so it sits at the end of an Aberrant graph like any other detector. In the
compiler _learned() lists it as rule: a step that learns can't feed it
(learning), and the martingale policy does not run on it (calibration). A
run whose detectors all follow a rule (_learns_nothing()) starts in
calibrating instead of warmup and reports a warm-up requirement of 0;
beside a detector that learns, the run warms up as that detector needs.
TACTIC's available_models(), which /v1/detectors serves to the setup form,
leaves it out: a rule needs a step before it, and the rollback rehearsal
expects settings for every model that field discovery lists.
No design stores a lane. PipelineDefinition.lane is derived: the strategy all
a design's learned steps share, or none. Routing and the schema 1 written form
use it; the compiler does not.
derive_monitoring_config follows the policy: the martingale gives static
baseline settings, the score threshold adaptive stream settings named after the
first detector that learns continuously. RADAR picks its service the same way
(detector_services.detector_service_class): a threshold run with a detector
fitted once runs MixedDetectionService. It is the adaptive service with a
BatchBranches beside its Aberrant graphs. While the online detectors warm up,
the others buffer the latest training window; during calibration the online
detectors score and learn while the others buffer the calibration window. Then
the run is fitting: the detectors fitted once and the steps they read
(BatchFeatureGraph(plan, detector_ids)) fit in the background on those
windows, with split conformal calibration as in a static run, and the online
detectors keep learning without evidence. When the fit publishes, each
detector's calibration scores form one column of the RankFusion, and the run
alarms as a combined adaptive run does. A Dojo replay waits for the fit, so it
scores every observation after calibration. Checkpoints keep the buffers or the
fitted detectors (batch) beside the adaptive state and refit when they were
taken while fitting.
Calibration steps that adapt¶
calibrate.<method> runs a method of aberrant.calibration: rolling split CP,
ACI, DtACI, AgACI, the quantile tracker, conformal PID, NexCP and eight more.
calibration_catalog.json describes them (name, reference, family, coverage,
parameters). RADAR generates it from the installed aberrant
(python -m off_key_mqtt_radar.catalog --write, checked with --check), so the
control plane imports no numerics; pipeline_calibration.py builds one parameter
model per method from it. A step takes alpha, the target alarm rate, the
method's own parameters and fusion. The method's window is the design's
calibration window. The editor offers them as the Calibration method of the
calibration step.
There are three stages, and each claims only what it can:
- Detectors give scores.
- The calibration step issues a boundary for each observation before it sees the
score, then learns the score. The calibration window seeds it. What a method
holds is the long-run share of scores above the boundary, at
alpha. That share counts every score above the boundary, anomalies included, so it is not a false alarm rate, and a method that adapts also gets used to a lasting anomaly. Methods of the Bayesian family hold no such rate (coverage: none). policy.exceedance, the detection rule, alarms onceconsecutivescores in a row are above the boundary. With more than one the alarm rate falls belowalpha, but not toalphato that power, because exceedances come together.
The martingale and the score threshold stay with the fixed split: only its p-values are exact, and only it freezes a threshold.
A boundary that is a quantile of the window (split, aci, dtaci, agaci)
needs ceil(1 / alpha) - 1 calibration observations, or it sits at the largest
score seen. Several detectors need that for any method, and
ceil(k / alpha) - 1 for k detectors under max, because each reaches the top
of its window about once in n scores. The compiler warns below that
(minimum_window).
Comparing calibration methods in the Dojo¶
A Dojo run can try the other calibration methods beside its own
(compare_calibration). TACTIC stores the plan with the run
(calibration_comparison in dojo_runs.config, from
models/dojo_runs.py): the run's own method, the methods to try in order, and
the alpha and consecutive they share. The replay installs a
CalibrationComparison (calibration_comparison.py) on the detector service.
The service hands it the calibration window where its own step starts, and then
the detector scores of every operational observation after the detectors took
it in, so each method sees exactly what a run with that calibration step would.
Several detectors meet as in a run: against their latest scores for a method
that adapts (RollingRanks), against their calibration scores for the fixed
split (RankFusion). tests/radar/test_calibration_comparison.py holds each
compared method to the alarms of a replay whose design has that step.
A monitor never has a comparison; only Replay sets one. Each result carries
method_alarms, one bit per method in the plan's order, stored in
dojo_run_results.method_alarms (migration 027) and null before the run is
operational. A method that raises leaves the comparison and the worker writes
the reason to calibration_comparison.failed when the run ends; such a method
keeps the counts of what it found and gets no share or rate, which would be
over results it never judged. TACTIC scores
each method on read like a run of its own (DojoMetrics.calibration), so a
moved label re-scores the methods too. All 15 adapting methods together cost
about 2 ms per observation, mostly the Bayesian ones, which is why the
comparison is asked for per run.
The editor without a lane¶
The composer has no strategy switch and no strategy.ts. learning.ts holds
what replaced it:
- A card says how its step learns (
learningLabel: Learns continuously, Fitted once, Pretrained, and No training for a detector that learns nothing), and the add-step menu lists every step with that tag.learningBlocker()refuses a step in front of a detector that learns differently, with the compiler's reason, before the step is added. - Detector model lists the models by how they learn. Choosing one that
learns differently (
changeDetector) moves the learned steps only that detector reads to their counterparts (COUNTERPARTS) and fits the policy. - The calibration step has its own card between the detectors and the detection
rule. Calibration method lists the methods by family (conformal, Bayesian)
and says what the chosen one holds (
coverage), with its reference.changeCalibration()keepsfusion, takes the new method's defaults and fits the policy. - Detection rule is the policy.
fittingPolicy()names the one policy the design allows; the picker marks the others (does not fit this design). After a change that moved steps or the policy, a status line says what changed (changeNotice) and Undo reverses it in one step. - Service settings name their first window after what the detectors do with it:
Training observations, Detector learning updates before calibration, or
both for a design that mixes them.
startupPhases()takes the same view. A design whose detectors all follow a rule has no such window (learningWindow()isnone): the setting is left out and the estimate starts with calibration.
normalizeDefinition() drops a strategy and stamps schema_version: 2 on what
the editor sends. A layout saved before the calibration step had a card gets one
at the policy's place, with the policy and everything right of it moved one
column over (placeCalibration). RADAR reports calibration under the policy, so
the run view shows the policy's state and diagnostics on the calibration card
too.
BoundaryDetectionService runs every design with such a step: detectors fitted
once, detectors that learn every observation, or both. It is the mixed service
with a ScoreBoundary (boundary.py) where that one freezes a threshold.
Warm-up, the calibration window and the background fit are the same. The
calibration window's scores then seed the boundary, and each scored observation
is judged against the boundary issued for it and then learned by the detectors,
the boundary and the rule's count, in that order; an observation a detector
fails on moves none of them. Results are adaptive_stream results with the
boundary as threshold, so evidence rows, charts and the Dojo read them as a
threshold run's. derive_monitoring_config names the first detector that takes
in every observation; when every detector is fitted once it gives static
baseline settings for the windows and the seed, while the results stay
adaptive_stream. Checkpoints keep the boundary and are refused when the
design's calibration step or rule changed.
One detector's raw score is the method's score. Several detectors meet as ranks,
as under the fixed split, but against each detector's latest n scores
(fusion.RollingRanks, n the calibration window) so the ranks follow the
detectors. A share alone stops at 1, where a score just above the window and one
far above it would tie and a boundary that has reached 1 could never be
exceeded. A score above its whole window therefore adds how far above it is, in
units of the window's range. The surprise reported per detector stays a share.
fusion.RankFusion combines detectors by rank, so detectors on different scales
need no tuning. A detector's surprise for a score s is the share of its n
calibration scores strictly below it, u = #{i : c_i < s} / n. Strongest combines
the counts by their maximum and Average by their sum; both stay integers, so ties
are exact, and value() maps a combined count back to [0, 1]. Counting strictly
below keeps one detector's rule: with one detector, or the same detector twice,
combined > threshold_count(q) holds exactly when the score exceeds the
method="higher" quantile q of the calibration scores.
The static strategy makes the combined count a conformal p-value. p_value()
ranks the new observation among the calibration observations: on each detector
a calibration observation counts the calibration scores below it, plus one when
the new score is lower, and the new observation counts the calibration scores
below its score. Without a change these n + 1 combined counts are exchangeable,
so the new observation's rank among them, with ties broken by a seeded
generator, is uniform on 1 to n + 1. The p-value, rank / (n + 1), takes the same
values with the same law as a single detector's, so the martingale trackers and
their thresholds work unchanged. A test draws 10,000 calibrations, with
continuous and with tied scores, and checks that law with a Kolmogorov–Smirnov
test. FusedStaticPipeline keeps a ConformalDetector per detector, fitted on
that detector's training and calibration features; BatchFeatureGraph fits each
shared step once and returns every detector's input (fit_transform_all). The
tie-breaking generator is saved with the checkpoint.
The adaptive strategy builds one Aberrant GraphPipeline per detector from the
nodes it reads (ExecutionPlan.reads), so each detector learns its own copy of a
shared step; the copies see the same observations and stay equal. Every detector
scores an observation before any of them learns it. A failed score skips the
observation for all of them, and so does a learning failure of the first
detector, before anything has learned. A later detector that fails cleanly misses
that one observation and counts an error rejection; GraphStateUncertainError
still stops the run. Warm-up ends when every graph is ready and the slowest has
made the configured updates. Calibration keeps each observation's scores; when it
is full, RankFusion freezes them, the threshold count is the policy's quantile
(method="higher") of the calibration observations' combined counts, and an
observation alarms when its combined count exceeds it. The anomaly score is the
fused surprise. Diagnostics report a shared step once, under the first detector
that reads it (ExecutionPlan.owned), and the model nodes per detector.
A combined run's checkpoint holds pipelines, detector_configs and
detector_fingerprints in detector order in place of pipeline, and a restore
refuses any difference. Admission counts the rank fusion, five n × K arrays of
doubles plus the lists an adaptive run collects, and each adaptive copy of a
learned step's state. Preflight builds every detector and reports a failure on
its node. Results carry each detector's detector_id, score and surprise in
their context, and processed_features holds each detector's features under its
ID. Evidence stores them as detector_results (migration 015), while p_value
or anomaly_score holds the fused value. Runs with one detector write an empty
list and keep their results, checkpoints and diagnostics. detector_inputs
(migration 017) keeps the features each detector scored, after every step before
it, so charts can plot derived features: one entry with a null detector_id for
a single detector, or one per detector ID for a combined policy, at most 64
features each.
Activation and recovery¶
Activation persists reservations before remote work. Claims cover the union of predecessor and successor inputs and reserve one successor capacity slot. Every form start, graph activation, collection mutation and release consults this authority under the collection configuration lock.
The controller runs credential-free constructor preflight in the exact target
RADAR image digest before retiring a predecessor. The report binds the executable,
catalog, collection and bindings to that digest. It reports known numerical
allocation costs; it does not prove data-dependent fitting or predict every model
allocation. Preflight has separate workload identity, bounded concurrency and
timeout, and cleanup ownership. A fresh nonfailed heartbeat confirms activation;
warm-up remains a run stage.
Activation supervision uses separate bounded capacity for preflight, remote
transitions and database checks, each with independent sessions and durable
leases. Orphan cleanup runs separately. Configure constructor capacity with
TACTIC_PIPELINE_MAX_CONCURRENT_PREFLIGHTS (default 2); remote transitions and
checks each use TACTIC_PIPELINE_MAX_CONCURRENT_TRANSITIONS (default 8). Oldest
updated operations are considered first. The database records the first healthy
heartbeat receipt, preserving timely startup evidence across later heartbeats
and delayed controller checks. A first heartbeat received after the deadline
cannot establish successful startup.
Remote uncertainty retains reservations until shutdown is confirmed. Cancellation
before retirement preserves the predecessor. Cancellation after retirement creates
no implicit rollback. Stopping a predecessor also cancels its pending replacement.
Reconciliation resumes durable operations after controller restart.
The durable sequence is requested, preflighting, validated, retiring,
starting, awaiting_heartbeat, then succeeded; cancellation/failure uses
cancelling until cleanup is confirmed. A controller token and expiring lease
fence every resumed phase and error transition after remote I/O. Stale replies
cannot move a newer activation back to an earlier phase.
Terminal lifecycle transactions lock checkpoint, run and service records in that
order, including service deletion's checkpoint cascade. Heartbeat acceptance and
fatal-run recording serialize their run/service updates so a stale healthy
heartbeat cannot overwrite a committed fatal state.
Collection stops, deletion and reconciliation share complete_stopped_runs
beside the lock helper. It retains failed/cancelled outcomes, error details and
the first terminal timestamp across repeated cleanup.
Deployment reserves maintenance before quiescing writers. It refuses a pending activation, and active maintenance prevents creation and handoff in both HTTP and background paths. Cleanup remains permitted. Release snapshots preserve graph definitions, bindings and run lineage; a software upgrade creates a fresh run when numerical compatibility requires it.
An uncertain mutation crosses resilience and message processing as a fatal error. The worker stops graph execution, blocks checkpoint export including shutdown, preserves the last signed snapshot and records failed-run coverage. That failed generation cannot be admitted again. A graph revision rollback starts a fresh run; same-run task recovery restores only compatible signed state.
Bounded inspection¶
Diagnostics observe the actual serial scoring and learning passes. They perform no additional inference, MQTT subscription or Docker inspection. At most one observation envelope is sampled per second and retained, with eight input/output values per node. Unexecuted nodes carry a reason and never reuse previous values. The serialized snapshot is capped at 128 KiB, with status and counters taking priority over previews. Static fitting appears as a batch summary. Adaptive nodes also report committed update counts, observed/required history in the component's own units, and their immediate blocking prerequisites. A zero required count means no quantified minimum was supplied; the ready flag remains authoritative. Committed counts include restored history, while collector call counts can restart after recovery. Readiness reasons and errors retain at most 128 and 256 UTF-8 bytes respectively, with the same ceilings applied after JSON escaping.
The ownership-fenced heartbeat publishes diagnostics every five seconds during healthy operation. One member-scoped workspace request returns service summaries and optional selected-run diagnostics. The browser pauses hidden work and displays snapshot age separately from evidence persistence freshness.
V1 admits at most 64 executable nodes, 128 connections and 100 features at any
intermediate boundary. Bound executable configuration has a 96 KiB serialized
budget to stay below the worker environment transport limit. Known allocation
admission and existing worker CPU/memory hard limits apply independently.
Adaptive PCA admission includes each initialization row's NumPy array header,
the old and new pointer lists retained during a learning transaction, the dense
initialization matrix and reduced SVD outputs. These costs use the target
worker's Python/NumPy object sizes; per-projection peaks are summed conservatively.
The estimate also covers explicit online update matrices. Detector state, native
linear algebra workspaces and other process allocations remain additional costs,
so passing admission does not promise that every workload fits the hard limit.
External validation reports, including read-only run metadata, have a 512 KiB
serialized budget. Large reports omit provenance first, then whole node tables,
with an explicit metadata_budget notice and features_complete /
provenance_complete flags. A selected_node_id on draft validation or the run
query preserves complete ordered feature names for that node and its immediate
parents; feature choices are never truncated. Unknown selections return an
explicit issue and no stale metadata. Invalid oversized definition echoes can be
omitted separately, and individual errors are bounded to 1 KiB of UTF-8 text.
The compiler rejects normalized definitions above 96 KiB before publication;
bindings must still fit the complete execution budget at activation.
Library lists return summaries with server-side search, archive filtering and
pagination (limit, offset; default 25, maximum 100). Design details include
at most 25 revision summaries. Revision pages use before revision numbers;
individual immutable definitions load on selection. Run history pages contain
summaries and a next_monitor_id continuation, resolved in one depth-bounded
recursive query per page. History never repeats executable definitions or binding
snapshots. A selected run includes its design ID so opening its draft needs no
library scan. /pipeline-metadata and immutable revision /metadata requests
return only the selected node's and its parents' compiler tables, without run
history or another definition echo. These member-readable inspections remain
available with authoring disabled.
The gateway assigns compilation and automatic metadata inspection a separate member-scoped budget of 120 requests per minute. They cannot consume the 30-per-minute mutation budget or the 120-per-minute workspace polling budget.
Performance evidence¶
backend/scripts/benchmark_pipeline_runtime.py compares real numerical graph
execution with observation disabled and enabled, without MQTT, PostgreSQL or
health-publication I/O. Both runners receive identical seeded observations and
must produce exactly identical scores and alarms. Short, alternating batches of
ten observations balance execution order and slow host/VM speed changes within
each trial. Whole-run blocks initially showed large speed drift inside identical
forest kernels; their timings are not used to infer observation overhead.
The 2026-10-16 paired probe used an AMD Ryzen 5 7530U, WSL2 Linux x86-64,
Python 3.14.8 and Aberrant 1.3.0. Docker limited the process to one CPU
(cpu.max=100000 100000) and 1 GiB; twelve logical CPUs were visible.
OPENBLAS_NUM_THREADS, OMP_NUM_THREADS, MKL_NUM_THREADS and
NUMEXPR_NUM_THREADS were all 1. Each of three trials used 512 warm-up
observations, 32 calibration score/learn calls and 100 timed observations. The
collector used its normal one-second sampling interval, including committed-update
and readiness metadata. The report records hardware, thread controls, cgroup
bounds, parameters and raw trial timings.
The representative graph has eight inputs and nine nodes: six selected features
pass through online standard scaling and three-component uncentered PCA, two
features bypass processing, then both branches merge into a seeded 100-tree
online isolation forest (window_size=512, max_leaf_samples=16, n_jobs=1).
Its frozen quantile policy and output are included. The cheap comparison uses one
input, standard scaling and a moving-average detector with a 128-value window.
| Workload | CPU without observation | CPU with observation | CPU change | Wall change |
|---|---|---|---|---|
| Representative forest | 50.587 ms/observation | 45.986 ms/observation | -9.10% | -9.19% |
| Cheap moving average | 0.162 ms/observation | 0.272 ms/observation | +68.02% | +67.80% |
These are medians of aggregate trial timings. All three paired representative trials measured lower CPU time with observation: no positive cost was measurable at this probe's precision. Those negative differences reflect residual host variation, with no claimed numerical speed improvement. Individual trials without observation ranged from 42.763 to 182.347 ms/observation, so this provides scoped acceptance evidence rather than a precise overhead estimate. The <=10% target applies to this documented representative workload. Cheap detectors can have much larger relative overhead; their measured absolute CPU increase was 0.110 ms/observation. Absolute throughput varies with host load, and these measurements are not an end-to-end throughput guarantee.
Two separate maximum-size fixtures ran 4,000 scored/learned observations each after warm-up. One has 64 nodes, 128 edges and 100 source features with shared branches and reconvergence; the other carries all 100 features through a 64-node, 63-edge chain. Every observation was captured for this retention probe, a stronger allocation load than normal sampling. Both retain stable node keys, eight preview values per side and one observation envelope.
| Maximum fixture | Largest serialized snapshot | Retained collector objects | Growth over final 2,000 observations |
|---|---|---|---|
| Reconvergent graph | 47,450 bytes | 132,024 bytes | 0 bytes |
| Full-width chain | 77,941 bytes | 145,803 bytes | 0 bytes |
Retained sizes count collector-owned reachable Python objects once, excluding
the numerical models and allocator overhead. Snapshots stay below 131,072 bytes.
The memory capture, sampled during the
final fixture, recorded a cumulative whole-container cgroup memory.peak of
147,513,344 bytes (140.68 MiB), including all workloads up to that point; this is
not a per-fixture measurement. Separate maximum-node tests combine
100-character IDs, 128 dependency links, multibyte/control-character readiness
reasons and errors, bounding both UTF-8 and JSON-escaped text. The fixture tests
also verify one learning update per eligible node and exact observation-on/off score
equivalence. Timing-only reruns use --timing-only; a full run also repeats the
sustained bounds probes.
The prepare workload despikes and smooths eight inputs, adds a change and a
60-value rolling standard deviation for four of them, and scales the 16 features
into a moving Mahalanobis distance (window_size=128). Its Prepare stage needs
80 observations before its first output. A 2026-11-01 timing-only run with the
default settings used a four-CPU cloud container (Intel Xeon at 2.10 GHz, Linux
x86-64, Python 3.13.18, Aberrant 1.4.0, one thread per numerical library, no
cgroup CPU limit).
| Prepare workload | CPU without observation | CPU with observation | CPU change |
|---|---|---|---|
| Whole graph | 0.783 ms/observation | 0.891 ms/observation | +13.70% |
| Prepare stage alone | 0.055 ms/observation | 0.067 ms/observation | +21.85% |
The detector dominates; four Prepare steps over eight inputs cost about 55 µs per observation. The Prepare evidence keeps the raw trials of that run.
The runtime evidence preserves the raw trials, component parameters, environment and retention results. Separate browser evidence records editor rendering checks.
Rollout¶
The production rehearsal restores the current release's backup, runs an older
pipeline-compatible build, then returns to the current build. Each cutover checks
the actual application and worker image digests, fresh graph scoring, bindings,
layout, retained run history and single-worker ownership with authoring disabled.
The test-only reference is immutable commit
197cc239119b8bfb7df1b6921828dbb690adcd7f, the commit that adds the composer's
saved blocks and their table through migration 031
(pipeline_blocks). It shares the current
migration ledger, the ABERRANT 1.5.0 pin, frozen lock, generated catalog
and field discovery metadata, so an already-open new browser keeps its controls
while the backend rolls back. It also serves the current REST paths and field
names, so the rehearsal calls and reads both releases the same way.
The test uses the current API directly, without compatibility adapters or
response rewriting.
Monitors are started on the current release, by reading id.
The previous reference, efe30f4a, has no migration 031;
f02a9201 has no migration 030;
535b29cd has neither migration 028 nor 029;
934f8f93 has no migration 027; cbe2d839 before it
has no migration 026, ad919d98 returns a design with its lane and without
its calibration step, where the current release returns it as the editor
reads it, 386eb796 lists no Timestral step in field discovery, 7194e94f
has a catalog without the forecaster steps, and b03157ec locks ABERRANT
1.4.0, so the rehearsal accepts none of them.
Releases before
migration 031 refuse a database that has applied it. Rolling back past
this schema boundary means restoring a verified
backup, as
recovery describes.
The binary rollback floor for this release is ABERRANT 1.5.0; migration from
earlier versions is checked separately and incompatible checkpoints relearn. Its migration ledger, numerical
catalog and locked Aberrant version must match the current source before it can
build. Other locked dependencies may differ, as they would in a real rollback:
each release calibrates its own run, and detector checkpoints refuse a changed
runtime. This fixture is not an eligible production release. Changes to those
contracts require review of the rollback reference. CI retains nonsecret
source/image identities and the asserted graph invariants as its
compatible-binary-rollback artifact.
The production rollback rehearsal passed both integration tests with zero skips in 625.685 seconds on 2026-10-16. Its retained artifact records all three cutovers and verifies the deployed image digests and graph invariants; the following browser phase is a separate gate.
The initial compatibility release shipped with authoring off. The visual
workbench is now enabled by default with TACTIC_PIPELINE_AUTHORING_ENABLED=true;
numerical, real infrastructure, lifecycle, browser and resource gates must pass
before release. An explicit operator override to false is preserved. Disabling
authoring blocks new designs and workbench activations while the setup forms,
existing graph runs, inspection, stopping and release recovery remain supported.
The software rollback floor is a pipeline-compatible release. Older binaries cannot interpret the migration ledger or graph configurations. Use a compatible binary, or the reviewed release database backup/restore procedure. Never erase migration history to make an old binary accept a newer database.