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 ADDED
@@ -0,0 +1,3 @@
1
+ """Exact, replayable certificates of data-compatible ambiguity for a PDE inverse problem."""
2
+
3
+ __version__ = "0.2.0"
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