softopt 0.7.0__tar.gz → 0.7.3__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.
- {softopt-0.7.0/softopt.egg-info → softopt-0.7.3}/PKG-INFO +1 -1
- {softopt-0.7.0 → softopt-0.7.3}/pyproject.toml +1 -1
- {softopt-0.7.0 → softopt-0.7.3}/softopt/__init__.py +6 -2
- softopt-0.7.3/softopt/_torch_compile.py +115 -0
- {softopt-0.7.0 → softopt-0.7.3}/softopt/calibrate.py +14 -3
- {softopt-0.7.0 → softopt-0.7.3/softopt.egg-info}/PKG-INFO +1 -1
- {softopt-0.7.0 → softopt-0.7.3}/softopt.egg-info/SOURCES.txt +3 -1
- softopt-0.7.3/tests/test_torch_compile.py +97 -0
- {softopt-0.7.0 → softopt-0.7.3}/LICENSE +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/README.md +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/setup.cfg +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/softopt/compile.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/softopt/numpy_optimizer.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/softopt/soft_number.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/softopt/torch_optimizer.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/softopt.egg-info/dependency_links.txt +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/softopt.egg-info/requires.txt +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/softopt.egg-info/top_level.txt +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/tests/test_basic.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/tests/test_calibrate.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/tests/test_causal.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/tests/test_compile.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/tests/test_exact_d2.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/tests/test_measured_slope.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/tests/test_soft_number.py +0 -0
- {softopt-0.7.0 → softopt-0.7.3}/tests/test_torch.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "softopt"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.3"
|
|
8
8
|
description = "An optimization layer that closes the gap between your model and the real system, built on Klein–Maimon soft-number calculus"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -6,6 +6,7 @@ model (a simulator, a circuit, a physical law) and the real system you measure.
|
|
|
6
6
|
from softopt import spsa_gradient # gradient, direction and measured slope from 2 readings
|
|
7
7
|
from softopt import SoftOptTorch # PyTorch version (torch.optim.Optimizer)
|
|
8
8
|
from softopt import soft_compile # turn a plain-Python model into g_delta
|
|
9
|
+
from softopt import torch_compile # the same for a PyTorch model (forward-mode = soft numbers)
|
|
9
10
|
from softopt import SoftNumber # the algebraic primitive itself
|
|
10
11
|
|
|
11
12
|
calibrate=True (new in 0.7.0): soft_compile(model, n_params, n_bias=k) for model(theta, beta), then
|
|
@@ -25,12 +26,15 @@ def __getattr__(name):
|
|
|
25
26
|
if name == "SoftOptTorch":
|
|
26
27
|
from .torch_optimizer import SoftOptTorch
|
|
27
28
|
return SoftOptTorch
|
|
29
|
+
if name == "torch_compile":
|
|
30
|
+
from ._torch_compile import torch_compile
|
|
31
|
+
return torch_compile
|
|
28
32
|
raise AttributeError(f"module 'softopt' has no attribute {name!r}")
|
|
29
33
|
|
|
30
34
|
|
|
31
35
|
__all__ = [
|
|
32
|
-
"SoftOpt", "SoftOptTorch", "SoftNumber", "soft_compile", "spsa_gradient",
|
|
36
|
+
"SoftOpt", "SoftOptTorch", "SoftNumber", "soft_compile", "torch_compile", "spsa_gradient",
|
|
33
37
|
"sadd", "sneg", "ssub", "smul", "sinv", "sdiv",
|
|
34
38
|
"spow", "sroot", "ssqrt", "ssin", "scos", "sexp", "slog",
|
|
35
39
|
]
|
|
36
|
-
__version__ = "0.7.
|
|
40
|
+
__version__ = "0.7.3"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
torch_compile: soft_compile for models written in PyTorch -- a neural network, a differentiable sampler, any
|
|
3
|
+
torch computation -- so SoftOpt (default and calibrate=True) can use them.
|
|
4
|
+
|
|
5
|
+
from softopt import SoftOpt, torch_compile
|
|
6
|
+
|
|
7
|
+
def model(theta, beta): # torch tensors in, a scalar tensor out
|
|
8
|
+
...
|
|
9
|
+
|
|
10
|
+
g = torch_compile(model, n_params=n, n_bias=k)
|
|
11
|
+
opt = SoftOpt(n, g, lr=0.02, calibrate=True, bias_scale=0.1)
|
|
12
|
+
|
|
13
|
+
How the soft algebra is carried
|
|
14
|
+
A soft number a*eps + b (eps^2 = 0) is what PyTorch's forward-mode automatic differentiation propagates:
|
|
15
|
+
every value b travels with a tangent a, and every operation applies f(a eps + b) = a f'(b) eps + f(b)
|
|
16
|
+
(Lemma 6.1). torch.func.jvp is that single-axis soft evaluation; a jvp inside a jvp is the NESTED soft number
|
|
17
|
+
((e, a), (c, v)) with two axes eps1, eps2:
|
|
18
|
+
eps1 and eps2 both along d -> v = f, c = a = D1, e = D2 (d1_d2)
|
|
19
|
+
eps1 along d, eps2 along beta_j -> e = d D1 / d beta_j (d1_and_jac: the mixed part)
|
|
20
|
+
d1_and_jac carries all beta_j axes in one vectorized pass (0.7.3); it falls back to one pass per beta_j for
|
|
21
|
+
models whose operations cannot be batched.
|
|
22
|
+
The same quantities softopt.calibrate.CompiledModel computes with SoftNumber objects, so the calibrator and
|
|
23
|
+
the optimizer are unchanged. Checked against CompiledModel in tests/test_torch_compile.py.
|
|
24
|
+
"""
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _torch():
|
|
29
|
+
import torch
|
|
30
|
+
from torch.func import jvp
|
|
31
|
+
return torch, jvp
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TorchModel:
|
|
35
|
+
"""model_fn(theta, beta) (or model_fn(theta) when n_bias=0) on nested soft numbers via nested jvp."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, model_fn, n_bias=0, dtype=None, device=None):
|
|
38
|
+
torch, _ = _torch()
|
|
39
|
+
self.f, self.nb = model_fn, int(n_bias)
|
|
40
|
+
self.dtype = torch.float64 if dtype is None else dtype
|
|
41
|
+
self.device = device
|
|
42
|
+
self._vectorized = True
|
|
43
|
+
|
|
44
|
+
def _t(self, v):
|
|
45
|
+
torch, _ = _torch()
|
|
46
|
+
return torch.as_tensor(np.asarray(v, dtype=float), dtype=self.dtype, device=self.device)
|
|
47
|
+
|
|
48
|
+
def _call(self, th, bs):
|
|
49
|
+
return self.f(th, bs) if self.nb else self.f(th)
|
|
50
|
+
|
|
51
|
+
def value(self, x, beta=()):
|
|
52
|
+
torch, _ = _torch()
|
|
53
|
+
with torch.no_grad():
|
|
54
|
+
return float(self._call(self._t(x), self._t(beta)))
|
|
55
|
+
|
|
56
|
+
def _d1(self, x, d, b):
|
|
57
|
+
_, jvp = _torch()
|
|
58
|
+
return jvp(lambda t: self._call(t, b), (x,), (d,))[1]
|
|
59
|
+
|
|
60
|
+
def d1_d2(self, x, d, beta=()):
|
|
61
|
+
"""D1 and D2 along d: eps1 and eps2 both along d."""
|
|
62
|
+
_, jvp = _torch()
|
|
63
|
+
x, d, b = self._t(x), self._t(d), self._t(beta)
|
|
64
|
+
D1, D2 = jvp(lambda t: self._d1(t, d, b), (x,), (d,))
|
|
65
|
+
return float(D1), float(D2)
|
|
66
|
+
|
|
67
|
+
def d1_and_jac(self, x, d, beta=()):
|
|
68
|
+
"""D1 along d, and d D1 / d beta_j for every j: eps1 along d, eps2 along beta_j (the mixed part)."""
|
|
69
|
+
torch, jvp = _torch()
|
|
70
|
+
x, d, b = self._t(x), self._t(d), self._t(beta)
|
|
71
|
+
if not self.nb:
|
|
72
|
+
return float(self._d1(x, d, b)), np.zeros(0)
|
|
73
|
+
if self._vectorized:
|
|
74
|
+
# all bias tangents in one pass: a soft number carrying one eps2 axis per bias parameter (vmap of the
|
|
75
|
+
# forward-mode slope over beta). Same numbers as the loop below, about nb times fewer passes.
|
|
76
|
+
try:
|
|
77
|
+
from torch.func import jacfwd
|
|
78
|
+
def slope(bb):
|
|
79
|
+
y = self._d1(x, d, bb)
|
|
80
|
+
return y, y
|
|
81
|
+
jac, D1 = jacfwd(slope, has_aux=True)(b)
|
|
82
|
+
return float(D1), jac.detach().cpu().numpy().astype(float).reshape(self.nb)
|
|
83
|
+
except Exception:
|
|
84
|
+
self._vectorized = False # a model op without batching support: use one pass per bias
|
|
85
|
+
jac, D1 = np.zeros(self.nb), None
|
|
86
|
+
for j in range(self.nb):
|
|
87
|
+
e = torch.zeros_like(b); e[j] = 1.0
|
|
88
|
+
D1, jac[j] = jvp(lambda bb: self._d1(x, d, bb), (b,), (e,))
|
|
89
|
+
return float(D1), jac
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def torch_compile(model_fn, n_params=None, n_bias=0, dtype=None, device=None, verify=True):
|
|
93
|
+
"""model_fn(theta) -> scalar tensor, or model_fn(theta, beta) with n_bias > 0 (beta = offsets of the
|
|
94
|
+
model's uncertain parameters, 0 = nominal). Returns g_delta for SoftOpt, carrying .d1_d2 (exact D2) and
|
|
95
|
+
.calibration (for calibrate=True). dtype defaults to float64; pass torch.float32 for large networks."""
|
|
96
|
+
M = TorchModel(model_fn, n_bias, dtype, device)
|
|
97
|
+
zero = np.zeros(M.nb)
|
|
98
|
+
|
|
99
|
+
def g_delta(theta, delta):
|
|
100
|
+
return M.d1_and_jac(theta, delta, zero)[0]
|
|
101
|
+
|
|
102
|
+
g_delta.d1_d2 = lambda theta, delta: M.d1_d2(theta, delta, zero)
|
|
103
|
+
g_delta.calibration = M
|
|
104
|
+
g_delta.value = lambda theta: M.value(theta, zero)
|
|
105
|
+
if verify and n_params:
|
|
106
|
+
rng = np.random.default_rng(0)
|
|
107
|
+
x, d = rng.normal(size=n_params), rng.choice([-1.0, 1.0], size=n_params)
|
|
108
|
+
h = 1e-4
|
|
109
|
+
fd = (M.value(x + h * d, zero) - M.value(x - h * d, zero)) / (2 * h)
|
|
110
|
+
D1 = g_delta(x, d)
|
|
111
|
+
tol = 1e-4 if M.dtype.itemsize == 8 else 1e-2
|
|
112
|
+
if not np.isfinite(D1) or abs(D1 - fd) > tol * max(1.0, abs(fd)):
|
|
113
|
+
raise ValueError(f'torch_compile: the forward-mode slope {D1:.6g} disagrees with finite differences '
|
|
114
|
+
f'{fd:.6g} -- the model may not be differentiable here, or it detaches theta')
|
|
115
|
+
return g_delta
|
|
@@ -28,6 +28,10 @@ Two ways to use it
|
|
|
28
28
|
their expected size (a number or one per parameter), on the PHYSICAL scale -- a prior far too wide lets the
|
|
29
29
|
filter fit noise.
|
|
30
30
|
|
|
31
|
+
The discrepancy dg enters the slope only as far as it stands out of its own uncertainty,
|
|
32
|
+
wg = (dg.e)^2 / ((dg.e)^2 + var(dg.e)) (new in 0.7.2): with noisy readings dg would otherwise absorb the noise
|
|
33
|
+
and cancel the model's soft-number slope.
|
|
34
|
+
|
|
31
35
|
Validated (development + held-out seeds) where the bias structure is right: gate calibration, adaptive optics,
|
|
32
36
|
H2 with coherent gate errors, robot sim-to-real. When the model lacks a major error type the median is still at
|
|
33
37
|
or below Adam but single runs can be worse; when the model is already exact, calibrate=False is better.
|
|
@@ -135,8 +139,15 @@ class Calibrator:
|
|
|
135
139
|
beta, dg = st[self.ib], st[self.ig]
|
|
136
140
|
D1m, D2m = self.M.d1_d2(x, e, beta)
|
|
137
141
|
_, jac_e = self.M.d1_and_jac(x, e, beta)
|
|
138
|
-
|
|
139
|
-
|
|
142
|
+
# dg is the part of the slope the model cannot explain. Under noisy readings it also absorbs the noise,
|
|
143
|
+
# so it enters only as far as it stands out of its own uncertainty (wg); the soft-number slope of the
|
|
144
|
+
# calibrated model is always kept.
|
|
145
|
+
dge = float(dg @ e)
|
|
146
|
+
hg = np.r_[np.zeros_like(jac_e), 0.0, e]
|
|
147
|
+
varg = max(float(hg @ P @ hg), 0.0)
|
|
148
|
+
wg = dge ** 2 / (dge ** 2 + varg) if (dge ** 2 + varg) > 0 else 0.0
|
|
149
|
+
D1 = D1m + wg * dge
|
|
150
|
+
h = np.r_[jac_e, 0.0, wg * e]
|
|
140
151
|
var = max(float(h @ P @ h), 0.0)
|
|
141
152
|
t = -D1 / D2m if D2m > 1e-8 else -eta_fallback * D1
|
|
142
153
|
w = D1 ** 2 / (D1 ** 2 + var) if (D1 ** 2 + var) > 0 else 0.0
|
|
@@ -144,7 +155,7 @@ class Calibrator:
|
|
|
144
155
|
if step != 0.0:
|
|
145
156
|
f0 = self.M.value(x, beta)
|
|
146
157
|
for _ in range(20):
|
|
147
|
-
df = self.M.value(x + step * e, beta) - f0 + step *
|
|
158
|
+
df = self.M.value(x + step * e, beta) - f0 + step * wg * dge
|
|
148
159
|
if df < 0:
|
|
149
160
|
break
|
|
150
161
|
c = (df - D1 * step) / step ** 2
|
|
@@ -2,6 +2,7 @@ LICENSE
|
|
|
2
2
|
README.md
|
|
3
3
|
pyproject.toml
|
|
4
4
|
softopt/__init__.py
|
|
5
|
+
softopt/_torch_compile.py
|
|
5
6
|
softopt/calibrate.py
|
|
6
7
|
softopt/compile.py
|
|
7
8
|
softopt/numpy_optimizer.py
|
|
@@ -19,4 +20,5 @@ tests/test_compile.py
|
|
|
19
20
|
tests/test_exact_d2.py
|
|
20
21
|
tests/test_measured_slope.py
|
|
21
22
|
tests/test_soft_number.py
|
|
22
|
-
tests/test_torch.py
|
|
23
|
+
tests/test_torch.py
|
|
24
|
+
tests/test_torch_compile.py
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""torch_compile (nested jvp) must give exactly what soft_compile (nested SoftNumber) gives."""
|
|
2
|
+
import numpy as np
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
torch = pytest.importorskip("torch")
|
|
6
|
+
|
|
7
|
+
from softopt import SoftOpt, soft_compile, spsa_gradient # noqa: E402
|
|
8
|
+
from softopt import torch_compile # noqa: E402
|
|
9
|
+
|
|
10
|
+
N, K = 5, 3
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def model_soft(theta, beta):
|
|
14
|
+
x = theta
|
|
15
|
+
s = x[0] * x[0] * (1 + beta[0]) + np.sin(x[1] - 0.3 * x[2]) * (2 + beta[1])
|
|
16
|
+
s = s + np.exp(x[3] * x[4] + beta[2] * x[0]) * 0.1 + x[2] * x[2] * x[2] / (2 + x[4] * x[4])
|
|
17
|
+
return s
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def model_torch(theta, beta):
|
|
21
|
+
x = theta
|
|
22
|
+
s = x[0] * x[0] * (1 + beta[0]) + torch.sin(x[1] - 0.3 * x[2]) * (2 + beta[1])
|
|
23
|
+
s = s + torch.exp(x[3] * x[4] + beta[2] * x[0]) * 0.1 + x[2] ** 3 / (2 + x[4] * x[4])
|
|
24
|
+
return s
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_matches_soft_compile():
|
|
28
|
+
gs = soft_compile(model_soft, n_params=N, n_bias=K)
|
|
29
|
+
gt = torch_compile(model_torch, n_params=N, n_bias=K)
|
|
30
|
+
rng = np.random.default_rng(1)
|
|
31
|
+
for _ in range(5):
|
|
32
|
+
x, d, b = rng.normal(size=N), rng.choice([-1.0, 1.0], size=N), 0.1 * rng.normal(size=K)
|
|
33
|
+
assert abs(gs(x, d) - gt(x, d)) < 1e-10
|
|
34
|
+
Ms, Mt = gs.calibration, gt.calibration
|
|
35
|
+
for a, c in zip(Ms.d1_d2(x, d, b), Mt.d1_d2(x, d, b)):
|
|
36
|
+
assert abs(a - c) < 1e-10
|
|
37
|
+
(D1s, Js), (D1t, Jt) = Ms.d1_and_jac(x, d, b), Mt.d1_and_jac(x, d, b)
|
|
38
|
+
assert abs(D1s - D1t) < 1e-10 and np.max(np.abs(Js - Jt)) < 1e-10
|
|
39
|
+
assert abs(Ms.value(x, b) - Mt.value(x, b)) < 1e-12
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_calibrate_trajectory_identical():
|
|
43
|
+
"""The whole calibrate=True optimisation is the same with either compiler."""
|
|
44
|
+
true_beta = np.array([0.2, -0.3, 0.1])
|
|
45
|
+
|
|
46
|
+
def measure(x, rng):
|
|
47
|
+
return model_torch(torch.tensor(x), torch.tensor(true_beta)).item() + 0.01 * rng.normal()
|
|
48
|
+
|
|
49
|
+
xs = []
|
|
50
|
+
for comp, fn in ((soft_compile, model_soft), (torch_compile, model_torch)):
|
|
51
|
+
g = comp(fn, n_params=N, n_bias=K)
|
|
52
|
+
opt = SoftOpt(N, g, lr=0.05, calibrate=True, bias_scale=0.3, seed=3)
|
|
53
|
+
x, rng = np.full(N, 0.4), np.random.default_rng(7)
|
|
54
|
+
for _ in range(25):
|
|
55
|
+
grad, d, slope = spsa_gradient(lambda t: measure(t, rng), x, c=0.1, rng=rng)
|
|
56
|
+
x = opt.step(x, grad, direction=d, measured_slope=slope)
|
|
57
|
+
xs.append(x)
|
|
58
|
+
assert np.max(np.abs(xs[0] - xs[1])) < 1e-8
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_plain_model_and_exact_d2():
|
|
62
|
+
g = torch_compile(lambda t: (t ** 2).sum() + torch.cos(t[0]), n_params=3)
|
|
63
|
+
D1, D2 = g.d1_d2(np.array([0.5, 1.0, -1.0]), np.array([1.0, -1.0, 1.0]))
|
|
64
|
+
assert abs(D2 - (6 - np.cos(0.5))) < 1e-12
|
|
65
|
+
assert abs(D1 - (1.0 - 2.0 - 2.0 - np.sin(0.5))) < 1e-12
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_detached_model_is_caught():
|
|
69
|
+
with pytest.raises(ValueError):
|
|
70
|
+
torch_compile(lambda t: torch.tensor(float(t.sum().detach())), n_params=2)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_vectorized_bias_jacobian_equals_loop():
|
|
74
|
+
"""0.7.3: d1_and_jac carries all bias axes in one vectorized pass; it must equal one pass per bias."""
|
|
75
|
+
gv = torch_compile(model_torch, n_params=N, n_bias=K)
|
|
76
|
+
gl = torch_compile(model_torch, n_params=N, n_bias=K)
|
|
77
|
+
gl.calibration._vectorized = False
|
|
78
|
+
rng = np.random.default_rng(2)
|
|
79
|
+
for _ in range(5):
|
|
80
|
+
x, d, b = rng.normal(size=N), rng.choice([-1.0, 1.0], size=N), 0.1 * rng.normal(size=K)
|
|
81
|
+
(D1v, Jv), (D1l, Jl) = gv.calibration.d1_and_jac(x, d, b), gl.calibration.d1_and_jac(x, d, b)
|
|
82
|
+
assert gv.calibration._vectorized
|
|
83
|
+
assert abs(D1v - D1l) < 1e-12 and np.max(np.abs(Jv - Jl)) < 1e-12
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_unbatchable_model_falls_back_to_loop():
|
|
87
|
+
"""A model op that cannot be batched (here .item() on a bias) must fall back to the per-bias loop."""
|
|
88
|
+
def model_item(theta, beta):
|
|
89
|
+
if float(beta[0].detach()) > 1e9: # data-dependent Python branch: not vmap-able
|
|
90
|
+
return theta.sum()
|
|
91
|
+
return model_torch(theta, beta)
|
|
92
|
+
g = torch_compile(model_item, n_params=N, n_bias=K, verify=False)
|
|
93
|
+
ref = torch_compile(model_torch, n_params=N, n_bias=K)
|
|
94
|
+
rng = np.random.default_rng(3)
|
|
95
|
+
x, d, b = rng.normal(size=N), rng.choice([-1.0, 1.0], size=N), 0.1 * rng.normal(size=K)
|
|
96
|
+
(D1, J), (D1r, Jr) = g.calibration.d1_and_jac(x, d, b), ref.calibration.d1_and_jac(x, d, b)
|
|
97
|
+
assert abs(D1 - D1r) < 1e-12 and np.max(np.abs(J - Jr)) < 1e-12
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|