Adaptive loops: decisions on a stream of drifting evidence¶
A real service does not make one decision. It makes the same kind of decision every request, every hour or every alert, while the evidence behind it improves, decays, wanders around a cut-off or simply gets noisier. This track shows what the library does in each of those situations, and how to rehearse it before launch so you know how often the service will act on its own and how many cases will reach a person.
It is for data scientists sizing a launch, policy owners who want to know how often a decision will be held, and engineers building the dashboards. It assumes you have seen one decision (the step track or Your first decision). Terms are defined in the glossary.
uv run grounded-decision examples --track adaptive
uv run grounded-decision examples adaptive_evidence_ramp
The source is src/grounded_decision/examples/adaptive.py. For the task
pages built on these runners, see
Estimate impact before launch (act rate
and review-queue size) and Operate and monitor
(drift dashboards and alerts).
The two rehearsal tools: a baseline and a spread¶
To rehearse, you need to say what a typical day's evidence looks like
and how much one day strays from it. The library has one object for
each, in grounded_decision.integrate.
EvidencePrior is the baseline: the evidence you expect on a
typical decision, before you look at today's graph. In a running service
it is the mean of each logged evidence field over, say, the last 30
days.
EvidencePrior field |
What it means in production |
|---|---|
n |
typical number of cases in the evidence graph |
degree |
mean links per case |
lambda_mean |
mean neighbour agreement (λ) |
p_mean |
mean link density |
p_threshold |
your cut-off density (a tuning constant, not averaged) |
label_mean |
typical candidate score |
n_cal, cal_loc, cal_step |
an evenly spaced stand-in for your calibration set |
mean_d_lambda_sq() returns the evidence strength at the baseline,
degree × lambda_mean². If it is not comfortably above 1.05, the
service will rarely act even on a calm day.
Volatility is the spread: how far one decision's evidence
typically strays from the baseline, as a standard deviation per field.
Three presets ship:
| Name | links σ | agreement σ | density σ | score σ | Stands for |
|---|---|---|---|---|---|
CALM |
0 | 0 | 0 | 0 | every draw equals the baseline; deterministic sweeps |
MILD |
0.4 | 0.04 | 0.02 | 0.05 | a stable service on an ordinary week |
WILD |
2.0 | 0.20 | 0.12 | 0.35 | an outage, a holiday, a broken pipeline, an attack |
draw_snapshot(prior, volatility, rng=...) draws one simulated
snapshot. It clips draws to legal ranges (links to [0, 64], agreement
and density to [0, 1], score to [0, 2]) and never adds noise to the
calibration set: refreshing the calibration set is a product decision,
not a volatility setting.
Estimating them from your own logs¶
estimate_prior_and_volatility(logged_evidence, decision_density_threshold=...)
takes the snapshots you already log with every decision and returns
(EvidencePrior, Volatility): the mean of each field and its sample
standard deviation. It lives in the examples module
grounded_decision.examples.adaptive, which is example code, not part
of the supported API. Copy its twenty lines into your own code rather
than importing it in production.
from grounded_decision.examples.adaptive import estimate_prior_and_volatility
from grounded_decision.integrate import DecisionSession, draw_snapshot
from grounded_decision.types import Event, EvidenceSnapshot
import numpy
# Last 30 days of decisions, read back from your decision log.
logged_evidence = [
EvidenceSnapshot.from_dict(row["evidence"]) for row in load_decision_log(days=30)
]
baseline, day_to_day_spread = estimate_prior_and_volatility(
logged_evidence, decision_density_threshold=0.08
)
random_generator = numpy.random.default_rng(0)
session = DecisionSession(build_my_policy(), reset_each_tick=True)
for _ in range(1000):
session.step(
Event("suspicious"),
draw_snapshot(baseline, day_to_day_spread, rng=random_generator),
)
automatic_rate = sum(record.acted for record in session.records) / 1000
automatic_rate is the share of cases the policy handles without a
person on a typical day; one minus it, times your daily volume, is the
review queue. Re-run with WILD to size the queue for a bad day.
(load_decision_log and build_my_policy are yours.) Because draws are
clipped, a spread estimated from simulated draws under WILD comes out
a little smaller than the preset that produced them.
Sessions and trajectories¶
DecisionSession(module, reset_each_tick=...)feeds a stream of(event, evidence)pairs, called ticks, to one module.reset_each_tick=Truetreats every tick as a new case (a new authorisation, post or alert).reset_each_tick=Falsekeeps the policy's state between ticks (one ticket, one incident, one access request moving through a workflow).trajectory(records)turns a run into dashboard rows:t,event,verdict,d_lambda_sq,p_hat,in_operational_window,conformal_p,failed_channels. Every runner below returns it assummary["trajectory"].
The simulators (draw_snapshot, ramp_snapshots, random_walk_p) are
for rehearsal in CI and design reviews. At decision time, always build
the snapshot from live data with snapshot_from_graph.
The knife-edge check does not know which side of the line you are on
The knife-edge check refuses to decide when the link density is
near your cut-off. Far from the line it passes on either side: a
density far below the cut-off passes just as a density far above
it does. If "above the line" and "below the line" mean different
actions, your policy must receive different events for each side
(for example dense_resharing and sparse_resharing) and propose a
different action for each. Otherwise a density far below the line
will pass the check and carry out the "above" action. See
Write a policy and
Are we on a knife-edge?.
adaptive_evidence_ramp¶
The situation. A newsroom wants to publish a short alert about a breaking claim automatically, once enough reports corroborate it. Publishing on the first report is how false alerts happen; waiting for an editor at 3am is how you end up an hour late. Each report is a case; two reports are linked when they describe the same scene and can be checked against each other; a report's label is whether it confirms the claim. The desk re-checks every ten minutes as reports arrive.
What to notice. Corroboration grows steadily from two loosely
agreeing reports per report (evidence strength \( 2 \times 0.20^2 = 0.08 \))
to ten strongly agreeing ones (\( 10 \times 0.60^2 = 3.6 \)). The first
six checks hold, with strength rising 0.08, 0.15, 0.26, 0.40, 0.59,
0.82, all below the 1.05 line. The seventh check, at minute 70
(first_publish_minutes_after_first_report = 70), reaches 1.11 and
publishes. Density and scores never approach their limits, so neighbour
agreement is the only check that moves.
The takeaway. The library waits for corroboration rather than for a fixed delay. In production, stop re-checking after the first publish, and build each check's snapshot from the reports collected so far.
adaptive_threshold_walk¶
The situation. A trust-and-safety desk takes down posts amplified by coordinated accounts. The rule "take down when the reshare density exceeds 0.20" flips every hour while the density hovers near 0.20, and every flip is a user-visible action and an appeal. The 200 accounts that shared the post are the cases; two are linked when one reshared the other within a minute. Every other piece of evidence is held strong.
What to notice. The hourly density takes a random walk from 0.20
with an hourly standard deviation of 0.03. With 200 accounts the
module refuses any density within 0.20 ± 0.057
(window_half_width = 0.057). The densities run 0.21, 0.235, 0.245, …,
so the first eight hours are held. From hour 8 (density 0.259) the post
is taken down; hour 14 dips back to 0.243 and is held again. In total,
9 hours held (hours_inside_threshold_window = 9) and 7 takedowns.
The takeaway. Near the cut-off the library refuses to flip a user-visible decision back and forth. Read the warning above: in this run the density stays above 0.20, but a policy that proposes a takedown on every hourly review would also take the post down at a density far below the line. Emit separate events for each side.
adaptive_volatility_regimes¶
The situation. The fraud dashboard shows the average evidence
quality, and the average is healthy. How often will an individual
flagged authorisation fall below the line anyway? The baseline is 8
links per account, agreement 0.55 (strength 2.42), density 0.35 against
a 0.08 cut-off. The runner draws twenty flagged authorisations under
each of CALM, MILD and WILD and decides each with the
fraud_review policy.
What to notice. blocks_by_regime is {calm: 20, mild: 20, wild: 11}.
On the wild day nine authorisations are held instead of auto-blocked:
five fail neighbour agreement (strengths such as 0.17, 0.86 and 1.02,
all at or below 1.05), four have a density inside the knife-edge window
(one of them also fails agreement), and one fails the unusual-case check
(p = 0.049). The run also feeds each regime's draws back through
estimate_prior_and_volatility; for MILD it recovers 0.36 links,
0.035 agreement, 0.017 density and 0.055 score, close to the preset.
The takeaway. A healthy average hides a tail of individual cases below the line. Estimate your own spread from logs to see which preset your service lives in, and size the review queue from the bad-day run, not the average.
adaptive_prior_grid¶
The situation. Before building a pipeline that labels online communities automatically (for example "medical advice"), a team has to decide how many links per member to collect, given how strongly linked members' labels agree.
What to notice. The grid is links per member {2, 4, 8, 16} by agreement {0.20, 0.35, 0.55, 0.80}, one deterministic decision per cell. The module labels only where links × agreement² exceeds 1.05:
- 16 links at agreement 0.20 give 0.64: held;
- 8 links at agreement 0.35 give 0.98: held, just short;
- 4 links at agreement 0.55 give 1.21: labelled;
- 2 links need agreement 0.80 (1.28).
summary["grid_cells"] lists all sixteen cells.
The takeaway. Agreement matters more than volume, because it enters squared: doubling agreement is worth four times as many links. Run the same sweep with your own measured agreement to decide which data source to build first.
adaptive_workflow_over_time¶
The situation. The access-request workflow from the step track, now
repeating:
(test(access_requested) ; (test(manager_approved) ; act(grant_access) | test(manager_rejected) ; act(close_request)))*.
The module is never reset. One engineer makes two requests a quarter
apart: the first in the team's first week, when the peer group is
barely linked (2 links each, agreement 0.2, density 0.10 near the 0.08
cut-off), the second once the team has settled.
What to notice.
| Tick | Event | Evidence | Verdict | Proposed action | State |
|---|---|---|---|---|---|
| 0 | access_requested |
new team | abstain |
none (waiting) | s1 |
| 1 | manager_approved |
new team | abstain |
grant_access, blocked |
s3 |
| 2 | access_requested |
settled | abstain |
none (waiting) | s1 |
| 3 | manager_approved |
settled | act |
grant_access |
s3 |
Tick 1 is a real hold: the approval cannot be backed by peer evidence, so a person decides. Ticks 0 and 2 are waiting for approval; on tick 0 the data checks also fail, but nothing was proposed, so nothing was blocked.
The takeaway. Where a request is in the workflow and how good its
evidence is are independent. A request can be at the right step with
the wrong evidence (tick 1), or have good evidence and nothing to do yet
(tick 2). Log both extras["state"] and the snapshot.
adaptive_incident_loop¶
The situation. On-call automation that mitigates and pages, using
the policy from the incident response case:
alert starts mitigation, still_bad pages, ack returns to
mitigation, clear resolves, and a new alert reopens. Hosts are the
cases, linked when they share a dependency (database, cache, load
balancer); a host's label is whether it is alerting. One incident, five
events, state kept throughout. Two of the events arrive with flappy
evidence: one host whose neighbours disagree with it.
What to notice.
| Event | Evidence | Verdict | Proposed | State after |
|---|---|---|---|---|
alert |
outage | act |
mitigate |
mitigating |
still_bad |
flappy | abstain |
page_oncall |
paging |
ack |
outage | act |
mitigate |
mitigating |
clear |
outage | act |
close |
resolved |
alert |
flappy | abstain |
mitigate |
mitigating |
The flappy still_bad pages nobody, yet the saved state moves to
paging. The flappy final alert does not reopen the incident's
mitigation, yet the state moves to mitigating.
The takeaway. The state follows the events; the verdict only
decides whether the action is carried out. After a hold, the saved
state describes where the incident would be if the proposal had run.
Your hold handler should treat the person's response as the next event
(for example ack once they take over) so the state and reality line up
again. The policy's progress check passes here, but that check is weak:
it only fails for policies whose every reachable state is marked bad,
and it does not detect general livelock (see
Can the workflow keep going?). The
holds on individual ticks come from the evidence.
adaptive_conformal_drift¶
The situation. A lender approves small credit lines automatically. The calibration set is the model's scores on last quarter's loans with known outcomes. A new acquisition channel then sends applicants unlike last quarter's, and the typical score climbs 0.12 per week. The graph evidence (applicants linked by shared employer and address history) stays healthy.
What to notice. Weeks are numbered from 0. The unusualness p-value
falls week by week: 1.0, 1.0, 1.0, 0.98, 0.83, 0.68, 0.54, 0.39, 0.24.
In week 8 the score still has 9 of 40 calibration scores at or above it
(p = 10/41 = 0.24) and is approved. In week 9 only 3 are (p = 4/41 =
0.098, below 0.10), and applications are held for an underwriter
(first_week_held_for_underwriter = 9).
The takeaway. The unusual-case check is the drift alarm. The first hold is the signal to refresh the calibration set, and probably the model, on the new population.
adaptive_autoscaling_loop¶
The situation. A naive autoscaler scales out on every CPU alert, so
one noisy instance can buy capacity all day. Instances are the cases,
linked when they sit behind the same load balancer; an instance's label
is whether it is saturated. Load ramps from a quiet fleet to sustained
load over eight minutes and back over eight more, with MILD noise and
a CPU alert every minute.
What to notice. The module holds for the first four and last four
minutes (evidence strength 0.03 to 0.95, below the line) and scales out
in the middle eight (1.39 up to 4.88): scale_out_count = 8, where the
naive autoscaler scales 16 times.
The takeaway. Scale-out follows fleet-wide saturation, not a single alert. In production, add a cool-down after each scale-out and a bound on consecutive holds while load stays high; the example leaves both out to show the raw verdicts. The cloud autoscaling case discusses the bound.
Independent ticks versus kept state¶
Six runners treat each tick as a new case: adaptive_evidence_ramp,
adaptive_threshold_walk, adaptive_volatility_regimes,
adaptive_prior_grid, adaptive_conformal_drift and
adaptive_autoscaling_loop. They answer "what does this evidence do to
the decision?"
adaptive_workflow_over_time and adaptive_incident_loop keep state.
They answer "what does this sequence of events, plus evidence, do?" A
flappy still_bad in the middle of an incident is a different hold from
a flappy alert on a quiet system, because the policy is in a different
place.
Mixing the two by accident is a common bug. If you keep state on a
policy whose only path is "intake, then proposal", the second tick
arrives at a state with no matching transition, and you get an abstain
with every check passing and nothing proposed. step_unknown_event_stutter
on the step track shows that in one record.
Wiring this into your system¶
- Log every snapshot. Store
evidence.to_dict()with eachrecord.to_dict(). Without it you cannot estimate a baseline or explain a hold later. - Estimate nightly. Recompute the baseline and spread over the last
30 days. Alert when the baseline evidence strength
(
prior.mean_d_lambda_sq()) drifts toward 1.05: the service is about to stop acting even on ordinary days. - Rehearse in CI. For each policy change, draw a few hundred
snapshots from the estimated baseline under the estimated spread and
under
WILD, and assert that the automatic rate stays within the range your team agreed. Fix the seed (numpy.random.default_rng(0)) so counts are reproducible. - Serve on live data only. At decision time, build the snapshot
with
snapshot_from_graph; never decide on a simulated draw. - Choose the session shape. Reset per tick when each tick is a new
case; persist and restore
extras["state"]when ticks belong to one entity (seestep_json_roundtrip). - Dashboard the trajectory. Plot
d_lambda_sqagainst 1.05,in_operational_window, andconformal_pagainst 0.10 over time.failed_channelstells you which check is behind a rise in holds.
A tick that fails two checks is still one abstain, with a longer
reason. The module does not produce a severity score; log the list of
failed checks instead. Nothing inside the module learns a baseline
either. If you want a Bayesian update, compute it outside the module,
put the result into an EvidenceSnapshot, and decide.