certflow 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
certflow/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """CERT-FLOW: certified route planning under drifting costs.
2
+
3
+ Every replanning round emits a high-probability certificate LB <= OPT <= UB
4
+ on the optimal route cost, built from age-weighted non-exchangeable conformal
5
+ prediction over drift-adjusted observation residuals, and directs paid sensing
6
+ at the edges that shrink the certified gap fastest.
7
+
8
+ Quickstart::
9
+
10
+ from certflow import CertPlanner, PlannerConfig
11
+ from certflow.drift import grid_world
12
+
13
+ world = grid_world(6, 6, seed=0, kind="bounded", rho=0.02, noise_scale=0.05)
14
+ planner = CertPlanner(world, (0, 0), (5, 5),
15
+ PlannerConfig(epsilon=5.0, alpha_prime=0.2))
16
+ for _ in range(150):
17
+ cert, sensed = planner.round()
18
+ print(cert.lb, cert.ub, cert.confidence)
19
+
20
+ Submodules: conformal (quantile machinery), cert (the planner loop), sensing
21
+ (observation selection), fastgraph (flat-array engine), snapshot / ch
22
+ (certificate-gated preprocessing), drift / realworld / movingai / roadnet
23
+ (worlds and graphs), harness / episodes / oracle (experiment infrastructure).
24
+ """
25
+ from certflow.cert import CertPlanner, PlannerConfig
26
+ from certflow.conformal import ACITracker, ConformalScorer
27
+ from certflow.types import Certificate, EdgeBelief, World
28
+
29
+ __version__ = "1.0.1"
30
+
31
+ __all__ = [
32
+ "ACITracker",
33
+ "CertPlanner",
34
+ "Certificate",
35
+ "ConformalScorer",
36
+ "EdgeBelief",
37
+ "PlannerConfig",
38
+ "World",
39
+ "__version__",
40
+ ]
certflow/baselines.py ADDED
@@ -0,0 +1,279 @@
1
+ """Gaussian mu±beta*sigma calibration baseline for CERT.
2
+
3
+ This module provides the honest strawman for the paper's coverage claim: the
4
+ same planner loop, same widening, same D* Lite dual search — only the quantile
5
+ machinery is swapped. The key contrast:
6
+
7
+ - ConformalScorer: distribution-free, uses the empirical weighted quantile.
8
+ Coverage degrades gracefully on heavy-tailed or drifted noise.
9
+ - GaussianScorer: parametric, uses mu + z_{1-alpha} * sigma. Claims full
10
+ confidence (delta_stale = 0) regardless of buffer age. Over-claims coverage
11
+ on heavy-tailed distributions — that is the mechanism under test.
12
+
13
+ The GaussianCertPlanner disables ACI (gamma -> 0) because the purpose of this
14
+ baseline is to expose the raw Gaussian claim without the assumption-free ACI
15
+ safety net widening it back. Leaving ACI active would partially mask the
16
+ over-claim and muddy the comparison. This is documented via the ``use_aci``
17
+ flag attribute; the underlying trick is replacing the ACITracker with one that
18
+ has gamma=1e-12 so working_alpha stays effectively fixed at alpha_target.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import math
23
+
24
+ from scipy.stats import norm
25
+
26
+ from certflow.cert import CertPlanner, PlannerConfig
27
+ from certflow.conformal import ACITracker, CalSample
28
+ from certflow.drift import grid_world
29
+ from certflow.harness import ExperimentConfig
30
+ from certflow.oracle import opt
31
+ from certflow.types import EpisodeResult, RoundLog
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # GaussianScorer
36
+ # ---------------------------------------------------------------------------
37
+
38
+ class GaussianScorer:
39
+ """Gaussian mu±z*sigma quantile using the same age-geometric weights as
40
+ ConformalScorer.
41
+
42
+ Interface mirrors ConformalScorer exactly:
43
+ push(residual, t), quantile(alpha, t), delta_stale(t), ready(alpha, t),
44
+ __len__.
45
+
46
+ Key differences from ConformalScorer
47
+ -------------------------------------
48
+ - quantile: parametric Gaussian formula using weighted mean and weighted
49
+ std, then z_{1-alpha} (scipy.stats.norm.ppf). No distribution-free
50
+ quantile computation.
51
+ - delta_stale: always 0.0. The Gaussian baseline silently assumes full
52
+ confidence regardless of how stale the buffer is. This over-claim is
53
+ the exact mechanism the paper tests against.
54
+ - ready: requires sum of weights >= 5 (effective sample count >= 5).
55
+ Returns False until threshold is met; quantile returns +inf before then.
56
+
57
+ Parameters
58
+ ----------
59
+ rho_w : float
60
+ Age-geometric weight decay per unit time (same meaning as
61
+ ConformalScorer.rho_w).
62
+ max_buffer : int
63
+ Rolling buffer capacity (same as ConformalScorer).
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ rho_w: float = 0.99,
69
+ max_buffer: int = 2000,
70
+ ) -> None:
71
+ if not 0.0 < rho_w <= 1.0:
72
+ raise ValueError("rho_w must be in (0, 1]")
73
+ self.rho_w = rho_w
74
+ self.max_buffer = max_buffer
75
+ self._buf: list[CalSample] = []
76
+
77
+ def push(self, residual: float, t: float) -> None:
78
+ self._buf.append(CalSample(residual, t))
79
+ if len(self._buf) > self.max_buffer:
80
+ self._buf.sort(key=lambda s: s.t)
81
+ del self._buf[0 : len(self._buf) - self.max_buffer]
82
+
83
+ def __len__(self) -> int:
84
+ return len(self._buf)
85
+
86
+ def _weights(self, t: float) -> list[float]:
87
+ return [self.rho_w ** max(0.0, t - s.t) for s in self._buf]
88
+
89
+ def _effective_n(self, t: float) -> float:
90
+ return sum(self._weights(t))
91
+
92
+ def quantile(self, alpha: float, t: float) -> float:
93
+ """Weighted Gaussian (1-alpha)-quantile: mu_w + z_{1-alpha} * sigma_w.
94
+
95
+ Returns +inf when ready() is False (fewer than 5 effective samples).
96
+ """
97
+ if not 0.0 < alpha < 1.0:
98
+ raise ValueError("alpha must be in (0, 1)")
99
+ if not self._buf:
100
+ return math.inf
101
+ w = self._weights(t)
102
+ w_sum = sum(w)
103
+ if w_sum < 5.0:
104
+ return math.inf
105
+ # Weighted mean
106
+ residuals = [s.residual for s in self._buf]
107
+ mu = sum(wi * r for wi, r in zip(w, residuals)) / w_sum
108
+ # Weighted variance (reliability weights: divide by w_sum, no Bessel)
109
+ var = sum(wi * (r - mu) ** 2 for wi, r in zip(w, residuals)) / w_sum
110
+ sigma = math.sqrt(var) if var > 0.0 else 0.0
111
+ z = float(norm.ppf(1.0 - alpha))
112
+ return mu + z * sigma
113
+
114
+ def delta_stale(self, t: float) -> float:
115
+ """Always 0.0.
116
+
117
+ The Gaussian baseline claims full confidence regardless of how old the
118
+ calibration buffer is. This is the over-claim the paper is designed to
119
+ expose: there is no staleness correction, so the reported confidence
120
+ never degrades with buffer age.
121
+ """
122
+ return 0.0
123
+
124
+ def push_signed(self, deviation: float, t: float) -> None:
125
+ """Interface parity with ConformalScorer; the Gaussian baseline does
126
+ not implement the sum-aware UB (T4), so signed deviations are dropped."""
127
+
128
+ def block_quantile(self, alpha: float, t: float, block_len: int) -> float:
129
+ return float("inf")
130
+
131
+ def block_delta_stale(self, t: float, block_len: int) -> float:
132
+ return 1.0
133
+
134
+ def effective_mass(self, t: float) -> float:
135
+ """Interface parity: weighted effective sample size (for annealing)."""
136
+ return sum(self._weights(t)) if hasattr(self, "_weights") else float(len(self))
137
+
138
+
139
+ def ready(self, alpha: float, t: float) -> bool:
140
+ """True when the effective sample weight sum >= 5."""
141
+ return math.isfinite(self.quantile(alpha, t))
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # GaussianCertPlanner
146
+ # ---------------------------------------------------------------------------
147
+
148
+ class GaussianCertPlanner(CertPlanner):
149
+ """CertPlanner subclass with GaussianScorer and ACI disabled.
150
+
151
+ Construction: calls super().__init__ then replaces self.scorer with a
152
+ GaussianScorer (same rho_w) and replaces self.aci with an ACITracker
153
+ whose gamma=1e-12 so working_alpha stays essentially pinned at alpha_target
154
+ (the change in raw alpha per update is ~1e-12, negligible).
155
+
156
+ The ``use_aci`` flag (False by default) documents intent: the Gaussian
157
+ baseline intentionally does not use the assumption-free ACI safety net.
158
+ Enabling ACI (use_aci=True) is valid but changes the comparison by letting
159
+ the ACI net partially compensate for Gaussian over-claims.
160
+ """
161
+
162
+ def __init__(
163
+ self,
164
+ world,
165
+ start,
166
+ goal,
167
+ config: PlannerConfig,
168
+ t0: float = 0.0,
169
+ use_aci: bool = False,
170
+ ) -> None:
171
+ super().__init__(world, start, goal, config, t0=t0)
172
+ # Swap the conformal scorer for the Gaussian one (same rho_w).
173
+ self.scorer = GaussianScorer(
174
+ rho_w=config.rho_w,
175
+ max_buffer=2000,
176
+ )
177
+ self.use_aci = use_aci
178
+ if not use_aci:
179
+ # Freeze working_alpha by using a vanishingly small gamma.
180
+ # ACITracker still accepts updates (no structural changes to the
181
+ # round() loop) but alpha_raw moves by at most ~1e-12 per step,
182
+ # so working_alpha() is effectively constant at alpha_target.
183
+ self.aci = ACITracker(
184
+ alpha_target=config.alpha_prime,
185
+ gamma=1e-12,
186
+ )
187
+
188
+
189
+ # ---------------------------------------------------------------------------
190
+ # gaussian_tier0_episode
191
+ # ---------------------------------------------------------------------------
192
+
193
+ def gaussian_tier0_episode(config: ExperimentConfig, seed: int) -> EpisodeResult:
194
+ """Tier-0 episode using GaussianCertPlanner.
195
+
196
+ Mirror of episodes.tier0_episode; constructs a GaussianCertPlanner
197
+ instead of CertPlanner. Reuses planner_config and coverage_among_valid
198
+ from certflow.episodes without modifying that module.
199
+ """
200
+ from certflow.episodes import planner_config # refactor-free reuse
201
+
202
+ import time
203
+
204
+ world_kwargs: dict = {
205
+ "noise_family": config.noise_family,
206
+ "noise_scale": config.noise_scale,
207
+ }
208
+ if config.kind == "bounded":
209
+ world_kwargs["rho"] = config.rho
210
+ world = grid_world(config.rows, config.cols, seed=seed, kind=config.kind, **world_kwargs)
211
+
212
+ start, goal = (0, 0), (config.rows - 1, config.cols - 1)
213
+ planner = GaussianCertPlanner(
214
+ world, start, goal, planner_config(config), use_aci=False
215
+ )
216
+
217
+ result = EpisodeResult()
218
+ prev_spend = planner.sense_spend
219
+ for _ in range(config.max_rounds):
220
+ t_round = planner.t
221
+ wall = time.perf_counter()
222
+ cert, sensed = planner.round()
223
+ wall = time.perf_counter() - wall
224
+
225
+ _, true_opt = opt(world, t_round, start, goal)
226
+ covered = bool(
227
+ cert.valid and cert.lb - 1e-9 <= true_opt <= cert.ub + 1e-9
228
+ )
229
+ certified = bool(cert.valid and cert.gap <= config.epsilon)
230
+ result.rounds.append(
231
+ RoundLog(
232
+ t=t_round,
233
+ lb=cert.lb,
234
+ ub=cert.ub,
235
+ confidence=cert.confidence,
236
+ opt=true_opt,
237
+ covered=covered,
238
+ certified=certified,
239
+ sensed_edge=sensed,
240
+ sense_spend=planner.sense_spend - prev_spend,
241
+ replan_seconds=wall,
242
+ )
243
+ )
244
+ prev_spend = planner.sense_spend
245
+
246
+ result.sense_cost = planner.sense_spend
247
+ result.reached_goal = False # Tier 0 is stationary by design
248
+ return result
249
+
250
+
251
+ def adstar_bound(
252
+ beliefs,
253
+ graph_struct,
254
+ start,
255
+ goal,
256
+ w: float = 1.5,
257
+ cost_floor: float = 1e-3,
258
+ ):
259
+ """AD*/ARA*-semantics suboptimality interval from current point estimates.
260
+
261
+ Bounded-suboptimal search with inflation w returns a path P-hat whose
262
+ cost on ITS map satisfies cost(P-hat) <= w * OPT_map, i.e. the standard
263
+ claim OPT_map in [cost(P-hat)/w, cost(P-hat)]. That claim is sound on
264
+ the searcher's own (stale, noisy point-estimate) map and has no
265
+ mechanism for observation noise or staleness; we evaluate it as-is
266
+ against the TRUE drifting optimum. The comparison targets the bound
267
+ SEMANTICS under staleness, not the search algorithm (on these graph
268
+ sizes exact search is instant, so anytime behavior is not the axis).
269
+ """
270
+ from certflow.graphcore import dijkstra
271
+
272
+ g = {
273
+ u: {v: max(beliefs[(u, v)].c_hat, cost_floor) for v in nbrs}
274
+ for u, nbrs in graph_struct.items()
275
+ }
276
+ path, c = dijkstra(g, start, goal)
277
+ if path is None:
278
+ return 0.0, float("inf")
279
+ return c / w, c