twindex 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- twindex/__init__.py +3 -0
- twindex/affine.py +135 -0
- twindex/audit.py +103 -0
- twindex/candidate.py +66 -0
- twindex/certificate.py +688 -0
- twindex/checker.py +502 -0
- twindex/cli.py +127 -0
- twindex/data/box_inside.json +63 -0
- twindex/data/box_outside.json +63 -0
- twindex/data/inconclusive.json +56 -0
- twindex/data/invalid_bc.json +72 -0
- twindex/data/invalid_gap.json +72 -0
- twindex/data/paving_regression.json +74 -0
- twindex/data/paving_theta500.json +82 -0
- twindex/data/regression_pair.json +72 -0
- twindex/data/theta500_pair.json +146 -0
- twindex/data/witness_theta500.json +86 -0
- twindex/fit.py +73 -0
- twindex/model.py +269 -0
- twindex/poly.py +255 -0
- twindex/rational.py +98 -0
- twindex/search.py +264 -0
- twindex/solver.py +101 -0
- twindex-0.2.0.dist-info/METADATA +84 -0
- twindex-0.2.0.dist-info/RECORD +29 -0
- twindex-0.2.0.dist-info/WHEEL +5 -0
- twindex-0.2.0.dist-info/entry_points.txt +2 -0
- twindex-0.2.0.dist-info/licenses/LICENSE +21 -0
- twindex-0.2.0.dist-info/top_level.txt +1 -0
twindex/__init__.py
ADDED
twindex/affine.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Affine candidate families over a parameter box (trusted core; exact arithmetic only).
|
|
2
|
+
|
|
3
|
+
On a box B = c +- h a certificate may use, instead of one candidate p, the family
|
|
4
|
+
|
|
5
|
+
p_D(x) = p0(x) + sum_j D_j s_j(x), D in prod_j [-h_j, h_j], theta = c + D,
|
|
6
|
+
|
|
7
|
+
with every s_j vanishing at 0 and 1. Expanding the residual exactly,
|
|
8
|
+
|
|
9
|
+
r(x; D) = -p_D'' + q(x; c + D) f(p_D) - g = sum_alpha D^alpha R_alpha(x),
|
|
10
|
+
|
|
11
|
+
gives the uniform bound sup_{x, D} |r| <= sum_alpha h^alpha sup_x |R_alpha|
|
|
12
|
+
(docs/THEORY.md, Theorem 3'). The polynomial p_D(x_i) at a sensor is affine in D,
|
|
13
|
+
so its exact range over the box is a_i +- sum_j h_j |s_j(x_i)|.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from collections.abc import Sequence
|
|
19
|
+
from fractions import Fraction
|
|
20
|
+
|
|
21
|
+
from .model import Model
|
|
22
|
+
from .poly import Poly, sup_abs_bound
|
|
23
|
+
|
|
24
|
+
__all__ = ["MAX_EXPANSION_TERMS", "ExpansionTooLarge", "affine_residual_bound", "sensor_range"]
|
|
25
|
+
|
|
26
|
+
#: Largest number of D-monomials an exact residual expansion may have.
|
|
27
|
+
MAX_EXPANSION_TERMS = 20_000
|
|
28
|
+
|
|
29
|
+
Exponent = tuple[int, ...]
|
|
30
|
+
MPoly = dict[Exponent, Poly] # sum over exponents alpha of D^alpha * coefficient(x)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ExpansionTooLarge(ValueError):
|
|
34
|
+
"""The exact expansion would exceed MAX_EXPANSION_TERMS monomials."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _add_into(acc: MPoly, other: MPoly) -> None:
|
|
38
|
+
for k, v in other.items():
|
|
39
|
+
s = acc[k] + v if k in acc else v
|
|
40
|
+
if s.coeffs:
|
|
41
|
+
acc[k] = s
|
|
42
|
+
elif k in acc:
|
|
43
|
+
del acc[k]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _mul(a: MPoly, b: MPoly) -> MPoly:
|
|
47
|
+
out: MPoly = {}
|
|
48
|
+
for ka, va in a.items():
|
|
49
|
+
for kb, vb in b.items():
|
|
50
|
+
k = tuple(i + j for i, j in zip(ka, kb, strict=True))
|
|
51
|
+
prod = va * vb
|
|
52
|
+
s = out[k] + prod if k in out else prod
|
|
53
|
+
if s.coeffs:
|
|
54
|
+
out[k] = s
|
|
55
|
+
elif k in out:
|
|
56
|
+
del out[k]
|
|
57
|
+
if len(out) > MAX_EXPANSION_TERMS:
|
|
58
|
+
raise ExpansionTooLarge(f"expansion exceeds {MAX_EXPANSION_TERMS} terms")
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _pow(a: MPoly, m: int, n: int) -> MPoly:
|
|
63
|
+
result: MPoly = {(0,) * n: Poly([1])}
|
|
64
|
+
base = a
|
|
65
|
+
while m:
|
|
66
|
+
if m & 1:
|
|
67
|
+
result = _mul(result, base)
|
|
68
|
+
m >>= 1
|
|
69
|
+
if m:
|
|
70
|
+
base = _mul(base, base)
|
|
71
|
+
return result
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _unit(n: int, j: int) -> Exponent:
|
|
75
|
+
return tuple(1 if i == j else 0 for i in range(n))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def residual_expansion(model: Model, center: Sequence[Fraction], p0: Poly,
|
|
79
|
+
sens: Sequence[Poly]) -> MPoly:
|
|
80
|
+
"""Exact coefficients R_alpha(x) of r(x; D) = sum_alpha D^alpha R_alpha(x)."""
|
|
81
|
+
n = model.num_params
|
|
82
|
+
zero: Exponent = (0,) * n
|
|
83
|
+
p_family: MPoly = {zero: p0}
|
|
84
|
+
for j, s in enumerate(sens):
|
|
85
|
+
if s.coeffs:
|
|
86
|
+
p_family[_unit(n, j)] = s
|
|
87
|
+
f_family: MPoly = {}
|
|
88
|
+
for m, a in model.nonlinearity:
|
|
89
|
+
term = _pow(p_family, m, n)
|
|
90
|
+
_add_into(f_family, {k: v.scale(a) for k, v in term.items()})
|
|
91
|
+
q_family: MPoly = {zero: model.q(center)}
|
|
92
|
+
for j, bj in enumerate(model.basis):
|
|
93
|
+
q_family[_unit(n, j)] = bj
|
|
94
|
+
out = _mul(q_family, f_family) if f_family else {}
|
|
95
|
+
lin: MPoly = {k: -v.deriv(2) for k, v in p_family.items()}
|
|
96
|
+
lin[zero] = lin.get(zero, Poly()) - model.source
|
|
97
|
+
_add_into(out, {k: v for k, v in lin.items() if v.coeffs})
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def affine_residual_bound(
|
|
102
|
+
model: Model,
|
|
103
|
+
center: Sequence[Fraction],
|
|
104
|
+
half_width: Sequence[Fraction],
|
|
105
|
+
p0: Poly,
|
|
106
|
+
sens: Sequence[Poly],
|
|
107
|
+
depth: int,
|
|
108
|
+
high_order_depth: int = 2,
|
|
109
|
+
) -> Fraction:
|
|
110
|
+
"""Certified bound of sup over x in [0,1] and D in the box of |r(x; D)|.
|
|
111
|
+
|
|
112
|
+
Terms of total degree <= 1 in D use ``depth`` subdivision levels, higher-order
|
|
113
|
+
terms use ``min(depth, high_order_depth)``; every choice is sound.
|
|
114
|
+
"""
|
|
115
|
+
expansion = residual_expansion(model, center, p0, sens)
|
|
116
|
+
rho = Fraction(0)
|
|
117
|
+
for alpha, coeff in expansion.items():
|
|
118
|
+
weight = Fraction(1)
|
|
119
|
+
for hj, aj in zip(half_width, alpha, strict=True):
|
|
120
|
+
if aj:
|
|
121
|
+
weight *= hj**aj
|
|
122
|
+
if weight == 0:
|
|
123
|
+
continue
|
|
124
|
+
d = depth if sum(alpha) <= 1 else min(depth, high_order_depth)
|
|
125
|
+
rho += weight * sup_abs_bound(coeff, d)
|
|
126
|
+
return rho
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def sensor_range(
|
|
130
|
+
p0: Poly, sens: Sequence[Poly], half_width: Sequence[Fraction], x: Fraction
|
|
131
|
+
) -> tuple[Fraction, Fraction]:
|
|
132
|
+
"""Exact range [lo, hi] of p_D(x) = p0(x) + sum_j D_j s_j(x) over |D_j| <= h_j."""
|
|
133
|
+
a = p0(x)
|
|
134
|
+
spread = sum((h * abs(s(x)) for h, s in zip(half_width, sens, strict=True)), Fraction(0))
|
|
135
|
+
return a - spread, a + spread
|
twindex/audit.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Untrusted measured-data workflow; only exact replay accepts evidence."""
|
|
2
|
+
import csv
|
|
3
|
+
from fractions import Fraction
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy.polynomial.chebyshev import chebval
|
|
8
|
+
|
|
9
|
+
from .candidate import to_candidate
|
|
10
|
+
from .certificate import dump_certificate, make_witness_certificate
|
|
11
|
+
from .checker import Verdict, check_certificate, check_witness
|
|
12
|
+
from .model import Problem
|
|
13
|
+
from .poly import Poly
|
|
14
|
+
from .rational import format_rational, parse_rational
|
|
15
|
+
from .search import find_pair
|
|
16
|
+
from .solver import solve_bvp
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_data(path):
|
|
20
|
+
"""Read exact x,y columns without a floating-point round trip."""
|
|
21
|
+
with open(path, newline='', encoding='utf-8-sig') as stream:
|
|
22
|
+
reader = csv.DictReader(stream)
|
|
23
|
+
if (reader.fieldnames is None or len(reader.fieldnames) != 2
|
|
24
|
+
or set(reader.fieldnames) != {'x', 'y'}):
|
|
25
|
+
raise ValueError('CSV must have exactly the columns x,y')
|
|
26
|
+
rows = list(reader)
|
|
27
|
+
if not rows or any(None in row or None in row.values() for row in rows):
|
|
28
|
+
raise ValueError('CSV must contain complete x,y rows')
|
|
29
|
+
return tuple(parse_rational(row['x']) for row in rows), tuple(
|
|
30
|
+
parse_rational(row['y']) for row in rows)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fit_seed(problem, *, n=32, iterations=30):
|
|
34
|
+
"""Bounded damped least squares, a heuristic with no feasibility guarantee."""
|
|
35
|
+
lo, hi = np.array(problem.box, dtype=float).T
|
|
36
|
+
theta = (lo + hi) / 2
|
|
37
|
+
xs, y = np.array(problem.sensors, dtype=float), np.array(problem.data, dtype=float)
|
|
38
|
+
|
|
39
|
+
def values(t):
|
|
40
|
+
return chebval(2 * xs - 1, solve_bvp(t, n=n, model=problem.model))
|
|
41
|
+
|
|
42
|
+
for _ in range(iterations):
|
|
43
|
+
base = values(theta)
|
|
44
|
+
error = base - y
|
|
45
|
+
if np.max(np.abs(error)) < float(problem.epsilon) / 2:
|
|
46
|
+
break
|
|
47
|
+
jac = np.zeros((len(xs), len(theta)))
|
|
48
|
+
for j in range(len(theta)):
|
|
49
|
+
if hi[j] == lo[j]:
|
|
50
|
+
continue
|
|
51
|
+
probe = theta.copy()
|
|
52
|
+
h = max(1e-4, abs(theta[j]) * 1e-4)
|
|
53
|
+
probe[j] = min(hi[j], theta[j] + h)
|
|
54
|
+
if probe[j] == theta[j]:
|
|
55
|
+
probe[j] = max(lo[j], theta[j] - h)
|
|
56
|
+
jac[:, j] = (values(probe) - base) / (probe[j] - theta[j])
|
|
57
|
+
step = np.linalg.lstsq(jac, -error, rcond=None)[0]
|
|
58
|
+
for backtrack in range(20):
|
|
59
|
+
trial = np.clip(theta + 2.0**(-backtrack) * step, lo, hi)
|
|
60
|
+
if np.linalg.norm(values(trial) - y) < np.linalg.norm(error):
|
|
61
|
+
theta = trial
|
|
62
|
+
break
|
|
63
|
+
else:
|
|
64
|
+
break
|
|
65
|
+
return tuple(Fraction(f'{v:.12f}') for v in theta)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def audit(problem: Problem, output, *, estimate=None, n=32, rounds=3, depth=6):
|
|
69
|
+
"""Return an honest summary and paths; never infer uniqueness from failure."""
|
|
70
|
+
seed = None
|
|
71
|
+
# An estimate is accepted only after an exact check. If it fails, try a fit.
|
|
72
|
+
attempts = [estimate, None] if estimate is not None else [None]
|
|
73
|
+
for attempt in attempts:
|
|
74
|
+
try:
|
|
75
|
+
theta = (tuple(parse_rational(v) for v in attempt) if attempt is not None
|
|
76
|
+
else fit_seed(problem, n=n))
|
|
77
|
+
p = Poly(to_candidate(solve_bvp(theta, n=n, model=problem.model)))
|
|
78
|
+
if check_witness(problem, theta, p, depth).verdict is Verdict.VALID:
|
|
79
|
+
seed = (theta, p)
|
|
80
|
+
break
|
|
81
|
+
except (ValueError, RuntimeError, ArithmeticError, np.linalg.LinAlgError):
|
|
82
|
+
continue
|
|
83
|
+
if seed is None:
|
|
84
|
+
return 2, 'No compatible seed certified; recoverability remains undecided.'
|
|
85
|
+
output = Path(output)
|
|
86
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
witness = make_witness_certificate(problem, *seed)
|
|
88
|
+
if check_certificate(witness, depth).verdict is not Verdict.VALID:
|
|
89
|
+
raise RuntimeError('seed certificate failed replay')
|
|
90
|
+
witness_path = output / 'seed.json'
|
|
91
|
+
dump_certificate(witness, witness_path)
|
|
92
|
+
try:
|
|
93
|
+
pair = find_pair(problem, seed=seed[0], n=n, rounds=rounds, depth=depth)
|
|
94
|
+
except (ValueError, RuntimeError, ArithmeticError, np.linalg.LinAlgError):
|
|
95
|
+
return 2, (f'Compatible seed certified: {witness_path}. No positive pair gap certified; '
|
|
96
|
+
'recoverability remains undecided.')
|
|
97
|
+
pair_path = output / 'pair.json'
|
|
98
|
+
dump_certificate(pair, pair_path)
|
|
99
|
+
radius = format_rational(parse_rational(pair['claim']['gap']) / 2)
|
|
100
|
+
return 0, (f'theta_{problem.target} cannot have a uniform absolute-error guarantee '
|
|
101
|
+
f'better than +/-{radius} from these data (certified). '
|
|
102
|
+
f'Certificates: {witness_path}, {pair_path}. '
|
|
103
|
+
'This is a lower bound on worst-case uncertainty, not an upper bound.')
|
twindex/candidate.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Untrusted conversion to exact monomial candidates with exact zero BCs."""
|
|
2
|
+
|
|
3
|
+
import operator
|
|
4
|
+
from fractions import Fraction
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from numpy.polynomial import chebyshev as cheb
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _add(a, b):
|
|
11
|
+
out = [Fraction(0)] * max(len(a), len(b))
|
|
12
|
+
for i, c in enumerate(a):
|
|
13
|
+
out[i] += c
|
|
14
|
+
for i, c in enumerate(b):
|
|
15
|
+
out[i] += c
|
|
16
|
+
return out
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _shifted_t_times(a):
|
|
20
|
+
"""Multiply by t=2x-1 in exact monomial arithmetic."""
|
|
21
|
+
out = [Fraction(0)] * (len(a) + 1)
|
|
22
|
+
for i, c in enumerate(a):
|
|
23
|
+
out[i] -= c
|
|
24
|
+
out[i + 1] += 2 * c
|
|
25
|
+
return out
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def to_candidate(coeffs, bits=60, degree=None):
|
|
29
|
+
"""Return exact monomial coefficients, constant first.
|
|
30
|
+
|
|
31
|
+
Divide the floating Chebyshev polynomial by x(1-x), discard its boundary
|
|
32
|
+
remainder, and round the quotient's Chebyshev coefficients to dyadics.
|
|
33
|
+
Convert the quotient EXACTLY using T_(k+1)=2(2x-1)T_k-T_(k-1), then
|
|
34
|
+
multiply by x(1-x). No floating monomial conversion is performed.
|
|
35
|
+
``degree`` optionally truncates the quotient before exact conversion.
|
|
36
|
+
"""
|
|
37
|
+
bits = operator.index(bits)
|
|
38
|
+
if bits < 0 or bits > 1024:
|
|
39
|
+
raise ValueError("bits must lie between 0 and 1024")
|
|
40
|
+
c = np.asarray(coeffs, dtype=float)
|
|
41
|
+
if c.ndim != 1 or c.size == 0 or not np.all(np.isfinite(c)):
|
|
42
|
+
raise ValueError("coeffs must be a nonempty finite one-dimensional array")
|
|
43
|
+
quotient, _ = cheb.chebdiv(c, [1 / 8, 0, -1 / 8])
|
|
44
|
+
if degree is not None:
|
|
45
|
+
degree = operator.index(degree)
|
|
46
|
+
if degree < 0:
|
|
47
|
+
raise ValueError("degree must be nonnegative")
|
|
48
|
+
quotient = quotient[:degree + 1]
|
|
49
|
+
scale = 1 << bits
|
|
50
|
+
# Fraction(float) is intentional here, outside the trusted core. Integer
|
|
51
|
+
# rounding avoids overflow even when bits is larger than the float range.
|
|
52
|
+
rounded = [Fraction(round(Fraction(float(a)) * scale), scale) for a in quotient]
|
|
53
|
+
previous, current = [Fraction(1)], [Fraction(-1), Fraction(2)]
|
|
54
|
+
s = [rounded[0]]
|
|
55
|
+
for k in range(1, len(rounded)):
|
|
56
|
+
s = _add(s, [rounded[k] * a for a in current])
|
|
57
|
+
previous, current = current, _add(
|
|
58
|
+
[2 * a for a in _shifted_t_times(current)], [-a for a in previous]
|
|
59
|
+
)
|
|
60
|
+
out = [Fraction(0)] * (len(s) + 2)
|
|
61
|
+
for i, a in enumerate(s):
|
|
62
|
+
out[i + 1] += a
|
|
63
|
+
out[i + 2] -= a
|
|
64
|
+
while len(out) > 1 and out[-1] == 0:
|
|
65
|
+
out.pop()
|
|
66
|
+
return out
|