Skip to content

chc.gate

Stability tier: evolving.

A deployment gate that can be read at any time: per zone, a candidate policy runs in shadow of the baseline until the evidence says deploy, hold, experiment or roll back.

The baseline acts, and each decision is logged with its propensity. After every batch -- every fifteen minutes, or every decision -- DeploymentGate.update returns a Verdict per zone:

  • "deploy": the candidate beats the baseline by more than delta;
  • "hold": the candidate is worse than the baseline by more than delta_harm and retires, or the zone's channel drifted and its evidence starts again;
  • "experiment": shadow evidence would come too slowly, so the zone logs under a mixture of the two policies, which bounds both weights;
  • "rollback": a deployed candidate turned out worse than the baseline, or its channel drifted;
  • "shadow": not yet, or, with a futility stop, no longer in an experiment whose candidate is not worth min_effect.

Each verdict is an e-process crossing a threshold: a mixture over constant bets of prod(1 + lam Y_t), with Y_t a decision's weighted contrast less the margin, scaled so that Y_t >= -1. Only the baseline's weight needs a bound, and the logging design supplies it (Waudby-Smith et al. 2024, ACM/IMS Journal of Data Science 1(3), doi:10.1145/3643693). Across zones, e-BH over e-values that freeze when their zone stops gathering keeps the false-deploy rate at every read (Wang, Dandapanthula and Ramdas 2025, arXiv:2502.08539).

What the guarantee needs, and the gate cannot check:

  • Propensities logged at decision time. A behaviour law fitted to the logs afterwards, as chc.offpolicy.fit_behavior_policy does, took a lab gate's type-I error from 0.013 to 1.000. The gate takes the logged propensity and refuses a batch whose propensities are not those of the policy it asked to log, but it cannot tell a logged propensity from a refitted one. A DecisionLog is the record that carries it: read back from storage, it refuses a record that does not say which version it is, or lacks a field, rather than filling one in.
  • No spillover between zones: each zone's null holds given every zone's past.
  • No carryover: a decision's reward does not depend on earlier decisions. On a plant with memory, per-decision increments certify the myopic contrast; in the lab they deployed, with probability 1.000, a candidate that was +0.029 better myopically and -0.010 worse in the long run. On such a plant, run the gate in blocks (below), or estimate a plan's value with chc.evaluation.evaluate_plan.
  • Rewards in [0, 1].

Plants with memory: the gate in blocks (experimental). With GateConfig(block_length=L, burn_in=b), a zone's evidence comes from an experiment on policies, not on decisions: each block of L periods draws one policy, the candidate with probability rho, runs it for the whole block, and logs the probability of that draw. A BlockBatch carries the blocks. The gate drops the first b periods of each block, averages the reward over the last r = L - b, and weighs the block by the inverse of its logged probability. One block is one increment of the same e-processes, and e-BH reads them as before. The inference is design-based: the only randomness it uses is the gate's own draw, so the dependence between one zone's blocks does not matter (Bojinov and Shephard 2019; Ham, Bojinov, Lindon and Tingley 2026). What a DEPLOY then claims is that the candidate's mean reward over the read periods beats the baseline's by more than delta, block by block, given everything before the block.

  • Carryover of order p <= b (a reward depends on the decisions of the last p periods only; Bojinov and Shephard's m-period causal impact): the read periods see only their own block's policy, so the contrast is the sustained one, that of running the candidate rather than the baseline for good.
  • Geometric memory: a plant whose expected reward t periods after a policy takes over lies within C a^t of that policy's long-run value V, whatever the state it took over in. The block's contrast then lies within 2 C a^b (1 - a^r) / (r (1 - a)), of order a^b, of V_new - V_base (validation/gate_block_contrast.mac), and a DEPLOY claims only V_new - V_base > delta less that bias: delta must cover it. On the lab's plant above (a = 0.9), with r = 10, the block's contrast is +0.014 from a start at 0.2, the stationary mean under rho = 0.5, and +0.062 from one at -1.1: the wrong sign. With b = 40 it lies between -0.0115 and -0.0069 for every start in [-1.1, 1.5], against -0.0096 in the long run, and the gate in blocks deployed the candidate on 5 of 1000 paths of 20 000 periods, where without blocks it deployed on every path.
  • The gate cannot test p or a on its own data.
  • A block is one increment however long it is, and its burned periods read nothing. Under carryover of order 1, a candidate +0.3 better sustained was deployed after a median of 562 periods in blocks of 2 with 1 burned, 978 in blocks of 5 with 1 burned, and 1288 in blocks of 5 with 4 burned.

A zone's channel is watched through per-decision drift e-values. channel_drift_evalues reads them off the Gaussian dither a DecisionLog records, and DriftAlarm turns them into an alarm outside the gate as well. Once the channel has moved, channel_move re-reads it off the same dither, and ChannelMove.price puts the move in a plan's own cost.

A deployed model's one-step innovations are watched too (experimental): MissRateAlarm alarms when they leave a split-conformal radius more often than its calibration allows, and OnlineRadius moves a radius per horizon so that each horizon's long-run miss rate stays near its target on any sequence.

GateConfig dataclass

What the gate tests, and at what level.

delta is the improvement margin: a DEPLOY claims V_new - V_base > delta. delta_harm is the harm margin: a HOLD for harm or a ROLLBACK claims V_base - V_new > delta_harm. alpha bounds the expected share of wrong DEPLOY decisions across zones, alpha_harm each HOLD for harm and each ROLLBACK, and drift_arl is the average number of decisions between false drift alarms on an unchanged channel. rho is the candidate's share of the logging mixture in EXPERIMENT.

min_effect, the smallest improvement beyond delta worth deploying, and horizon, the decisions per zone the gate may spend, decide when to switch to EXPERIMENT, and no guarantee depends on them.

alpha_futility (experimental), unset by default, adds a futility stop: an experiment whose evidence says the candidate does not beat the baseline by delta + min_effect returns to shadow, and the zone no longer switches to EXPERIMENT until its channel drifts. Each stop is wrong, stopping a candidate that does beat the baseline by more than delta + min_effect, with probability at most alpha_futility.

block_length (experimental), unset by default, runs the gate in blocks of that many periods, for a plant with memory (see chc.gate), and burn_in, the periods at the start of each block that are not read, must be at least the plant's carryover order. In blocks, rho is the probability that a block runs the candidate, and horizon and the zones' ZonePlan are not read.

Raises:

Type Description
ValueError

on a margin outside [0, 1), a level or rho outside (0, 1), a min_effect that is not positive, a horizon below 1, a drift_arl not above 1, an alpha_futility with delta + min_effect not below 1, which no reward in [0, 1] can beat, a block_length below 1, or a burn_in that is negative, leaves no period of a block to read, or is set without a block_length.

ZonePlan dataclass

What is known about a zone's candidate before any data, used only to decide when to switch to EXPERIMENT.

chi2 is the chi-square divergence of the candidate's action law from the baseline's, per decision: the variance of the shadow weight. tv is their total variation distance. Compute both from the two policies, never from logged weights: when the candidate's action law is wider than the baseline's by a factor of sqrt(2) or more, chi2 is infinite, an estimate from weights is finite, and a tail index estimated from them reads far heavier than the true one. For Gaussian actions N(m_new, s_new^2) against N(m_base, s_base^2)::

1 + chi2 = s_base^2 / (s_new sqrt(2 s_base^2 - s_new^2))
           * exp((m_new - m_base)^2 / (2 s_base^2 - s_new^2)),

infinite when s_new^2 >= 2 s_base^2.

Raises:

Type Description
ValueError

on a negative or NaN chi2, or a tv outside [0, 1].

DecisionLog dataclass

What each decision recorded when it was taken, one entry per decision.

action is the action as applied, one row per decision for a vector action. propensity is the probability, or density, of drawing it, computed by the policy that drew it when it drew it: the number the gate's guarantee is stated on. saturated flags a decision whose action was clipped, so that what was applied is not what was drawn. dither, optional, is the Gaussian perturbation drawn for the policy's action, as drawn: on a clipped decision, the draw, not what the clip left of it.

Stored, a decision is a record with decision_log_version beside those fields. from_records reads version VERSION and refuses a record with no version, or with another, rather than guess what an older log meant. What a new version would require of stored logs is in docs/adr/0015-what-a-logged-decision-records.md.

Raises:

Type Description
ValueError

on entries whose number differs between the fields, a non-finite entry, a propensity that is not positive, a saturated that is not boolean, or a dither whose shape is not the action's.

from_records(records) classmethod

The log that stored records hold, such as JSON lines or a table's rows.

Every record needs decision_log_version equal to VERSION, and action, propensity and saturated, a boolean. dither is on every record or on none. Keys the log does not define, such as a timestamp, the reward or the caller's own version, are left to the caller.

Raises:

Type Description
ValueError

on a record with no version or with another, a missing field, a saturated that is not a boolean, or a dither on some records and not others; and on what the constructor refuses.

to_records()

One record per decision, versioned and ready for JSON, which from_records reads back to the bit.

dither_draws()

The dither of every decision, for a reader that needs each action to carry its whole draw, as channel_move does: on a clipped decision, what the product of the residual and the draw reads is scaled by the chance that the draw was not clipped.

Raises:

Type Description
ValueError

on a log with no dither, or with a clipped decision.

ZoneBatch dataclass

One zone's decisions since the gate was last read, one entry per decision.

candidate and baseline are the two policies' propensities of the action taken -- a probability for a discrete action, a density for a continuous one -- and logged is the propensity recorded when the action was drawn, under the policy the zone's mode asked for; from_log reads it off a DecisionLog. drift, optional, holds per-decision e-values for "the channel is unchanged", one column per detector, each with expectation at most 1 given the past while the channel holds: channel_drift_evalues reads them off the log's dither.

Raises:

Type Description
ValueError

on arrays of different lengths, a non-finite entry, a reward outside [0, 1], a negative propensity, a logged propensity that is not positive, or a negative e-value.

from_log(log, *, reward, candidate, baseline, drift=None) classmethod

The batch whose logged propensities are the ones log recorded, decision by decision.

Raises:

Type Description
ValueError

on what the constructor refuses.

BlockBatch dataclass

One zone's blocks since the gate was last read, for a gate in blocks. Experimental.

reward holds one row per block and one column per period of it, burn-in included. candidate says whether the block ran the candidate, and logged is the probability with which the block's draw chose the policy it ran, recorded at the draw: rho or 1 - rho in an experiment, and 1 when the zone's mode left no choice. drift, optional, is a ZoneBatch's, with one row per period, block by block.

Raises:

Type Description
ValueError

on rewards that are not one row per block of at least one period, a non-finite entry, a reward outside [0, 1], a candidate that is not one boolean per block, a logged that is not one probability in (0, 1] per block, or a negative e-value.

DriftAlarm

Shiryaev-Roberts over e-values for "the channel is unchanged": an alarm that sounds, on an unchanged channel, after arl decisions on average or later.

Each detector's statistic runs R_t = (R_(t-1) + 1) e_t from R_0 = 0, and the alarm sounds when their average reaches arl. While every e-value has expectation at most 1 given the past, the average less t is a supermartingale, whatever the dependence between the detectors, so the average run length is at least arl (Shin, Ramdas and Rinaldo 2024, arXiv:2203.03532). After an alarm the statistic starts again at the next call, and the call's e-values after the alarming decision are dropped: they were computed against the model the alarm rejected. DeploymentGate runs one per zone.

Raises:

Type Description
ValueError

on an arl not above 1.

detectors property

How many detectors the running statistic follows: None before the first e-value and after an alarm, when any number may start.

statistic property

The detectors' average statistic, 0 before the first e-value and after an alarm.

update(evalues)

Add e-values, one row per decision and one column per detector, and say whether the alarm sounded.

Raises:

Type Description
ValueError

on e-values that are not finite and non-negative, or whose number of detectors is not the running statistic's.

MissRateAlarm

An alarm that sounds once a deployed model's one-step innovations leave a split-conformal radius more often than its calibration allows: on a plant the model still describes, it ever sounds with probability at most delta.

calibration holds one-step residuals, the next state less the model's prediction, of consecutive steps held out from the fit, one column per state. Each residual is whitened against the one before, w_t = r_t - persistence r_(t-1), and scored by its Euclidean norm, in the states' units: scale them first where those differ. radius is the k-th smallest of the n scores, k = ceil((n + 1)(1 - alpha)), split conformal's radius. Given the calibration, the share of later innovations outside it is a fixed p, and over calibrations p follows Beta(n + 1 - k, k) (Vovk 2012; Lin and Bansal 2024): bound, its upper delta / 2 quantile, exceeds p with probability at least 1 - delta / 2.

update whitens each deployed residual against the one before it and counts the innovations outside radius. After S misses in t innovations, statistic is the likelihood ratio of a miss rate q against bound, mixed over q uniform on [bound, 1], in closed form through the incomplete beta function. While each innovation misses with probability at most bound given the past, it is a nonnegative supermartingale from 1, so by Ville's inequality it ever reaches 2 / delta with probability at most delta / 2, and the alarm sounds there. The two halves add up to delta: Podkopaev and Ramdas's test for a harmful shift (ICLR 2022, Proposition 1), with the source risk's bound read off the calibration's exact law and no tolerance. After an alarm the counts start again at the next call, and the call's later residuals are dropped.

On a plant whose model is exact and whose noise is AR(1) at 0.8, with 300 calibration innovations, alpha = delta = 0.1 and 1000 paths of 1000 steps read every 100, the alarm sounded on 0.016 of the paths at the noise's persistence, on 0.020 at the lag-1 correlation fitted on each calibration log, and on 0.209 unwhitened. With the noise's scale 1.5 times the calibration's, it sounded on every one of 200 paths.

What the guarantee needs, and the alarm cannot check:

  • Innovations independent of the past and exchangeable with the calibration's: the model right, so a residual is the plant's noise, and the noise AR(1) at rate persistence, such as chc.decision.DecisionCertificate.tube_persistence or a fit's lag-1 correlation. Without whitening, misses cluster: a miss makes the next one likelier, and the supermartingale fails.
  • A persistence fixed before the deployed data; one estimated on the calibration log leaves a correlation of the order of its error.

Raises:

Type Description
ValueError

on alpha or delta not in (0, 1), a persistence not in (-1, 1), residuals that are not finite or not one row per step, and fewer calibration innovations than (1 - alpha) / alpha, below which no radius carries the coverage.

misses property

Innovations outside radius since the start or the last alarm.

steps property

Innovations read since the start or the last alarm.

statistic property

The mixture's e-value: 1 before any innovation and after an alarm.

update(residuals)

Add one-step residuals, one row per consecutive step, and say whether the alarm sounded. The first residual after the start or an alarm only whitens the next.

Raises:

Type Description
ValueError

on residuals that are not finite, or not one column per calibration state.

OnlineRadius

A radius per horizon, updated online so that, over any sequence of scores, each horizon's long-run miss rate comes within a stated bound of alpha (Angelopoulos, Barber and Bates, ICML 2024).

Horizon h (1 to H, one per entry of initial) has a radius. Each period issues radius, one per horizon, and closes with update, which takes one score per horizon: for horizon h, the score of the prediction issued h - 1 periods before this one, nan where there is none. A score is any number in [0, bound] the caller computes, such as the error's norm over the tube's scale, and the prediction misses when its score exceeds the radius it was issued with. Each scored horizon's radius then moves by eta_n (miss - alpha), with eta_n = step n^(-decay) at its n-th score: up after a miss, down otherwise.

On any sequence of scores in [0, bound], chosen even by an adversary who sees the radii, such as a plan steering its own states, the miss rate of horizon h after n scores lies within miss_bound = (bound + h step) / (eta_n n) of alpha, which shrinks as n^(decay - 1). For h = 1 it is their Theorem 1. For h > 1, up to h - 1 updates are in flight while a prediction waits for its score, which widens their Lemma 1's range of the radius from bound + eta_1 to bound + h eta_1; the proof is otherwise theirs. On every one of the 2^12 sequences of misses an adversary can force in 12 periods, at a constant step of 1, horizon 3 came within 0.9 of that bound and past the one without h. The bound holds for each horizon on its own, and on no other sequence than the one scored; it says nothing of coverage at any one period. Their step is decay = 0.6.

Raises:

Type Description
ValueError

on an empty or non-finite initial or one outside [0, bound], alpha not in (0, 1), a bound or step that is not positive and finite, or a decay not in [0, 1).

radius property

The radius each horizon issues at the next period.

scored property

How many predictions each horizon has scored.

miss_rate property

Each horizon's share of scored predictions that missed, nan before its first.

miss_bound property

(bound + h step) / (eta_n n): how far each horizon's miss rate may lie from alpha, inf before its first score.

update(scores)

Close a period: score each horizon's prediction that resolved in it, nan for none, against the radius it was issued with, and move the scored horizons' radii. Returns which horizons missed.

Raises:

Type Description
ValueError

on scores that are not one per horizon, a score outside [0, bound], or a score for horizon h before h periods have closed, when no prediction for it has been issued.

MovePrice dataclass

What a move of the one-step channel costs a plan, against the plan that knew it, in expectation. Experimental.

See ChannelMove.price.

ChannelMove dataclass

How far the plant's one-step channel lies from the model's, read off a log's dither. Experimental.

See channel_move.

price(weight)

What the move costs the plan weight was read off, kept or re-solved on the estimate.

With W the plan's chc.plan.CausalPlan.decision_weight, d the move and S the covariance, keeping the plan loses d' W d / 2 to the plan that knew d, and a plan re-solved on the estimate dh loses (dh - d)' W (dh - d) / 2, tr(W S) / 2 on average. Since E[dh' W dh] = d' W d + tr(W S), keeping is priced at (dh' W dh - tr(W S)) / 2, and its standard error is sqrt(4 d' W S W d + 2 tr((W S)^2)) / 2, with d' W S W d read as dh' W S W dh - tr((W S)^2), for the same reason, and floored at 0: dh put in for d reads its variance three times too large on a channel that has not moved (validation/dither_channel_move.mac STEPs 4-5, proofs/dither_channel_move.v).

Two expectations, not a decision rule. Re-planning pays in expectation when d' W d > tr(W S), but comparing the two prices of one log selects on its error: the logs whose estimate reads a large move are the ones whose estimate errs most along W, and they are the ones re-planned. On the budgeted plan of scripts/bench_channel_move.py, 200 logs of each size, re-planning when the keep price beat the re-plan price lost 0.149 where always keeping lost 0.095, at 30 decisions, and 0.100 and 0.043 where always re-planning lost 0.080 and 0.024, at 100 and 400. Re-planning on the estimate shrunk by 1 - tr(W S) / dh' W dh, or choosing on one half of the log and re-planning on the other, did no better than the better of the two throughout. Re-read the move on the decisions logged after the choice, such as those after a DriftAlarm sounds, and the re-plan price is the re-solved plan's.

How far they reach. Both are second order in the move, and hold as far as W does: while the plan's active set stays as it is, and for a move small against the channel (see chc.plan.CausalPlan.decision_weight). With a move of 10-15% of the channel's entries, the kept plan lost 0.095 where the keep price at the true move reads 0.107. A plan re-solved on the estimate from 30 decisions lost 0.18 where the re-plan price read 0.37, its box capping how far it moved; from 400 and 1600, 0.024 and 0.0060 against 0.026 and 0.0066.

Raises:

Type Description
ValueError

on a weight whose channel is not the move's shape.

DeploymentGate

Shadow evidence per zone that ends in DEPLOY, HOLD, EXPERIMENT or ROLLBACK, read any time.

Whenever the gate is read -- after every batch, or at a time chosen by looking at the data -- and under the conditions in chc.gate:

  • the expected share of wrong DEPLOY decisions among those made so far is at most alpha, under any dependence across zones; a DEPLOY is wrong when the candidate does not beat the baseline by more than delta;
  • each HOLD for harm, and each ROLLBACK on evidence, is wrong with probability at most alpha_harm;
  • with drift e-values supplied, an unchanged channel alarms at most once per drift_arl decisions on average;
  • with alpha_futility, each futility stop is wrong with probability at most alpha_futility; a stop is wrong when the candidate beats the baseline by more than delta + min_effect.

The rules, in order, for each zone at each read:

  1. a drift alarm: HOLD, and the zone's evidence opens a new epoch; ROLLBACK if deployed;
  2. selected by e-BH over every zone's e-value: DEPLOY;
  3. the harm e-value reaches 1 / alpha_harm: HOLD, and the zone retires;
  4. with alpha_futility, the futility e-value has reached 1 / alpha_futility in this epoch: SHADOW, back from an experiment, and the zone does not switch to EXPERIMENT again;
  5. in shadow, a verdict at min_effect would take longer than the horizon has left, and the mixture would gather evidence faster: EXPERIMENT;
  6. otherwise the zone stays where it is, in shadow or in the experiment.

A deployed zone is watched by a rollback e-process on the baseline's weight. A retired zone reads HOLD from then on; a new candidate is a new hypothesis, for a new gate. mode says how to log a zone's next batch: under the baseline in "shadow", under (1 - rho) baseline + rho candidate in "experiment", and under the candidate once "deployed". The gate keeps its evidence in memory, and one caller updates it at a time.

In blocks (GateConfig.block_length), each zone is fed a BlockBatch and starts in "experiment", where each block runs the candidate with probability rho. "shadow", after a futility stop, runs the baseline's blocks, and "deployed" the candidate's. Shadow blocks are no evidence for the candidate, so rule 5 sends a shadow zone back to the experiment as soon as it may enter one, and a deployed zone's rollback evidence has no baseline block to read: only a drift alarm rolls it back.

Raises:

Type Description
ValueError

on no zones.

update(batches)

Add each zone's batch to its evidence and return every zone's verdict.

A zone without a batch keeps its evidence and still gets a verdict, since e-BH reads every zone's; a retired zone's batch is ignored. Every batch is checked before any is added. A gate in blocks reads each block as one decision, whose action is the policy drawn.

Raises:

Type Description
KeyError

on a zone the gate has no plan for.

TypeError

on a BlockBatch for a gate not in blocks, or a ZoneBatch for one that is.

ValueError

on a batch whose logged propensities are not the mode's, blocks whose length is not block_length, or drift e-values that change their number of detectors mid-run.

channel_drift_evalues(log, residual, *, dither_scale, radius, residual_scale)

Per-decision e-values for "every entry of the one-step channel lies within radius of the model's", read off the logged dither: a ZoneBatch's drift, or what a DriftAlarm runs on.

residual holds, for each decision in log, the next state less the model's one-step prediction at the action as applied, one column per state. dither_scale is the standard deviation each action's dither was drawn with. radius bounds, entry by entry, how far the plant's one-step channel d x_(t+1) / d u_t may lie from the model's while nothing has moved, in the residual's units per unit of action. residual_scale, one per state and fixed before the data, such as the model's one-step noise, is the unit the bets are placed in. A scalar stands for every state or action.

For state i and action j, the dither is standardised, xi = dither_j / dither_scale_j, and the residual is moved to the radius's edge and scaled, r = (residual_i - radius_ij u_j) / residual_scale_i against a channel that grew and (residual_i + radius_ij u_j) / residual_scale_i against one that shrank. The e-value at a bet theta is exp(theta r xi - theta^2 r^2 / 2), with theta among 1, 1/2, ..., 1/128, negated against shrinkage. On a plant whose one-step map is g(x) + B u plus noise, r = c + k xi, where k is how far B_ij lies past that edge, times dither_scale_j / residual_scale_i, and c is everything else. When xi is N(0, 1) and independent of c, E[exp(theta r xi - theta^2 r^2 / 2) | c] = 1 / |1 - theta k|, which is at most 1 on each side the entry has not crossed, whatever the model's error in g, the noise's law or the policy. Row t of the result, reshaped to (states, actions, 2, 8), holds decision t's e-values by state, action, growth then shrinkage, and bet.

A clipped decision is read the same way: the bet reads the draw, and the residual moves with the action as applied. With a box cutting the draw to h = clip(xi, -m1, m2) in dither units, r = c + k h, and the mean given c is 1 - (Phi(u2) - Phi(u1)) (1 - 1 / s), with s = 1 - theta k, u1 = -s m1 - theta c and u2 = s m2 - theta c. That is at most 1 on each side the entry has not crossed, exactly 1 on the radius's edge whatever the clip, and at least 1 on the side it has (validation/dither_drift_evalue.mac STEP 8). On the sides not crossed, the mean stays at most 1 for an action applied as any nondecreasing function of its own draw, such as a saturating actuator. The draw is what the log must carry: the dither as applied, put in its place, is not an e-value, and on the edge, with the nominal action on a bound, its mean reaches 1.15.

On the lab's plant, whose model has the drift wrong and whose noise is Laplace, a DriftAlarm on these e-values ran at least 3.2 and 5.6 times its target on an unchanged channel, at 10^3 and 10^4, and caught a channel at 1.4 instead of 1.07 in 259 and 456 decisions on average: 1.56 and 1.37 times an oracle that knew which entry moved, which way and at what rate (scripts/bench_drift.py, 300 paths). With the actions boxed to +-0.5, so that a quarter of the decisions clipped, it ran at least 2.8 and 4.6 times its target and caught the move in 438 and 749 decisions, 1.69 and 1.64 times the unboxed delays. With the clipped decisions' e-values set to 0 instead, which is also valid, it caught the move on none of the paths within 3000 decisions.

What the guarantee needs, and the function cannot check:

  • A plant affine in the action over one step. The model's error in g and the noise must not depend on the dither drawn at that step. A response nonlinear in the action, such as xi^2 - 1 in the next state, is uncorrelated with the dither and still breaks the identity: in the lab, a running product of such e-values passed 20 on 27% of paths, where Ville's inequality allows 5%. A plant integrated over a step has a one-step channel that carries the drift's Jacobian too, to first order in the step.
  • A dither drawn as stated and logged as drawn: each action's from N(0, dither_scale_j^2), independently of the other actions' and of the past, and applied whole or cut by a box on that action alone, fixed before the draw. A projection that couples the actions, such as a budget shared across them, moves one action's residual with another's draw, and is not covered. With a fifth more variance than stated, the e-value's mean at theta r = 1 is exp(0.1) = 1.105.
  • A radius that covers the identification error. The e-values hold while every entry lies inside it. On the same plant, a channel 0.07 from the model's, watched with no radius, alarmed after 0.36 of the average run length the alarm was set for. A standard error, such as chc.dynamics_id.CausalDynamicsFit.channel_error, is a scale, not a radius.

Raises:

Type Description
ValueError

on a log with no dither; on a residual without one row per decision, a non-finite entry, a scale that is not positive, a negative radius, or a scale or radius that is neither a scalar nor of its full shape; and on a dither whose draws, over dither_scale, a two-sided chi-square test rejects at 1e-9: a slip in units, or a variance passed for a standard deviation.

channel_move(log, residual, *, dither_scale, forgetting=1.0)

How far the plant's one-step channel lies from the model's, read off the logged dither, with the older decisions forgotten at a constant rate. Experimental.

residual and dither_scale are channel_drift_evalues': per decision, the next state less the model's one-step prediction at the action as applied, and the standard deviation each action's dither was drawn with. With xi = dither_j / dither_scale_j, decision t reads entry (i, j) as the product z = residual_i xi_j / dither_scale_j. On a plant whose one-step map is g(x) + B u plus noise, residual_i = c + sum_k d_ik dither_k, with d the plant's channel less the model's and c everything else: the model's error in g, the policy's action through d, and the noise. When the dither is N(0, dither_scale^2) and independent of c, E[z] = d_ij whatever c holds, and c adds to the product's variance rather than to its mean (validation/dither_channel_move.mac STEPs 1-2).

The estimate is the products' mean under the weights forgetting^k, k the decisions since, so the last decision weighs 1. Re-read as decisions arrive, it follows a channel that moves, and is worth (1 + forgetting) / (1 - forgetting) equally weighted decisions once the log is long (STEP 3); 1, the default, weighs every decision alike. The covariance is the products' own, weighted the same way and scaled so that it is unbiased when they share one: E[sum_t w_t^2 (z_t - dh)(z_t - dh)'] = sum_t w_t^2 [(sum_(s != t) w_s)^2 + sum_(s != t) w_s^2] Var(z) / (sum w)^2, which is (n - 1) Var(z) for equal weights. The products' errors are uncorrelated across decisions, since each dither is drawn after everything before it, so it holds for noise that moves with the state as well; unequal variances leave a bias of order 1 / effective_size under forgetting, and none under equal weights.

ChannelMove.price reads the move in the cost of a plan: what keeping it costs, and what re-solving it on the estimate would.

On the drift monitor's plant (scripts/bench_channel_move.py, 2000 paths), whose model has the drift wrong and whose noise is Laplace, the estimate of a move of 0.07 lay within 0.75 of its standard errors of it over 500 and 2000 decisions, at dithers of 0.3 and 1.0; its spread was 0.99-1.03 of its standard error, and estimate +- 1.96 of them covered the move on 0.943-0.955 of the paths. With the channel at 1.4 for the last 1000 of 4000 decisions, the intervals covered what each estimate follows on 0.960 of the paths without forgetting and 0.951 forgetting at 0.995.

What the estimate needs, and the function cannot check: channel_drift_evalues' contract. A plant affine in the action over one step, since a response nonlinear in the action reads into the product; a dither drawn as stated and applied as logged; and, for the move to be one number per entry, a channel that does not move with the state over the log: the estimate is the one-step channel's average over the decisions, weighted as above, and a plant integrated over a step has a channel that carries the drift's Jacobian, to first order in the step.

Raises:

Type Description
ValueError

on a log with no dither or with a clipped decision (see DecisionLog.dither_draws), fewer than two decisions, a residual without one row per decision or not finite, a dither_scale that is not positive or is neither a scalar nor one per action, a dither the chi-square test in channel_drift_evalues rejects, a forgetting outside (0, 1], or one so close to 0 that a single decision carries all the weight.