chc.plan¶
Stability tier: stable.
The one-call spine: plan a control sequence and get its guarantees attached to it.
Everything here already existed -- constrained OC (chc.control), the offline pessimism and
uncertainty penalties (chc.support, chc.uncertainty), and the certified Gronwall
error tube with its safe horizon. What was missing was a single object that carries a plan
together with the evidence about where it may be trusted, so a caller cannot walk away with the
actions and leave the certificate behind.
plan = causal_plan(model, x0, cost, dt=0.1, horizon=20, u_lo=-5.0, u_hi=5.0,
lipschitz=0.8, model_error=0.05, tolerance=0.5)
plan.actions # the full sequence
plan.certified_actions # only the prefix whose error tube is inside tolerance
plan.certified_horizon # where that prefix ends
plan.shadow_prices() # what each constraint row is worth to the plan
With no safety arguments this is exactly chc.control.projected_gradient_control with the
trajectory and cost packaged; each safety argument switches on one existing layer, so the defaults
promise nothing that was not asked for.
Three modes, deliberately named apart, because "safety" alone does not say which one you get:
- plan --
causal_plan. Box constraints in the solve -- optionally intersected with linear rows over the whole sequence, such as a budget or a rate limit -- plus an a-priori Gronwall error tube that says how far ahead the plan may be trusted. The tube is computed fromlipschitzandmodel_error, and from the plant's own one-stepnoisewhere it is given; it does not enter the objective and does not move a single action. ABarrierConstraintdoes: passed asbarrier=, the audit's own condition is held in the solve by augmented-Lagrangian rounds (docs/adr/0002-barrier-in-the-solve.md). - audit --
certify_safety. Given a barrier and a sensitivity levelGamma, it prices a finished plan against §40: where along it the safety guarantee survives unmeasured confounding, and the largestGammathe whole plan tolerates. Read-only by construction. - filter --
chc.barrier.robust_safety_filter. It clips one nominal action into the certified interval at one state, online, scalar control.
A held barrier binds the answer, not every iterate as the box and the rows do, and the solver's
word is not taken for it: a solve stopped by its budget, or a condition no admissible action can
meet, comes back short of the condition, and CausalPlan.safety -- the audit, run on the
finished plan -- says so. The tube is still never imposed inside the optimisation, so a plan can
leave tolerance at step 3; that is why CausalPlan.certified_actions exists.
DiagnosticStatus = Literal['within', 'beyond', 'not_finite', 'not_evaluated']
module-attribute
¶
How a plan's stationarity or constraint violation reads against its tolerance: within it,
beyond it, not_finite where the number is nan or infinite, as where the rollout left the
float range, and not_evaluated where it was not read: a plan built by hand, or the stationarity
of a plan held under a barrier, whose condition's multipliers it would need.
CostBiasDirection = Literal['optimistic', 'pessimistic', 'unbiased']
module-attribute
¶
Which way a planned cost reads against what the plan pays the plant, in expectation:
optimistic where it reads low, a positive bias, and pessimistic where it reads high.
Barrier = Callable[[Array], Array] | tuple[Callable[[Array], Array], ...]
module-attribute
¶
h, safe where h >= 0; or a tuple of them, safe where each is, each its own condition.
ModulusSource = Literal['supplied', 'measured']
module-attribute
¶
Where PlanRegretBound.modulus comes from: the caller, or J's Hessian. Measured, it is
the one Hessian of an objective the model's structure makes quadratic, which is the whole box's, or
the least eigenvalue at the plan and at random points of the box, a sample that cannot bound the
box's least; PlanRegretBound.status tells the two apart.
RegretStatus = Literal['certified', 'diagnostic', 'refused']
module-attribute
¶
What PlanRegretBound.bound is. certified on a supplied modulus or a quadratic
objective's; diagnostic on a sampled modulus, which a pocket of negative curvature between the
samples breaks; refused, the bound inf, where the modulus came out negative or not a finite
number, or the bound itself did not come out finite.
CausalPlan
dataclass
¶
A control sequence together with the certificate that says how far to trust it.
None in the certificate fields means no error model was supplied, which is a different
statement from a certificate that came back empty: the first is "unknown", the second is
"checked, and nothing holds". Earlier versions returned an all-zero tube and a full
certified_horizon in the first case, which reads as a proof of safety over the whole plan
when nothing was proved at all. Read certificate_status before either field.
solver_status answers a different question from certificate_status, and the
two are independent. The certificate is about the model: how far the plan can be trusted given
the error budget. The solver status is about the optimisation: whether the descent reached its
own stopping rule or merely ran out of budget. A fully certified plan built on an unfinished
solve is a trustworthy tube around a suboptimal action, and nothing in the tube says so.
shadow_prices answers a third: what each constraint row costs the plan, read off the
problem the plan was solved for.
certificate_status
property
¶
Whether the tube was evaluated at all, and if so how much of the plan it covers.
Derived rather than stored: a status field that can disagree with the horizon it summarises is a worse footgun than the one this replaces.
stationarity_status
property
¶
How stationarity reads against stationarity_tolerance.
stationarity is the plan's KKT residual (ADR 0097). The descent steps in
v = sigma * u (ADR 0059), where sigma_kj^2 is the cost's curvature along lever j
at step k over |J| at the guess, zeros clipped to the box: a pure number in any
units of the lever and the cost. There the gradient is read as a share of |J|, each row
is divided by its norm, and the residual is the least norm of the gradient plus a push of
the binding rows and box sides with the signs their bounds allow, over
max(1, |gradient on the free actions|): zero exactly at a KKT point, positive where a
multiplier has the wrong sign. Off the constraints its square is about twice the share of
|J| that one more Newton step along each action alone would buy. What binds is
shadow_prices' rule, read in v.
The tolerance is what the descent can see: it stops where no step buys more than
max(1e-14, 4 eps) of |J|, eps the actions' precision, which leaves a residual of
about the root of twice that; ten times it allows for a curvature off one along the
residual. Where the curvature is further off, as under a steep penalty, the descent reads
this residual itself and stops only within the tolerance, or where the gradients at the
ends of every trial read no fall either. beyond it, the descent stopped on its budget
or short of the optimum, and the plan, its prices and its regret are a stopped solve's.
Not read (not_evaluated) on a plan held under a barrier, whose condition's multipliers
the residual would need.
violation_status
property
¶
How constraint_violation reads against violation_tolerance.
constraint_violation is the largest distance in v, the variables of
stationarity_status, from the plan to a side of its box or to a bound of one of its
constraints rows that it is past: a row's excess over the row's norm in v. Zero
where the plan meets every one. Its tolerance is the one the descent's projection holds
each side and row to, 256 eps (1 + max|v|), times four for the plan's own rounding and
a projection started from another guess. The barrier's condition is not among them:
safety audits it.
certified_actions
property
¶
The prefix of the plan the tube still covers -- what a cautious caller should execute.
Raises:
| Type | Description |
|---|---|
ValueError
|
if no error model was supplied. Slicing by |
shadow_prices(tolerance=None)
¶
What each row of the plan's constraints is worth: its KKT multiplier at this plan.
The gradient is of the objective the solve minimised -- the task cost, plus the pessimism
penalties when a support model was given -- at the returned actions. A row binds within
tolerance * (1 + |bound|) of its bound, and so does a box bound. The box's multipliers
absorb every action at a bound, so the rows' multipliers solve the stationarity conditions
on the free actions alone, by least squares. Where the binding rows are dependent there,
the multiplier is not unique, and two linear programs give each row's range instead.
Read residual and dual_feasible before the prices. A solve stopped by its budget is
not at a KKT point, and its prices answer a question that has none. A price is the marginal
fall in the optimum per unit its row is relaxed: of the optimum, on a plan convex in its
actions -- a linear model and a quadratic cost -- and of the local optimum the solve found
otherwise. These are the stated rows' multipliers, not the barrier rounds' that
chc.mpc.RecedingHorizon carries between steps.
Computed when asked: one gradient, a least-squares solve and, for a degenerate row, two
linear programs. tolerance defaults to max(1e-6, 1e3 * eps) of the actions' dtype.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a plan held under a barrier, whose condition is a constraint of its own, with multipliers that would enter every row's price; or on a plan that carries no problem, as one built by hand does. |
decision_weight(tolerance=None)
¶
What an error in the one-step channel costs this plan, to second order. Experimental.
If the plant's one-step map is the planning model's plus E u, each entry of the channel
off by E as chc.gate.channel_drift_evalues watches them, the plan loses
vec(E)' W vec(E) / 2 against the plan that knew E, up to a term cubic in E. By
the envelope theorem W = J_Eu M^-1 J_uE, with M the Hessian of the task cost in
the directions the plan may move and J_uE its mixed derivative in those directions and
E. The directions are the actions off the box, along the binding rows, with
shadow_prices' rule for what binds. u is the action the field takes: a lever
chc.decision.prescribe keeps on the log's rule of the state enters at its rule's
level, not at its column of actions.
W is the regret's curvature at E = 0, and three things bound how far it reaches:
- The active set.
Wholds while the bounds and rows that bind stay binding. At one met with a zero multiplier, counted inweakly_active, the regret is piecewise quadratic, andW, with the bound held, is its lower branch: freeing a direction can only raise it. - The cubic term. On the scalar plan of
validation/plan_decision_weight.mac, whose one-step channel is 0.117, the regret's third derivative inEis -280 whereWis 9.2, and the quadratic is within 10% of the exact regret only forEin[-0.0096, 0.0102], about 8% of the channel. - A stationary plan.
residualis the stationarity miss along the free directions. A solve stopped by its budget carries a first-order term thatWdoes not.
W vanishes where the plan's response to the channel is flat, as on one step at
r = gamma^2 qf, and the regret there is fourth order, not nil.
Computed when asked, in the plan's precision: one Hessian of the task cost in the actions,
(H m)^2 entries, and one mixed derivative. tolerance is shadow_prices'.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a plan held under a barrier, whose condition moves with the channel; on
one solved with pessimism penalties, whose regret on the task cost has a
first-order term; on one that carries no problem, as one built by hand does; or
where |
planned_cost_bias(covariance, *, error_mean=None, tolerance=None)
¶
How far the plan's own cost reads from what it costs the plant, in expectation over the estimate's error, and the cost corrected for it. Experimental.
The plan was made on an estimate of its one-step channel, off the plant's by e with
covariance = S = E[e e'], e the estimate less the plant's channel, raveled as
decision_weight ravels it. Its planned cost, the task cost on the estimate, misses
what the plan pays on the plant by
E[J_true - J_model] = tr(W S) - tr(J_ee S) / 2 - J_e . E[e]
to second order (validation/planned_cost_bias.mac), with W the decision weight,
J_ee the task cost's Hessian in the channel at the fixed plan and J_e its gradient.
The first term is the optimiser's curse and is never negative: the plan fits its estimate's
error. The second is the curvature of the planned cost at the fixed plan, never positive
where that cost is convex in the channel. So the sum has no fixed sign. On one step of
x1 = a x0 + b u at the cost q x1^2 + r u^2 it is a^2 q^2 x0^2 (2 r^2 - 5 b^2 q r
+ b^4 q^2) / (r + b^2 q)^3 per unit of S, which is negative, a planned cost that
reads high, for q b^2 / r between (5 - sqrt 17)/2 = 0.438 and (5 + sqrt 17)/2 =
4.562, and positive outside. The third term is the estimate's own bias, error_mean,
0 where it is not given; a cross-fitted channel's is of the order of S.
Each term is read at the plan and the estimate, not at the plant's channel, which the
expansion is about: the difference is of higher order. It holds while the plan's active set
does not move with the error, so a plan that meets a bound or a row with a zero multiplier
is refused; and as far as W does, for an error small against the channel (see
decision_weight). residual is the stationarity miss along the free directions:
a solve stopped by its budget carries a first-order term that W does not.
chc.misspecification.planned_cost_bias reads S off a fitted channel's
influence, over every parameter the fit holds, the drift's included.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a |
RowPrice
dataclass
¶
What one constraint row is worth to the plan: how much its objective falls per unit the row is relaxed -- an upper bound raised, a lower bound lowered.
status says how far price can be read:
exact-- the row binds, and the binding rows are independent on the actions the box leaves free (LICQ), so the multiplier is unique.weakly_active-- the row sits at its bound with a multiplier of zero: relaxing it buys nothing to first order, and tightening it costs nothing to first order.degenerate-- the binding rows are dependent on the free actions, so the multiplier is a set:priceisNoneandintervalits range. Relaxing the bound buysinterval[0]a unit and tightening it costsinterval[1], both widened by the stationarity slack.inactive-- the row has room, and is worth nothing at the margin.
An equality row's price takes either sign: the fall per unit its value rises.
ShadowPrices
dataclass
¶
The rows' prices at a plan, with what it takes to trust them.
See CausalPlan.shadow_prices.
DecisionWeight
dataclass
¶
What an error in the one-step channel costs a plan, to second order. Experimental.
See CausalPlan.decision_weight.
regret(change)
¶
vec(E)' W vec(E) / 2 at change = E: what the plan loses, to second order,
against the plan that knew its one-step channel was off by E.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a |
PlannedCostBias
dataclass
¶
A plan's planned cost, its bias against what the plan pays the plant, with its sign, and the cost corrected for it, to second order in the estimate's error. Experimental.
See CausalPlan.planned_cost_bias.
bias
property
¶
E[J_true - J_model]: what the plan pays the plant less what it reads, in
expectation; positive where the planned cost reads low.
corrected_cost
property
¶
The planned cost plus its bias: what the plan pays the plant, in expectation.
direction
property
¶
Which way the planned cost reads (CostBiasDirection).
BarrierConstraint
dataclass
¶
grad h . xdot >= -alpha * h at every planned step, against every effect the data allow.
The condition certify_safety audits, handed to causal_plan to hold in the solve
rather than to price after it. barrier is h -- safe where h >= 0, a JAX-traceable
scalar function; alpha is the class-K gain; gamma and cvar_gap set the
identification radius (gamma-1)/(gamma+1) * cvar_gap * ||B_hat(x0)||_2 on the effect,
cvar_gap a share of the channel's operator norm at the plan's start, so the condition is
the worst case over the identified set and gamma = 1 is the ordinary CBF condition. The
defaults and the calibration burden on cvar_gap are certify_safety's.
Several bounds are a tuple of barriers, safe where every one is >= 0, and each holds its own
condition at every step. Their least is no substitute: its condition jumps where two tie,
reading on each side that side's gradient, so a solve that ends on the tie cannot step off it,
and the condition it holds asks nothing of a bound until that bound is the nearest.
u_max, one number per lever, each lever's largest action in its own units, reads the
condition in units of each lever's own largest action, as certify_safety does with the
same numbers, and the audit after the solve reads them; None reads it in the levers' own
units, one ball for all of them, as before. chc.prescribe passes its box's. A lever
whose number is 0 must have a box that holds it at 0.
Raises:
| Type | Description |
|---|---|
ValueError
|
on a |
SafetyCertificate
dataclass
¶
§40 evaluated along a finished plan: how much confounding its safety guarantee survives.
Two questions that must not be conflated, so both are reported:
planned_certified-- does the action the planner actually chose still clear the barrier once the adversary moves the effect inside the identified set? This is about this plan.gamma_star-- does any admissible action clear it, and up to which sensitivity level? This is about the problem, and is the number an operator can act on. A plan can fail the first while the second is comfortable, which says the planner, not the confounding, is at fault; the reverse says no controller would have helped.
PlanRegretBound
dataclass
¶
How far a finished plan can be from the best one the same box allows (Result 69, L3.2).
Result 6's self-certifying bound J(U) - J* <= |grad J|^2/(2 mu) needs no optimum, which is
what makes it a certificate rather than a diagnostic. It does not survive a box: at a lever the
gradient holds against its own bound grad J is nonzero while the true regret is zero, so
that bound charges regret to a coordinate that cannot move. Result 13 read the same fact from
the other side -- an active cap freezes the control and the regret's curvature collapses.
What replaces it is the same quantity maximised over the feasible moves only. Per coordinate
the certified gain of a move d is -g d - (mu/2) d^2, and its maximum over
[lo - U, hi - U] is the unconstrained one minus a perfect square
(validation/constrained_plan_regret.mac STEP 3a): never larger, exactly zero at a lever the
gradient pins, and still finite as mu -> 0, where it degrades to frank_wolfe_gap
rather than to infinity.
The moves are measured lever by lever, each in its own scale (STEP 8 of the same file): the
modulus is the curvature in the norm |D^-1 d|, D one scale per lever that moves with
that lever's units, and a move's curvature along lever j is mu / s_j^2. In the plain
norm the modulus is the cheapest lever's curvature, which charges a stiff lever's moves at a
cheap one's, and the bound read the levers' units: lever u1 in units 1e3 times its own
took one plan's bound from 0.0266 to 0.0576, where it now reads 0.0214 in both. Measured, the
scales come from the Hessian's diagonal (_objective_modulus); a supplied modulus is
the plain norm's, every scale 1.
The bound is on the planning objective, which is the question the solver was asked. How far
the planning model itself is from the plant is the error tube's question
(CausalPlan.uncertainty_tube), and the two must not be added.
It is a certificate only where the modulus is one for the whole box: supplied, or read off an
objective the model's structure makes quadratic (status). A modulus sampled at the plan
and a few points of the box is not: a pocket of negative curvature between the samples can hide
a plan much cheaper than this one, and the bound then reads small with nothing behind it.
causal_plan(model, x0, cost, dt, horizon, u_lo, u_hi, *, support=None, lam_supp=0.0, uncertainty=None, lam_unc=0.0, lipschitz=0.0, model_error=0.0, noise=0.0, noise_persistence=0.0, tolerance=float('inf'), steps=10000, constraints=(), barrier=None, warm_start=None)
¶
Plan under box constraints, optional offline pessimism, and a certified error tube.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
support
|
SupportModel | None
|
offline |
None
|
uncertainty
|
PenaltyModel | None
|
a |
None
|
lipschitz, model_error
|
feed the error tube of the plan's RK4 rollout, the field off by at
most |
required | |
noise, noise_persistence
|
the plant's own error at each step, which no model error
covers: |
required | |
tolerance
|
float
|
tube radius above which the plan stops being certified. |
float('inf')
|
constraints
|
Sequence[LinearConstraint]
|
linear rows over the whole action sequence
( |
()
|
barrier
|
BarrierConstraint | None
|
a condition to hold at every planned step ( |
None
|
warm_start
|
Array | None
|
the |
None
|
Raises:
| Type | Description |
|---|---|
ValueError
|
if an uncertainty penalty is given without a support model, which would
silently drop it -- the pessimistic solver is the only consumer of that argument; if
|
certify_safety(plan, model, barrier, dt, *, alpha=1.0, gamma=1.0, cvar_gap=1.0, u_max=None)
¶
Price a plan's barrier guarantee against a partially identified control effect (§40).
The plan was made under a point estimate of the effect. If that estimate came from confounded
logs the effect matrix is only set-identified, and given an operator-norm identification
radius ||B_hat - B||_op <= Delta the channel w = B^T grad h is pinned within
d = Delta * ||grad h||, so the guaranteed barrier derivative at action u drops to
a + <w, u> - d*||u|| (chc.barrier). This walks the nominal trajectory and reports
where that guarantee survives.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
barrier
|
Barrier
|
|
required |
alpha
|
float
|
the class-K gain in |
1.0
|
gamma, cvar_gap
|
the marginal sensitivity model's level and the gap it scales, combined into
|
required | |
u_max
|
ArrayLike | None
|
actuation limit, used for |
None
|
Assumes the plant is control-affine and the uncertainty set isotropic (see chc.barrier;
d*||u|| is exact for a Euclidean ball, an outer bound otherwise), and audits the model's
trajectory pointwise. Forward invariance (Nagumo/Brezis) is assumed as throughout the CBF
literature; what is certified is the pointwise condition.
Raises:
| Type | Description |
|---|---|
ValueError
|
if |
plan_regret_bound(plan, model, x0, cost, dt, u_lo, u_hi, *, modulus=None, probes=16, seed=0)
¶
RESULT 69 (L3.2) -- the optimality gap of a finished plan, certified from its own gradient.
Derived in validation/constrained_plan_regret.mac, proved in
proofs/constrained_plan_regret.v. Reads the plan's actions, not the solver's internals, so
it prices a plan that came from anywhere -- including one an operator edited by hand.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
modulus
|
float | None
|
the strong-convexity modulus of |
None
|
probes
|
int
|
random feasible points added to the curvature sample when |
16
|
The three numbers to read together: bound is what the box certifies,
unconstrained_bound is what Result 6 would have reported at the same
plan, and frank_wolfe_gap is what survives if the modulus goes to zero.
status says what the bound is: certified on a supplied modulus or
a linear model's, diagnostic on a sampled one, and refused -- the bound inf -- when
the modulus is negative, because then no convexity argument applies and a finite number would be
a fabrication, and when the modulus or the bound is not a finite number.
Raises:
| Type | Description |
|---|---|
ValueError
|
if a supplied |