tailor-aoi 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AiminLi-Hi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: tailor-aoi
3
+ Version: 0.1.0
4
+ Summary: Age-optimal sampling and preemption under heavy-tailed delays (TAILOR, IEEE ISIT 2026)
5
+ Author: Aimin Li, Yiğit İnce, Elif Uysal
6
+ License-Expression: MIT
7
+ Project-URL: Paper, https://doi.org/10.1109/ISIT62367.2026.11653616
8
+ Project-URL: arXiv, https://arxiv.org/abs/2601.16624
9
+ Project-URL: Code, https://github.com/AiminLi-Hi/aoi-preemption-heavy-tail
10
+ Project-URL: Project page, https://yigiti.github.io/Preemption/
11
+ Keywords: age of information,AoI,preemption,heavy-tailed delay,optimal stopping,impulse control,queueing,status update,information freshness,policy iteration
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
14
+ Classifier: Topic :: System :: Networking
15
+ Classifier: Intended Audience :: Science/Research
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy>=1.22
20
+ Requires-Dist: scipy>=1.8
21
+ Provides-Extra: plot
22
+ Requires-Dist: matplotlib>=3.5; extra == "plot"
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=7; extra == "test"
25
+ Dynamic: license-file
26
+
27
+ # tailor-aoi
28
+
29
+ Python implementation of **TAILOR**, the age-optimal joint sampling and preemption policy from
30
+ *Taming the Heavy Tail: Age-Optimal Preemption* (A. Li, Y. İnce, E. Uysal, IEEE ISIT 2026,
31
+ [DOI 10.1109/ISIT62367.2026.11653616](https://doi.org/10.1109/ISIT62367.2026.11653616)).
32
+
33
+ ![TAILOR vs. baselines](https://raw.githubusercontent.com/AiminLi-Hi/aoi-preemption-heavy-tail/main/assets/fig2_tailor.png)
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install tailor-aoi # core
39
+ pip install "tailor-aoi[plot]" # with matplotlib helpers
40
+ ```
41
+
42
+ ## Use
43
+
44
+ ```python
45
+ from tailor_aoi import ServiceDist, solve, compare
46
+
47
+ dist = ServiceDist.pareto2(mean=2.0, variance=40.0) # or lognormal_from_mean, exponential, from_scipy
48
+ policy = solve(dist, kappa_s=1.0, kappa_p=1.0) # optimal sampling + preemption policy
49
+ print(compare(dist, 1.0, 1.0, policy=policy)) # vs. Update-or-Wait, Zero-Wait, best static
50
+ ```
51
+
52
+ Command line: `python -m tailor_aoi --dist lognormal --mean 2 --sigma2 6`.
53
+
54
+ - Repository and MATLAB code: https://github.com/AiminLi-Hi/aoi-preemption-heavy-tail
55
+ - Colab quickstart: https://colab.research.google.com/github/AiminLi-Hi/aoi-preemption-heavy-tail/blob/main/python/examples/quickstart.ipynb
56
+ - Project page: https://yigiti.github.io/Preemption/
57
+
58
+ ## Citation
59
+
60
+ If you use this package, please cite the paper (`python -m tailor_aoi --cite` prints the BibTeX):
61
+
62
+ ```bibtex
63
+ @inproceedings{li2026taming,
64
+ title = {Taming the Heavy Tail: Age-Optimal Preemption},
65
+ author = {Li, Aimin and {\.I}nce, Yi{\u{g}}it and Uysal, Elif},
66
+ booktitle = {Proc. IEEE International Symposium on Information Theory (ISIT)},
67
+ year = {2026},
68
+ doi = {10.1109/ISIT62367.2026.11653616}
69
+ }
70
+ ```
@@ -0,0 +1,44 @@
1
+ # tailor-aoi
2
+
3
+ Python implementation of **TAILOR**, the age-optimal joint sampling and preemption policy from
4
+ *Taming the Heavy Tail: Age-Optimal Preemption* (A. Li, Y. İnce, E. Uysal, IEEE ISIT 2026,
5
+ [DOI 10.1109/ISIT62367.2026.11653616](https://doi.org/10.1109/ISIT62367.2026.11653616)).
6
+
7
+ ![TAILOR vs. baselines](https://raw.githubusercontent.com/AiminLi-Hi/aoi-preemption-heavy-tail/main/assets/fig2_tailor.png)
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install tailor-aoi # core
13
+ pip install "tailor-aoi[plot]" # with matplotlib helpers
14
+ ```
15
+
16
+ ## Use
17
+
18
+ ```python
19
+ from tailor_aoi import ServiceDist, solve, compare
20
+
21
+ dist = ServiceDist.pareto2(mean=2.0, variance=40.0) # or lognormal_from_mean, exponential, from_scipy
22
+ policy = solve(dist, kappa_s=1.0, kappa_p=1.0) # optimal sampling + preemption policy
23
+ print(compare(dist, 1.0, 1.0, policy=policy)) # vs. Update-or-Wait, Zero-Wait, best static
24
+ ```
25
+
26
+ Command line: `python -m tailor_aoi --dist lognormal --mean 2 --sigma2 6`.
27
+
28
+ - Repository and MATLAB code: https://github.com/AiminLi-Hi/aoi-preemption-heavy-tail
29
+ - Colab quickstart: https://colab.research.google.com/github/AiminLi-Hi/aoi-preemption-heavy-tail/blob/main/python/examples/quickstart.ipynb
30
+ - Project page: https://yigiti.github.io/Preemption/
31
+
32
+ ## Citation
33
+
34
+ If you use this package, please cite the paper (`python -m tailor_aoi --cite` prints the BibTeX):
35
+
36
+ ```bibtex
37
+ @inproceedings{li2026taming,
38
+ title = {Taming the Heavy Tail: Age-Optimal Preemption},
39
+ author = {Li, Aimin and {\.I}nce, Yi{\u{g}}it and Uysal, Elif},
40
+ booktitle = {Proc. IEEE International Symposium on Information Theory (ISIT)},
41
+ year = {2026},
42
+ doi = {10.1109/ISIT62367.2026.11653616}
43
+ }
44
+ ```
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tailor-aoi"
7
+ version = "0.1.0"
8
+ description = "Age-optimal sampling and preemption under heavy-tailed delays (TAILOR, IEEE ISIT 2026)"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [
13
+ { name = "Aimin Li" },
14
+ { name = "Yiğit İnce" },
15
+ { name = "Elif Uysal" },
16
+ ]
17
+ requires-python = ">=3.9"
18
+ dependencies = ["numpy>=1.22", "scipy>=1.8"]
19
+ keywords = [
20
+ "age of information", "AoI", "preemption", "heavy-tailed delay", "optimal stopping",
21
+ "impulse control", "queueing", "status update", "information freshness", "policy iteration",
22
+ ]
23
+ classifiers = [
24
+ "Programming Language :: Python :: 3",
25
+ "Topic :: Scientific/Engineering :: Mathematics",
26
+ "Topic :: System :: Networking",
27
+ "Intended Audience :: Science/Research",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ plot = ["matplotlib>=3.5"]
32
+ test = ["pytest>=7"]
33
+
34
+ [project.urls]
35
+ Paper = "https://doi.org/10.1109/ISIT62367.2026.11653616"
36
+ arXiv = "https://arxiv.org/abs/2601.16624"
37
+ Code = "https://github.com/AiminLi-Hi/aoi-preemption-heavy-tail"
38
+ "Project page" = "https://yigiti.github.io/Preemption/"
39
+
40
+ [tool.setuptools.packages.find]
41
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,59 @@
1
+ """tailor-aoi: age-optimal sampling and preemption under heavy-tailed delays.
2
+
3
+ Quick start::
4
+
5
+ from tailor_aoi import ServiceDist, solve, compare
6
+
7
+ dist = ServiceDist.lognormal_from_mean(mean=2.0, sigma2=4.0)
8
+ policy = solve(dist, kappa_s=1.0, kappa_p=1.0)
9
+ print(policy.summary())
10
+ print(compare(dist, 1.0, 1.0, policy=policy))
11
+
12
+ Reference: A. Li, Y. Ince, E. Uysal, "Taming the Heavy Tail: Age-Optimal
13
+ Preemption", Proc. IEEE ISIT 2026. DOI: 10.1109/ISIT62367.2026.11653616
14
+ """
15
+
16
+ from .baselines import StaticPolicy, optimized_static, static_cost, update_or_wait, zero_wait
17
+ from .distributions import ServiceDist
18
+ from .simulate import SimResult, sample_path, simulate
19
+ from .solver import Policy, solve
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ CITATION = """@inproceedings{li2026taming,
24
+ title = {Taming the Heavy Tail: Age-Optimal Preemption},
25
+ author = {Li, Aimin and {\\.I}nce, Yi{\\u{g}}it and Uysal, Elif},
26
+ booktitle = {Proc. IEEE International Symposium on Information Theory (ISIT)},
27
+ year = {2026},
28
+ doi = {10.1109/ISIT62367.2026.11653616}
29
+ }"""
30
+
31
+
32
+ def compare(dist, kappa_s=1.0, kappa_p=1.0, policy=None, **solve_kwargs):
33
+ """Average cost of TAILOR vs. the baselines used in the paper.
34
+
35
+ Baseline costs are exact (renewal-reward); TAILOR's cost is the optimal
36
+ average cost ``rho`` returned by the ACOE solver.
37
+ """
38
+ if policy is None:
39
+ policy = solve(dist, kappa_s, kappa_p, **solve_kwargs)
40
+ rows = [("TAILOR (Proposed)", policy.rho)]
41
+ rows.append(("Update-or-Wait [Sun et al. 2017]", update_or_wait(dist, kappa_s, kappa_p, rule="sun")[1]))
42
+ rows.append(("Zero-Wait", zero_wait(dist, kappa_s, kappa_p)[1]))
43
+ rows.append(("Optimized Static (D, T)", optimized_static(dist, kappa_s, kappa_p)[1]))
44
+ width = max(len(r[0]) for r in rows)
45
+ lines = [f"{dist.name}, k_s={kappa_s:g}, k_p={kappa_p:g}"]
46
+ lines += [f" {n:<{width}} {c:12.5f} ({c / policy.rho:6.2f}x)" for n, c in rows]
47
+ return "\n".join(lines)
48
+
49
+
50
+ def cite() -> str:
51
+ """BibTeX entry to cite when you use this package."""
52
+ return CITATION
53
+
54
+
55
+ __all__ = [
56
+ "ServiceDist", "Policy", "solve", "StaticPolicy", "static_cost", "zero_wait",
57
+ "update_or_wait", "optimized_static", "simulate", "sample_path", "SimResult",
58
+ "compare", "cite", "CITATION",
59
+ ]
@@ -0,0 +1,42 @@
1
+ """Command-line interface.
2
+
3
+ python -m tailor_aoi --dist lognormal --mean 2 --sigma2 6 --ks 1 --kp 1
4
+ python -m tailor_aoi --dist pareto2 --mean 2 --var 40 --ks 1 --kp 1
5
+ python -m tailor_aoi --dist exponential --mean 1
6
+ """
7
+
8
+ import argparse
9
+
10
+ from . import ServiceDist, cite, compare, solve
11
+
12
+
13
+ def main(argv=None):
14
+ ap = argparse.ArgumentParser(prog="tailor_aoi", description="Age-optimal sampling and preemption (TAILOR).")
15
+ ap.add_argument("--dist", choices=["lognormal", "pareto2", "exponential"], default="lognormal")
16
+ ap.add_argument("--mean", type=float, default=2.0, help="mean service time E[Y]")
17
+ ap.add_argument("--sigma2", type=float, default=4.0, help="log-normal: variance of ln Y")
18
+ ap.add_argument("--var", type=float, default=40.0, help="Pareto II: variance of Y")
19
+ ap.add_argument("--ks", type=float, default=1.0, help="sampling cost")
20
+ ap.add_argument("--kp", type=float, default=1.0, help="preemption cost")
21
+ ap.add_argument("--dt", type=float, default=0.005, help="grid step")
22
+ ap.add_argument("--cite", action="store_true", help="print the BibTeX entry and exit")
23
+ a = ap.parse_args(argv)
24
+ if a.cite:
25
+ print(cite())
26
+ return
27
+ if a.dist == "lognormal":
28
+ d = ServiceDist.lognormal_from_mean(a.mean, a.sigma2)
29
+ elif a.dist == "pareto2":
30
+ d = ServiceDist.pareto2(a.mean, a.var)
31
+ else:
32
+ d = ServiceDist.exponential(1.0 / a.mean)
33
+ p = solve(d, a.ks, a.kp, dt=a.dt)
34
+ print(p.summary())
35
+ print(f" sample when AoI reaches z*(0) = {float(p.sampling_target(0.0)):.4f}")
36
+ print(f" preempt a fresh service after theta*(z*(0)) = {float(p.preemption_time(p.sampling_target(0.0))):.4f}")
37
+ print(compare(d, a.ks, a.kp, policy=p))
38
+ print("\nIf this is useful in your research, please cite: python -m tailor_aoi --cite")
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
@@ -0,0 +1,127 @@
1
+ """Benchmark policies with exact (renewal-reward) average costs.
2
+
3
+ A *static* policy ``(D, T)`` waits until the AoI reaches ``D`` before sampling
4
+ and preempts any service that is still running after ``T`` time units. Its
5
+ long-run average cost (time-average AoI plus sampling/preemption costs per
6
+ unit time) follows from the renewal-reward theorem over delivery cycles:
7
+
8
+ * the AoI at the start of a cycle is a completed service time ``S ~ Y | Y <= T``;
9
+ * the cycle waits ``W = (D - S)^+`` and then runs ``N`` failed attempts of
10
+ length ``T`` (geometric, success probability ``F(T)``) and one successful
11
+ attempt ``S' ~ Y | Y <= T``.
12
+
13
+ Special cases: Zero-Wait is ``(0, inf)``; Update-or-Wait is ``(beta, inf)``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+
20
+ import numpy as np
21
+ from scipy import optimize
22
+
23
+ from .distributions import ServiceDist
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class StaticPolicy:
28
+ """Wait until AoI >= ``D``, then sample; preempt after ``T`` (``inf`` = never)."""
29
+
30
+ D: float
31
+ T: float
32
+ name: str = "Static"
33
+ kind: str = "Static"
34
+
35
+ def sampling_target(self, delta):
36
+ return np.maximum(np.asarray(delta, float), self.D)
37
+
38
+ def wait_time(self, delta):
39
+ return np.maximum(0.0, self.D - np.asarray(delta, float))
40
+
41
+ def preemption_time(self, y):
42
+ return np.full(np.shape(y), self.T, dtype=float)
43
+
44
+
45
+ def static_cost(dist: ServiceDist, D: float, T: float, kappa_s: float = 1.0, kappa_p: float = 1.0) -> float:
46
+ """Exact long-run average cost of the static policy ``(D, T)``."""
47
+ D = max(float(D), 0.0)
48
+ T = float(T)
49
+ p = dist.partial_moment(0, T) # P(Y <= T)
50
+ if p <= 0:
51
+ return np.inf
52
+ ES = dist.partial_moment(1, T) / p
53
+ ES2 = dist.partial_moment(2, T) / p
54
+ a = min(D, T)
55
+ P_a, m1_a, m2_a = (dist.partial_moment(k, a) for k in range(3))
56
+ EW = (D * P_a - m1_a) / p
57
+ idle_area = (D**2 * P_a - m2_a) / (2 * p) # E[S W + W^2/2]
58
+ Ey0 = ES + EW
59
+ if np.isinf(T):
60
+ EN = EN2 = 0.0
61
+ EB, EB2 = ES, ES2
62
+ else:
63
+ EN = (1 - p) / p
64
+ EN2 = (1 - p) * (2 - p) / p**2
65
+ EB = T * EN + ES
66
+ EB2 = T**2 * EN2 + 2 * T * EN * ES + ES2
67
+ num = idle_area + Ey0 * EB + 0.5 * EB2 + kappa_s + kappa_p * EN
68
+ return float(num / (EW + EB))
69
+
70
+
71
+ def zero_wait(dist: ServiceDist, kappa_s: float = 1.0, kappa_p: float = 1.0):
72
+ """Zero-Wait without preemption: sample right after every delivery."""
73
+ pol = StaticPolicy(0.0, np.inf, name="Zero-Wait")
74
+ return pol, static_cost(dist, 0.0, np.inf, kappa_s, kappa_p)
75
+
76
+
77
+ def update_or_wait(dist: ServiceDist, kappa_s: float = 1.0, kappa_p: float = 1.0, rule: str = "sun"):
78
+ """Update-or-Wait without preemption (threshold policy ``z = max(Delta, beta)``).
79
+
80
+ ``rule="sun"`` : the AoI-optimal threshold of Sun et al. (IEEE TIT 2017),
81
+ i.e. the root of E[X] = E[X^2] / (2 beta), X = max(Y, beta).
82
+ This is the benchmark used in the ISIT 2026 paper.
83
+ ``rule="cost"`` : the threshold that minimises the average cost *including*
84
+ the sampling cost, i.e. eq. (29) of the paper.
85
+ """
86
+ if rule == "sun":
87
+ def o(beta):
88
+ Fb = dist.partial_moment(0, beta)
89
+ EX = beta * Fb + dist.mean - dist.partial_moment(1, beta)
90
+ EX2 = beta**2 * Fb + dist.second_moment - dist.partial_moment(2, beta)
91
+ return EX - EX2 / (2 * beta)
92
+
93
+ hi = max(dist.mean, 1.0)
94
+ while o(hi) < 0:
95
+ hi *= 2
96
+ beta = optimize.brentq(o, 1e-12, hi, xtol=1e-12)
97
+ elif rule == "cost":
98
+ res = optimize.minimize_scalar(
99
+ lambda b: static_cost(dist, b, np.inf, kappa_s, kappa_p),
100
+ bounds=(0.0, 50 * dist.mean + dist.second_moment / max(dist.mean, 1e-12)),
101
+ method="bounded",
102
+ options={"xatol": 1e-10},
103
+ )
104
+ beta = float(res.x)
105
+ else:
106
+ raise ValueError("rule must be 'sun' or 'cost'")
107
+ pol = StaticPolicy(beta, np.inf, name="Update-or-Wait")
108
+ return pol, static_cost(dist, beta, np.inf, kappa_s, kappa_p)
109
+
110
+
111
+ def optimized_static(dist: ServiceDist, kappa_s: float = 1.0, kappa_p: float = 1.0):
112
+ """Best static (deadline, timeout) policy, found by exact cost minimisation."""
113
+ f = lambda x: static_cost(dist, np.exp(x[0]), np.exp(x[1]), kappa_s, kappa_p)
114
+ q = [float(dist.ppf(u)) for u in (0.5, 0.9, 0.99)]
115
+ best = None
116
+ for D0 in (0.1 * dist.mean, dist.mean, 3 * dist.mean):
117
+ for T0 in q + [dist.mean]:
118
+ r = optimize.minimize(f, x0=np.log([max(D0, 1e-6), max(T0, 1e-6)]), method="Nelder-Mead",
119
+ options={"xatol": 1e-8, "fatol": 1e-10, "maxiter": 4000})
120
+ if best is None or r.fun < best.fun:
121
+ best = r
122
+ D, T = np.exp(best.x)
123
+ # compare with never preempting
124
+ pol_np, c_np = update_or_wait(dist, kappa_s, kappa_p, rule="cost")
125
+ if c_np <= best.fun:
126
+ return StaticPolicy(pol_np.D, np.inf, name="Optimized Static"), c_np
127
+ return StaticPolicy(float(D), float(T), name="Optimized Static"), float(best.fun)
@@ -0,0 +1,169 @@
1
+ """Service-time distributions used by TAILOR.
2
+
3
+ A ``ServiceDist`` only needs a survival function, a density, a quantile
4
+ function and a sampler. Partial moments ``E[Y^n; Y <= a]`` (n = 0, 1, 2) are
5
+ used by the exact baseline evaluators; closed forms are provided where they
6
+ exist and numerical quadrature is used otherwise.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Callable
13
+
14
+ import numpy as np
15
+ from scipy import integrate, special, stats
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ServiceDist:
20
+ """A continuous, non-negative service-time distribution ``Y``.
21
+
22
+ Use the constructors :meth:`lomax`, :meth:`pareto2`, :meth:`lognormal`,
23
+ :meth:`exponential` or :meth:`from_scipy` instead of building it by hand.
24
+ """
25
+
26
+ name: str
27
+ sf: Callable[[np.ndarray], np.ndarray]
28
+ pdf: Callable[[np.ndarray], np.ndarray]
29
+ ppf: Callable[[np.ndarray], np.ndarray]
30
+ sampler: Callable[[np.random.Generator, int], np.ndarray]
31
+ mean: float
32
+ second_moment: float
33
+ _partial: Callable[[int, float], float] | None = None
34
+
35
+ # ------------------------------------------------------------------ API
36
+ @property
37
+ def variance(self) -> float:
38
+ return self.second_moment - self.mean**2
39
+
40
+ def cdf(self, t):
41
+ return 1.0 - self.sf(t)
42
+
43
+ def sample(self, n: int, rng: np.random.Generator | int | None = None) -> np.ndarray:
44
+ rng = np.random.default_rng(rng)
45
+ return self.sampler(rng, int(n))
46
+
47
+ def partial_moment(self, n: int, a: float) -> float:
48
+ """Return ``E[Y^n ; Y <= a]`` for n in {0, 1, 2}."""
49
+ if a <= 0:
50
+ return 0.0
51
+ if np.isinf(a):
52
+ return [1.0, self.mean, self.second_moment][n]
53
+ if self._partial is not None:
54
+ return float(self._partial(n, a))
55
+ val, _ = integrate.quad(lambda s: s**n * float(self.pdf(np.array(s))), 0.0, a, limit=400)
56
+ return float(val)
57
+
58
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
59
+ return f"ServiceDist({self.name}, mean={self.mean:.4g}, var={self.variance:.4g})"
60
+
61
+ # --------------------------------------------------------- constructors
62
+ @staticmethod
63
+ def lomax(alpha: float, scale: float = 1.0) -> "ServiceDist":
64
+ """Pareto type II (Lomax): ``P(Y > t) = (1 + t/scale)^(-alpha)``.
65
+
66
+ Requires ``alpha > 2`` so that ``E[Y^2] < inf``.
67
+ """
68
+ if alpha <= 2:
69
+ raise ValueError("Lomax needs alpha > 2 for a finite second moment.")
70
+ k, a = float(scale), float(alpha)
71
+ mean = k / (a - 1)
72
+ m2 = 2 * k**2 / ((a - 1) * (a - 2))
73
+
74
+ def partial(n, x):
75
+ u = 1 + x / k
76
+ if n == 0:
77
+ return 1 - u ** (-a)
78
+ # E[Y^n; Y<=x] = n * int_0^x s^{n-1} sf(s) ds - x^n sf(x)
79
+ if n == 1:
80
+ integral = k / (a - 1) * (1 - u ** (1 - a))
81
+ return integral - x * u ** (-a)
82
+ integral = k**2 * ((u ** (2 - a) - 1) / (2 - a) - (u ** (1 - a) - 1) / (1 - a))
83
+ return 2 * integral - x**2 * u ** (-a)
84
+
85
+ return ServiceDist(
86
+ name=f"Lomax(alpha={a:g}, scale={k:g})",
87
+ sf=lambda t: (1 + np.asarray(t, float) / k) ** (-a),
88
+ pdf=lambda t: (a / k) * (1 + np.asarray(t, float) / k) ** (-a - 1),
89
+ ppf=lambda p: k * ((1 - np.asarray(p, float)) ** (-1 / a) - 1),
90
+ sampler=lambda rng, n: k * (rng.random(n) ** (-1 / a) - 1),
91
+ mean=mean,
92
+ second_moment=m2,
93
+ _partial=partial,
94
+ )
95
+
96
+ @staticmethod
97
+ def pareto2(mean: float, variance: float) -> "ServiceDist":
98
+ """Lomax (Pareto II) parameterised by its mean and variance."""
99
+ # var/mean^2 = alpha / (alpha - 2) => alpha = 2 r / (r - 1), r = var/mean^2
100
+ r = variance / mean**2
101
+ if r <= 1:
102
+ raise ValueError("Pareto II needs variance > mean^2.")
103
+ alpha = 2 * r / (r - 1)
104
+ return ServiceDist.lomax(alpha, scale=mean * (alpha - 1))
105
+
106
+ @staticmethod
107
+ def lognormal(mu: float, sigma: float) -> "ServiceDist":
108
+ """Log-normal: ``ln Y ~ N(mu, sigma^2)``."""
109
+ mu, s = float(mu), float(sigma)
110
+ d = stats.lognorm(s=s, scale=np.exp(mu))
111
+
112
+ def partial(n, x):
113
+ z = (np.log(x) - mu - n * s**2) / s
114
+ return np.exp(n * mu + 0.5 * n**2 * s**2) * special.ndtr(z)
115
+
116
+ return ServiceDist(
117
+ name=f"LogNormal(mu={mu:g}, sigma^2={s**2:g})",
118
+ sf=lambda t: d.sf(np.asarray(t, float)),
119
+ pdf=lambda t: d.pdf(np.asarray(t, float)),
120
+ ppf=lambda p: d.ppf(np.asarray(p, float)),
121
+ sampler=lambda rng, n: np.exp(mu + s * rng.standard_normal(n)),
122
+ mean=float(np.exp(mu + s**2 / 2)),
123
+ second_moment=float(np.exp(2 * mu + 2 * s**2)),
124
+ _partial=partial,
125
+ )
126
+
127
+ @staticmethod
128
+ def lognormal_from_mean(mean: float, sigma2: float) -> "ServiceDist":
129
+ """Log-normal with a given mean and log-variance ``sigma^2`` (as in the paper)."""
130
+ return ServiceDist.lognormal(np.log(mean) - sigma2 / 2, np.sqrt(sigma2))
131
+
132
+ @staticmethod
133
+ def exponential(rate: float) -> "ServiceDist":
134
+ lam = float(rate)
135
+
136
+ def partial(n, x):
137
+ e = np.exp(-lam * x)
138
+ if n == 0:
139
+ return 1 - e
140
+ if n == 1:
141
+ return (1 - e * (1 + lam * x)) / lam
142
+ return (2 - e * (2 + 2 * lam * x + (lam * x) ** 2)) / lam**2
143
+
144
+ return ServiceDist(
145
+ name=f"Exp(rate={lam:g})",
146
+ sf=lambda t: np.exp(-lam * np.asarray(t, float)),
147
+ pdf=lambda t: lam * np.exp(-lam * np.asarray(t, float)),
148
+ ppf=lambda p: -np.log1p(-np.asarray(p, float)) / lam,
149
+ sampler=lambda rng, n: rng.exponential(1 / lam, n),
150
+ mean=1 / lam,
151
+ second_moment=2 / lam**2,
152
+ _partial=partial,
153
+ )
154
+
155
+ @staticmethod
156
+ def from_scipy(frozen, name: str | None = None) -> "ServiceDist":
157
+ """Wrap any frozen ``scipy.stats`` continuous distribution on ``[0, inf)``."""
158
+ m1, m2 = frozen.mean(), frozen.moment(2)
159
+ if not (np.isfinite(m1) and np.isfinite(m2)):
160
+ raise ValueError("TAILOR needs E[Y] < inf and E[Y^2] < inf.")
161
+ return ServiceDist(
162
+ name=name or frozen.dist.name,
163
+ sf=lambda t: frozen.sf(np.asarray(t, float)),
164
+ pdf=lambda t: frozen.pdf(np.asarray(t, float)),
165
+ ppf=lambda p: frozen.ppf(np.asarray(p, float)),
166
+ sampler=lambda rng, n: frozen.rvs(size=n, random_state=rng),
167
+ mean=float(m1),
168
+ second_moment=float(m2),
169
+ )
@@ -0,0 +1,41 @@
1
+ """Plot helpers (matplotlib is imported lazily)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+
7
+
8
+ def plot_policy(policy, ax=None, y_max: float | None = None):
9
+ """Plot the preemption map theta*(y) and the sampling target z*(Delta)."""
10
+ import matplotlib.pyplot as plt
11
+
12
+ if ax is None:
13
+ _, ax = plt.subplots(1, 2, figsize=(9, 3.4))
14
+ y = policy.y
15
+ m = y <= (y_max if y_max is not None else y[-1])
16
+ ax[0].plot(y[m], policy.theta_map[m], lw=1.8)
17
+ ax[0].set_xlabel("busy-start AoI $y$")
18
+ ax[0].set_ylabel(r"preemption time $\theta^\star(y)$")
19
+ ax[0].grid(alpha=0.3)
20
+ ax[1].plot(y[m], policy.z_map[m], lw=1.8, label=r"$z^\star(\Delta)$")
21
+ ax[1].plot(y[m], y[m], "k:", lw=1, label=r"$z=\Delta$ (no wait)")
22
+ ax[1].set_xlabel(r"AoI at delivery $\Delta$")
23
+ ax[1].set_ylabel("sampling target")
24
+ ax[1].legend(frameon=False)
25
+ ax[1].grid(alpha=0.3)
26
+ ax[0].figure.tight_layout()
27
+ return ax
28
+
29
+
30
+ def plot_sample_path(ts, ages, preempt_times=(), ax=None, label=None):
31
+ import matplotlib.pyplot as plt
32
+
33
+ if ax is None:
34
+ _, ax = plt.subplots(figsize=(8, 3))
35
+ ax.plot(ts, ages, lw=1.4, label=label)
36
+ for p in np.asarray(preempt_times):
37
+ ax.axvline(p, color="tab:red", alpha=0.25, lw=0.8)
38
+ ax.set_xlabel("time")
39
+ ax.set_ylabel("AoI")
40
+ ax.grid(alpha=0.3)
41
+ return ax
@@ -0,0 +1,144 @@
1
+ """Monte Carlo evaluation of sampling/preemption policies (SMDP-consistent).
2
+
3
+ Port of ``simulate_aoi_smdp_strict_q99`` in ``Paretov2.m``: after a delivery
4
+ the source waits until the AoI reaches the sampling target and pays
5
+ ``kappa_s``; a service still running after ``theta(y)`` is preempted, costs
6
+ ``kappa_p`` and restarts immediately with the same AoI.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ import numpy as np
14
+
15
+ from .distributions import ServiceDist
16
+
17
+
18
+ @dataclass
19
+ class SimResult:
20
+ avg_cost: float
21
+ avg_aoi: float
22
+ sampling_rate: float
23
+ preemption_rate: float
24
+ q99_aoi: float
25
+ horizon: float
26
+
27
+ def __str__(self) -> str: # pragma: no cover - cosmetic
28
+ return (f"avg cost {self.avg_cost:.5f} | avg AoI {self.avg_aoi:.5f} | "
29
+ f"q99 AoI {self.q99_aoi:.4f} | samples/t {self.sampling_rate:.4f} | "
30
+ f"preemptions/t {self.preemption_rate:.4f}")
31
+
32
+
33
+ def _lookup(policy):
34
+ """Return fast scalar callables z(delta), theta(y) for the given policy."""
35
+ if getattr(policy, "kind", "") == "TAILOR":
36
+ zm, tm, dt = policy.z_map, policy.theta_map, policy.dt
37
+ nz, far = zm.size, policy.rho - policy.slope
38
+ z_list, t_list = zm.tolist(), tm.tolist()
39
+
40
+ def z(d):
41
+ i = int(round(d / dt))
42
+ return z_list[i] if i < nz else max(d, far)
43
+
44
+ def th(y):
45
+ i = int(round(y / dt))
46
+ return t_list[i] if i < nz else t_list[-1]
47
+
48
+ return z, th
49
+ D, T = float(policy.D), float(policy.T)
50
+ return (lambda d: d if d > D else D), (lambda y: T)
51
+
52
+
53
+ def simulate(policy, dist: ServiceDist, kappa_s: float = 1.0, kappa_p: float = 1.0,
54
+ n_attempts: int = 500_000, rng=None, samples: np.ndarray | None = None) -> SimResult:
55
+ """Simulate ``n_attempts`` service attempts and return time averages.
56
+
57
+ Pass the same ``samples`` array to compare several policies under common
58
+ random numbers (as done in the paper's MATLAB code).
59
+ """
60
+ S_vec = samples if samples is not None else dist.sample(n_attempts, rng)
61
+ S_list = np.asarray(S_vec, float).tolist()
62
+ z_of, th_of = _lookup(policy)
63
+
64
+ t_now = area = 0.0
65
+ n_samp = n_pre = 0
66
+ in_idle, delta, y = True, 0.0, 0.0
67
+ seg_a, seg_b = [], []
68
+ for S in S_list:
69
+ if in_idle:
70
+ z = z_of(delta)
71
+ w = z - delta if z > delta else 0.0
72
+ if w > 0:
73
+ seg_a.append(delta); seg_b.append(delta + w)
74
+ area += delta * w + 0.5 * w * w
75
+ t_now += w
76
+ n_samp += 1
77
+ y = delta + w
78
+ in_idle = False
79
+ th = th_of(y)
80
+ dur = S if S <= th else th
81
+ if dur > 0:
82
+ seg_a.append(y); seg_b.append(y + dur)
83
+ area += y * dur + 0.5 * dur * dur
84
+ t_now += dur
85
+ if S <= th:
86
+ delta, in_idle = S, True
87
+ else:
88
+ n_pre += 1
89
+ y += dur
90
+ avg_aoi = area / t_now
91
+ return SimResult(
92
+ avg_cost=(area + kappa_s * n_samp + kappa_p * n_pre) / t_now,
93
+ avg_aoi=avg_aoi,
94
+ sampling_rate=n_samp / t_now,
95
+ preemption_rate=n_pre / t_now,
96
+ q99_aoi=_time_weighted_quantile(np.array(seg_a), np.array(seg_b), 0.99),
97
+ horizon=t_now,
98
+ )
99
+
100
+
101
+ def _time_weighted_quantile(a, b, p):
102
+ """Quantile of the AoI under the time-average distribution (exact sweep)."""
103
+ L = b - a
104
+ ok = (L > 0) & np.isfinite(a) & np.isfinite(b)
105
+ a, b, L = a[ok], b[ok], L[ok]
106
+ total = L.sum()
107
+ if total <= 0:
108
+ return float("nan")
109
+ x = np.concatenate([a, b])
110
+ typ = np.concatenate([np.ones(a.size), -np.ones(b.size)])
111
+ order = np.lexsort((typ, x))
112
+ x, typ = x[order], typ[order]
113
+ active = np.cumsum(typ) # number of active segments right after each event
114
+ dx = np.diff(x)
115
+ mass = np.concatenate([[0.0], np.cumsum(active[:-1] * dx) / total])
116
+ k = np.searchsorted(mass, p)
117
+ if k >= mass.size:
118
+ return float(b.max())
119
+ k0 = k - 1
120
+ c = active[k0]
121
+ return float(x[k0] + (p - mass[k0]) * total / c) if c > 0 else float(x[k])
122
+
123
+
124
+ def sample_path(policy, dist: ServiceDist, horizon: float = 50.0, rng=None):
125
+ """Return (time, AoI) breakpoints of one sample path, for plotting."""
126
+ rng = np.random.default_rng(rng)
127
+ z_of, th_of = _lookup(policy)
128
+ t, age = 0.0, 0.0
129
+ ts, ages, pre = [0.0], [0.0], []
130
+ while t < horizon:
131
+ w = max(0.0, z_of(age) - age)
132
+ if w > 0:
133
+ t += w; age += w
134
+ ts.append(t); ages.append(age)
135
+ while True:
136
+ S = float(dist.sample(1, rng)[0])
137
+ th = th_of(age)
138
+ if S <= th:
139
+ t += S; ts.append(t); ages.append(age + S)
140
+ age = S; ts.append(t); ages.append(age)
141
+ break
142
+ t += th; age += th
143
+ ts.append(t); ages.append(age); pre.append(t)
144
+ return np.array(ts), np.array(ages), np.array(pre)
@@ -0,0 +1,336 @@
1
+ """TAILOR: average-cost policy iteration for joint sampling and preemption.
2
+
3
+ This is a Python port of the MATLAB solver in ``Paretov2.m`` and implements
4
+ the coupled integral ACOEs of Theorem 1 in
5
+
6
+ A. Li, Y. Ince, E. Uysal, "Taming the Heavy Tail: Age-Optimal Preemption",
7
+ Proc. IEEE ISIT 2026.
8
+
9
+ Idle phase h_I(D) = rho*D - D^2/2 + inf_{z>=D} { k_s + v(z) + z^2/2 - rho*z }
10
+ Busy phase v(y) = inf_{theta>=0} Q(y, theta; rho)
11
+ Q = (y - rho) A(theta) + J1(theta) + int_0^theta h_I dF
12
+ + sf(theta) * (k_p + v(y + theta))
13
+
14
+ Numerics follow Section V-A of the paper: a uniform grid in y, a hybrid
15
+ (uniform + log-spaced) action grid for theta, and a far-field linear closure
16
+ v(y) ~ v(y_cut) + s (y - y_cut) with slope s = E[min(Y, theta_max)].
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from dataclasses import dataclass, field
22
+
23
+ import numpy as np
24
+ from scipy import sparse
25
+ from scipy.sparse.linalg import spsolve
26
+
27
+ from .distributions import ServiceDist
28
+
29
+
30
+ def _cumtrapz(t: np.ndarray, g: np.ndarray) -> np.ndarray:
31
+ out = np.zeros_like(g, dtype=float)
32
+ out[1:] = np.cumsum(0.5 * (g[1:] + g[:-1]) * np.diff(t))
33
+ return out
34
+
35
+
36
+ @dataclass
37
+ class Policy:
38
+ """Stationary TAILOR policy on the busy-start grid.
39
+
40
+ * ``sampling_target(delta)`` returns z*(delta): wait until the AoI reaches
41
+ this value, then sample (Theorem 1a).
42
+ * ``preemption_time(y)`` returns theta*(y): starting a service with AoI y,
43
+ preempt if it has not finished after this long (Theorem 1b).
44
+ """
45
+
46
+ rho: float
47
+ y: np.ndarray
48
+ v: np.ndarray
49
+ z_map: np.ndarray
50
+ theta_map: np.ndarray
51
+ dt: float
52
+ y_cut: float
53
+ slope: float
54
+ kappa_s: float
55
+ kappa_p: float
56
+ dist_name: str
57
+ iterations: int
58
+ converged: bool
59
+ rho_history: list = field(default_factory=list)
60
+ kind: str = "TAILOR"
61
+
62
+ def sampling_target(self, delta):
63
+ delta = np.asarray(delta, float)
64
+ idx = np.rint(delta / self.dt).astype(int)
65
+ inside = idx < self.z_map.size
66
+ out = np.maximum(delta, self.rho - self.slope) # far-field rule
67
+ out = np.where(inside, self.z_map[np.minimum(idx, self.z_map.size - 1)], out)
68
+ return out
69
+
70
+ def wait_time(self, delta):
71
+ return np.maximum(0.0, self.sampling_target(delta) - np.asarray(delta, float))
72
+
73
+ def preemption_time(self, y):
74
+ idx = np.rint(np.asarray(y, float) / self.dt).astype(int)
75
+ return self.theta_map[np.minimum(idx, self.theta_map.size - 1)]
76
+
77
+ def summary(self) -> str:
78
+ return (
79
+ f"{self.kind} policy for {self.dist_name}: rho = {self.rho:.6f} "
80
+ f"(k_s={self.kappa_s:g}, k_p={self.kappa_p:g}; "
81
+ f"{'converged' if self.converged else 'NOT converged'} in {self.iterations} iterations)"
82
+ )
83
+
84
+
85
+ def _action_grid(dt, theta_cap, theta_fine, n_log):
86
+ theta_fine = min(theta_fine, theta_cap)
87
+ dense = np.arange(0.0, theta_fine + 0.5 * dt, dt)
88
+ if theta_fine < theta_cap:
89
+ a = max(theta_fine, dt)
90
+ tail = np.exp(np.linspace(np.log(a), np.log(theta_cap), n_log))
91
+ cand = np.unique(np.concatenate([dense, tail, [theta_cap]]))
92
+ else:
93
+ cand = dense
94
+ cand = np.unique(np.floor(cand / dt + 1e-9) * dt)
95
+ idx = np.unique(np.rint(cand / dt).astype(int))
96
+ return idx[idx >= 1] # forbid theta = 0
97
+
98
+
99
+ def solve(
100
+ dist: ServiceDist,
101
+ kappa_s: float = 1.0,
102
+ kappa_p: float = 1.0,
103
+ *,
104
+ dt: float = 0.005,
105
+ y_cut: float | None = None,
106
+ eps_tail: float = 1e-6,
107
+ theta_fine: float = 8.0,
108
+ n_log: int = 250,
109
+ max_iter: int = 60,
110
+ tol_v: float = 1e-8,
111
+ tol_rho: float = 1e-10,
112
+ verbose: bool = False,
113
+ ) -> Policy:
114
+ """Compute the TAILOR (ACOE-optimal) sampling and preemption policy.
115
+
116
+ Parameters
117
+ ----------
118
+ dist : ServiceDist
119
+ Service-time distribution (needs E[Y^2] < inf).
120
+ kappa_s, kappa_p : float
121
+ Sampling and preemption costs.
122
+ dt : float
123
+ Grid step for the busy-start AoI ``y`` and the action ``theta``.
124
+ y_cut : float, optional
125
+ Truncation of the ``y`` grid. Defaults to ``max(30, 15 E[Y])``.
126
+ eps_tail : float
127
+ Largest action ``theta_max`` satisfies ``P(Y > theta_max) = eps_tail``.
128
+ theta_fine, n_log : float, int
129
+ Uniform action grid on ``[0, theta_fine]`` and ``n_log`` log-spaced
130
+ actions on ``[theta_fine, theta_max]``.
131
+ """
132
+ if y_cut is None:
133
+ y_cut = max(30.0, 15.0 * dist.mean)
134
+ y_cut = np.floor(y_cut / dt) * dt
135
+
136
+ theta_cap = float(dist.ppf(1.0 - eps_tail))
137
+ theta_cap = np.floor(theta_cap / dt) * dt
138
+ y = np.arange(0.0, y_cut + 0.5 * dt, dt)
139
+ M = y.size
140
+ t = np.arange(0.0, theta_cap + 0.5 * dt, dt)
141
+ T = t.size
142
+
143
+ # Kernels: sf, density, A(theta)=int sf, J1(theta)=int s*sf(s) ds
144
+ barF = np.asarray(dist.sf(t), float)
145
+ f = np.asarray(dist.pdf(t), float)
146
+ f[~np.isfinite(f)] = 0.0
147
+ A = _cumtrapz(t, barF)
148
+ J1 = _cumtrapz(t, t * barF)
149
+ slope = float(A[-1]) # far-field slope ~ E[min(Y, theta_max)] ~ E[Y]
150
+
151
+ cand = _action_grid(dt, theta_cap, theta_fine, n_log)
152
+ rho = 0.0
153
+ v = np.zeros(M)
154
+ tau_idx = np.full(M, cand[0])
155
+ rho_hist = []
156
+ converged = False
157
+ z_map = y.copy()
158
+
159
+ for it in range(1, max_iter + 1):
160
+ v_old, rho_old, tau_old = v.copy(), rho, tau_idx.copy()
161
+ v_cut = v[-1]
162
+
163
+ # ---- Step 1: idle envelope m(i) = min_{j>=i} phi(j)
164
+ phi = kappa_s + v + 0.5 * y**2 - rho * y
165
+ rev = phi[::-1]
166
+ run_min = np.minimum.accumulate(rev)
167
+ # argmin of the suffix (take the smallest index attaining the min, as in MATLAB)
168
+ is_new = np.empty(M, bool)
169
+ is_new[0] = True
170
+ is_new[1:] = rev[1:] <= run_min[:-1]
171
+ pos = np.where(is_new, np.arange(M), 0)
172
+ pos = np.maximum.accumulate(pos)
173
+ m_grid = run_min[::-1]
174
+ arg_grid = (M - 1 - pos)[::-1]
175
+ z_map = y[arg_grid]
176
+
177
+ # ---- Step 2: h_I(t) on the action grid t
178
+ const_ext = kappa_s + v_cut - slope * y_cut
179
+ inside = t <= y_cut + 1e-12
180
+ i0 = np.minimum(np.rint(t / dt).astype(int), M - 1)
181
+ m1 = np.where(inside, m_grid[i0], np.inf)
182
+ z1 = np.where(inside, y[arg_grid[i0]], np.inf)
183
+ L = np.maximum(t, y_cut)
184
+ z2 = np.maximum(L, rho - slope)
185
+ m2 = 0.5 * z2**2 + (slope - rho) * z2 + const_ext
186
+ use1 = m1 <= m2
187
+ z_choice = np.where(use1, z1, z2)
188
+ m_star = np.where(use1, m1, m2)
189
+ hI = rho * t - 0.5 * t**2 + m_star
190
+ I_fh = _cumtrapz(t, f * hI)
191
+
192
+ # ---- Step 3: busy improvement (vectorised over y and candidate theta)
193
+ tc = t[cand]
194
+ base = J1[cand] - rho * A[cand] + I_fh[cand] + barF[cand] * kappa_p
195
+ tau_idx = np.empty(M, dtype=int)
196
+ for s in range(0, M, 512): # chunk over y to bound memory
197
+ ys = y[s:s + 512]
198
+ yplus = ys[:, None] + tc[None, :]
199
+ v_shift = np.where(
200
+ yplus <= y_cut,
201
+ np.interp(np.minimum(yplus, y_cut), y, v),
202
+ v_cut + slope * (yplus - y_cut),
203
+ )
204
+ Q = ys[:, None] * A[cand] + base + barF[cand] * v_shift
205
+ tau_idx[s:s + 512] = cand[np.argmin(Q, axis=1)]
206
+
207
+ # ---- Step 4: policy evaluation (sparse linear system in (rho, v))
208
+ w = z_choice - t
209
+ Bw = _cumtrapz(t, f * w)
210
+ Ccost = _cumtrapz(t, f * (t * w + 0.5 * w**2 + kappa_s))
211
+ rho, v = _evaluate(
212
+ y, t, dt, y_cut, slope, f, barF, A, J1, Bw, Ccost, z_choice, tau_idx, kappa_p
213
+ )
214
+ rho_hist.append(rho)
215
+
216
+ dv = np.max(np.abs(v - v_old))
217
+ dr = abs(rho - rho_old)
218
+ dp = int(np.count_nonzero(tau_idx != tau_old))
219
+ if verbose:
220
+ print(f"iter {it:2d}: rho={rho:.8f} |dv|={dv:.2e} |drho|={dr:.2e} policy changes={dp}")
221
+ if dv <= tol_v and dr <= tol_rho and dp == 0:
222
+ converged = True
223
+ break
224
+
225
+ return Policy(
226
+ rho=float(rho),
227
+ y=y,
228
+ v=v,
229
+ z_map=z_map,
230
+ theta_map=t[tau_idx],
231
+ dt=dt,
232
+ y_cut=float(y_cut),
233
+ slope=slope,
234
+ kappa_s=kappa_s,
235
+ kappa_p=kappa_p,
236
+ dist_name=dist.name,
237
+ iterations=it,
238
+ converged=converged,
239
+ rho_history=rho_hist,
240
+ )
241
+
242
+
243
+ def _interp_weights(x, dt, M, y_cut, slope):
244
+ """Linear-interpolation stencil of v at points x (with far-field closure)."""
245
+ inside = x <= y_cut + 1e-12
246
+ iL = np.clip(np.floor(x / dt + 1e-12).astype(int), 0, M - 2)
247
+ lam = np.where(inside, x / dt - iL, 0.0)
248
+ lam = np.clip(lam, 0.0, 1.0)
249
+ iL = np.where(inside, iL, M - 1)
250
+ const = np.where(inside, 0.0, slope * (x - y_cut))
251
+ return iL, lam, inside, const
252
+
253
+
254
+ def _evaluate(y, t, dt, y_cut, slope, f, barF, A, J1, Bw, Ccost, z_choice, tau_idx, kappa_p):
255
+ """Solve the Poisson equation for a fixed policy.
256
+
257
+ The integral term int_0^theta f(t) v(z(t)) dt is handled with auxiliary
258
+ cumulative unknowns S_j (one per distinct theta used by the policy) that
259
+ are chained in increasing order of theta, which keeps the system sparse.
260
+ """
261
+ M = y.size
262
+ used = np.unique(tau_idx)
263
+ U = used.size
264
+ n = 1 + M + U # [rho, v_0..v_{M-1}, S_1..S_U]
265
+ col_v = 1
266
+ col_S = 1 + M
267
+
268
+ # trapezoid weights: cumulative integral up to index j is
269
+ # sum_{k<=j} dt f_k g_k - dt/2 (f_0 g_0 + f_j g_j)
270
+ iL, lam, inside, zconst = _interp_weights(z_choice, dt, M, y_cut, slope)
271
+ wk = dt * f
272
+
273
+ rows, cols, vals = [], [], []
274
+ rhs = np.zeros(n)
275
+
276
+ # Auxiliary chain: S_u - S_{u-1} - sum_{k in (prev, j]} wk * interp_k(v) = sum wk*const
277
+ prev = -1
278
+ for a, j in enumerate(used):
279
+ r = col_S + a
280
+ rows.append(r); cols.append(col_S + a); vals.append(1.0)
281
+ if a > 0:
282
+ rows.append(r); cols.append(col_S + a - 1); vals.append(-1.0)
283
+ seg = slice(prev + 1, j + 1)
284
+ w_seg = wk[seg]
285
+ il, lm, ins = iL[seg], lam[seg], inside[seg]
286
+ cw = np.bincount(il, weights=w_seg * np.where(ins, 1 - lm, 1.0), minlength=M)
287
+ cw += np.bincount(np.minimum(il + 1, M - 1), weights=w_seg * np.where(ins, lm, 0.0), minlength=M)
288
+ nz = np.nonzero(cw)[0]
289
+ rows.extend([r] * nz.size); cols.extend((col_v + nz).tolist()); vals.extend((-cw[nz]).tolist())
290
+ rhs[r] = float(np.sum(w_seg * zconst[seg]))
291
+ prev = j
292
+ # Bellman rows i = 0..M-2 (row M-1 replaced by normalisation v(0) = 0)
293
+ a_of = {j: a for a, j in enumerate(used)}
294
+ for i in range(M - 1):
295
+ j = tau_idx[i]
296
+ r = col_v + i
297
+ g = A[j] + Bw[j]
298
+ rv = y[i] * A[j] + J1[j] + Ccost[j] + barF[j] * kappa_p
299
+ rows += [r, r]; cols += [0, col_v + i]; vals += [g, 1.0]
300
+ # - c_j . v = - (S_j - dt/2 f_0 interp_0(v) - dt/2 f_j interp_j(v))
301
+ rows.append(r); cols.append(col_S + a_of[j]); vals.append(-1.0)
302
+ cst = 0.0
303
+ for k in (0, j):
304
+ hw = 0.5 * dt * f[k]
305
+ if hw == 0.0:
306
+ continue
307
+ if inside[k]:
308
+ rows += [r, r]; cols += [col_v + iL[k], col_v + min(iL[k] + 1, M - 1)]
309
+ vals += [hw * (1 - lam[k]), hw * lam[k]]
310
+ else:
311
+ rows.append(r); cols.append(col_v + M - 1); vals.append(hw)
312
+ cst -= hw * zconst[k]
313
+ # S_j carries + sum wk*const, so c_j.v-const part: rhs gets + (S const) handled in chain;
314
+ # remove the half-weights' constants at the endpoints.
315
+ # - sf(theta) v(y_i + theta)
316
+ yp = y[i] + t[j]
317
+ if yp <= y_cut + 1e-12:
318
+ il = min(int(np.floor(yp / dt + 1e-12)), M - 2)
319
+ lm = yp / dt - il
320
+ rows += [r, r]; cols += [col_v + il, col_v + il + 1]
321
+ vals += [-barF[j] * (1 - lm), -barF[j] * lm]
322
+ extra = 0.0
323
+ else:
324
+ rows.append(r); cols.append(col_v + M - 1); vals.append(-barF[j])
325
+ extra = barF[j] * slope * (yp - y_cut)
326
+ rhs[r] = rv + extra + cst
327
+ # normalisation v(0) = 0 (replaces Bellman row at y_cut)
328
+ r = col_v + M - 1
329
+ rows.append(r); cols.append(col_v + 0); vals.append(1.0)
330
+ # far-field slope closure v(M-1) - v(M-2) = slope * dt (uses the rho "row")
331
+ rows += [0, 0]; cols += [col_v + M - 1, col_v + M - 2]; vals += [1.0, -1.0]
332
+ rhs[0] = slope * dt
333
+
334
+ Amat = sparse.csr_matrix((vals, (rows, cols)), shape=(n, n))
335
+ sol = spsolve(Amat.tocsc(), rhs)
336
+ return float(sol[0]), sol[col_v:col_v + M]
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: tailor-aoi
3
+ Version: 0.1.0
4
+ Summary: Age-optimal sampling and preemption under heavy-tailed delays (TAILOR, IEEE ISIT 2026)
5
+ Author: Aimin Li, Yiğit İnce, Elif Uysal
6
+ License-Expression: MIT
7
+ Project-URL: Paper, https://doi.org/10.1109/ISIT62367.2026.11653616
8
+ Project-URL: arXiv, https://arxiv.org/abs/2601.16624
9
+ Project-URL: Code, https://github.com/AiminLi-Hi/aoi-preemption-heavy-tail
10
+ Project-URL: Project page, https://yigiti.github.io/Preemption/
11
+ Keywords: age of information,AoI,preemption,heavy-tailed delay,optimal stopping,impulse control,queueing,status update,information freshness,policy iteration
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
14
+ Classifier: Topic :: System :: Networking
15
+ Classifier: Intended Audience :: Science/Research
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy>=1.22
20
+ Requires-Dist: scipy>=1.8
21
+ Provides-Extra: plot
22
+ Requires-Dist: matplotlib>=3.5; extra == "plot"
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=7; extra == "test"
25
+ Dynamic: license-file
26
+
27
+ # tailor-aoi
28
+
29
+ Python implementation of **TAILOR**, the age-optimal joint sampling and preemption policy from
30
+ *Taming the Heavy Tail: Age-Optimal Preemption* (A. Li, Y. İnce, E. Uysal, IEEE ISIT 2026,
31
+ [DOI 10.1109/ISIT62367.2026.11653616](https://doi.org/10.1109/ISIT62367.2026.11653616)).
32
+
33
+ ![TAILOR vs. baselines](https://raw.githubusercontent.com/AiminLi-Hi/aoi-preemption-heavy-tail/main/assets/fig2_tailor.png)
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install tailor-aoi # core
39
+ pip install "tailor-aoi[plot]" # with matplotlib helpers
40
+ ```
41
+
42
+ ## Use
43
+
44
+ ```python
45
+ from tailor_aoi import ServiceDist, solve, compare
46
+
47
+ dist = ServiceDist.pareto2(mean=2.0, variance=40.0) # or lognormal_from_mean, exponential, from_scipy
48
+ policy = solve(dist, kappa_s=1.0, kappa_p=1.0) # optimal sampling + preemption policy
49
+ print(compare(dist, 1.0, 1.0, policy=policy)) # vs. Update-or-Wait, Zero-Wait, best static
50
+ ```
51
+
52
+ Command line: `python -m tailor_aoi --dist lognormal --mean 2 --sigma2 6`.
53
+
54
+ - Repository and MATLAB code: https://github.com/AiminLi-Hi/aoi-preemption-heavy-tail
55
+ - Colab quickstart: https://colab.research.google.com/github/AiminLi-Hi/aoi-preemption-heavy-tail/blob/main/python/examples/quickstart.ipynb
56
+ - Project page: https://yigiti.github.io/Preemption/
57
+
58
+ ## Citation
59
+
60
+ If you use this package, please cite the paper (`python -m tailor_aoi --cite` prints the BibTeX):
61
+
62
+ ```bibtex
63
+ @inproceedings{li2026taming,
64
+ title = {Taming the Heavy Tail: Age-Optimal Preemption},
65
+ author = {Li, Aimin and {\.I}nce, Yi{\u{g}}it and Uysal, Elif},
66
+ booktitle = {Proc. IEEE International Symposium on Information Theory (ISIT)},
67
+ year = {2026},
68
+ doi = {10.1109/ISIT62367.2026.11653616}
69
+ }
70
+ ```
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/tailor_aoi/__init__.py
5
+ src/tailor_aoi/__main__.py
6
+ src/tailor_aoi/baselines.py
7
+ src/tailor_aoi/distributions.py
8
+ src/tailor_aoi/plotting.py
9
+ src/tailor_aoi/simulate.py
10
+ src/tailor_aoi/solver.py
11
+ src/tailor_aoi.egg-info/PKG-INFO
12
+ src/tailor_aoi.egg-info/SOURCES.txt
13
+ src/tailor_aoi.egg-info/dependency_links.txt
14
+ src/tailor_aoi.egg-info/requires.txt
15
+ src/tailor_aoi.egg-info/top_level.txt
16
+ tests/test_tailor.py
@@ -0,0 +1,8 @@
1
+ numpy>=1.22
2
+ scipy>=1.8
3
+
4
+ [plot]
5
+ matplotlib>=3.5
6
+
7
+ [test]
8
+ pytest>=7
@@ -0,0 +1 @@
1
+ tailor_aoi
@@ -0,0 +1,74 @@
1
+ import numpy as np
2
+ import pytest
3
+ from scipy import integrate
4
+
5
+ from tailor_aoi import ServiceDist, optimized_static, simulate, solve, static_cost, update_or_wait, zero_wait
6
+ from tailor_aoi.baselines import StaticPolicy
7
+
8
+
9
+ @pytest.mark.parametrize("dist", [
10
+ ServiceDist.lomax(2.5, 1.3),
11
+ ServiceDist.lognormal(0.2, 0.9),
12
+ ServiceDist.exponential(0.7),
13
+ ])
14
+ @pytest.mark.parametrize("a", [0.3, 1.7, 6.0])
15
+ def test_partial_moments_match_quadrature(dist, a):
16
+ for n in range(3):
17
+ ref, _ = integrate.quad(lambda s: s**n * float(dist.pdf(np.array(s))), 0, a)
18
+ assert dist.partial_moment(n, a) == pytest.approx(ref, rel=1e-7, abs=1e-10)
19
+
20
+
21
+ def test_pareto2_parameterisation():
22
+ d = ServiceDist.pareto2(mean=2.0, variance=40.0)
23
+ assert d.mean == pytest.approx(2.0)
24
+ assert d.variance == pytest.approx(40.0)
25
+
26
+
27
+ def test_lognormal_from_mean():
28
+ d = ServiceDist.lognormal_from_mean(2.0, 3.0)
29
+ assert d.mean == pytest.approx(2.0)
30
+
31
+
32
+ @pytest.mark.parametrize("D,T", [(0.0, np.inf), (1.2, np.inf), (0.5, 1.5), (2.0, 0.8)])
33
+ def test_static_cost_matches_monte_carlo(D, T):
34
+ """Exact renewal-reward cost vs. simulation in a light-tailed case."""
35
+ d = ServiceDist.lognormal(0.0, 0.5)
36
+ exact = static_cost(d, D, T, kappa_s=1.0, kappa_p=2.0)
37
+ mc = simulate(StaticPolicy(D, T), d, 1.0, 2.0, n_attempts=400_000, rng=11).avg_cost
38
+ assert mc == pytest.approx(exact, rel=0.01)
39
+
40
+
41
+ def test_zero_wait_closed_form():
42
+ d = ServiceDist.lognormal_from_mean(2.0, 6.0)
43
+ expected = (d.mean**2 + d.second_moment / 2 + 1.0) / d.mean
44
+ assert zero_wait(d, 1.0, 1.0)[1] == pytest.approx(expected, rel=1e-10)
45
+
46
+
47
+ def test_exponential_theorem2():
48
+ """Theorem 2: v(y) = y / lambda, constant preemption threshold, z* = max(D, rho - 1/lambda)."""
49
+ lam = 1.0
50
+ p = solve(ServiceDist.exponential(lam), 1.0, 1.0, dt=0.01)
51
+ m = p.y <= 10
52
+ assert np.max(np.abs(p.v[m] - p.y[m] / lam)) < 1e-3
53
+ assert np.unique(np.round(p.theta_map[m], 6)).size == 1
54
+ assert p.z_map[0] == pytest.approx(p.rho - 1 / lam, abs=0.011)
55
+ # optimum for k_s = k_p = 1 equals the best static policy: 1 + sqrt(2)
56
+ assert p.rho == pytest.approx(1 + np.sqrt(2), abs=1e-3)
57
+
58
+
59
+ def test_tailor_beats_baselines_and_matches_simulation():
60
+ d = ServiceDist.lomax(2.1, 1.0)
61
+ p = solve(d, 1.0, 5.0)
62
+ assert p.converged
63
+ _, c_static = optimized_static(d, 1.0, 5.0)
64
+ assert p.rho <= c_static + 1e-6
65
+ assert p.rho < update_or_wait(d, 1.0, 5.0)[1]
66
+ mc = simulate(p, d, 1.0, 5.0, n_attempts=400_000, rng=5).avg_cost
67
+ assert mc == pytest.approx(p.rho, rel=0.02)
68
+
69
+
70
+ def test_paper_lognormal_headline():
71
+ """Fig. 2(b): log-normal, E[Y] = 2, sigma^2 = 6, k_s = k_p = 1 -> TAILOR ~ 1.76."""
72
+ d = ServiceDist.lognormal_from_mean(2.0, 6.0)
73
+ p = solve(d, 1.0, 1.0)
74
+ assert p.rho == pytest.approx(1.76, abs=0.01)