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.
- tailor_aoi-0.1.0/LICENSE +21 -0
- tailor_aoi-0.1.0/PKG-INFO +70 -0
- tailor_aoi-0.1.0/README.md +44 -0
- tailor_aoi-0.1.0/pyproject.toml +41 -0
- tailor_aoi-0.1.0/setup.cfg +4 -0
- tailor_aoi-0.1.0/src/tailor_aoi/__init__.py +59 -0
- tailor_aoi-0.1.0/src/tailor_aoi/__main__.py +42 -0
- tailor_aoi-0.1.0/src/tailor_aoi/baselines.py +127 -0
- tailor_aoi-0.1.0/src/tailor_aoi/distributions.py +169 -0
- tailor_aoi-0.1.0/src/tailor_aoi/plotting.py +41 -0
- tailor_aoi-0.1.0/src/tailor_aoi/simulate.py +144 -0
- tailor_aoi-0.1.0/src/tailor_aoi/solver.py +336 -0
- tailor_aoi-0.1.0/src/tailor_aoi.egg-info/PKG-INFO +70 -0
- tailor_aoi-0.1.0/src/tailor_aoi.egg-info/SOURCES.txt +16 -0
- tailor_aoi-0.1.0/src/tailor_aoi.egg-info/dependency_links.txt +1 -0
- tailor_aoi-0.1.0/src/tailor_aoi.egg-info/requires.txt +8 -0
- tailor_aoi-0.1.0/src/tailor_aoi.egg-info/top_level.txt +1 -0
- tailor_aoi-0.1.0/tests/test_tailor.py +74 -0
tailor_aoi-0.1.0/LICENSE
ADDED
|
@@ -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
|
+

|
|
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
|
+

|
|
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,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
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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)
|