Upload provenance_reconstruct.py
Browse files- provenance_reconstruct.py +846 -0
provenance_reconstruct.py
ADDED
|
@@ -0,0 +1,846 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
provenance_reconstruct.py
|
| 4 |
+
=========================
|
| 5 |
+
|
| 6 |
+
Forensic provenance reconstruction for bare numbers.
|
| 7 |
+
|
| 8 |
+
Given a value that arrived without provenance — a float from a solver,
|
| 9 |
+
a token count from an API — infer the most likely provenance that
|
| 10 |
+
produced it, with calibrated confidence.
|
| 11 |
+
|
| 12 |
+
This is the consumer-side complement to numerical_provenance.py and
|
| 13 |
+
token_provenance.py. Those tools produce values that carry their
|
| 14 |
+
provenance. This tool reconstructs provenance for values that lost
|
| 15 |
+
it at an API boundary, a log line, or a file copy.
|
| 16 |
+
|
| 17 |
+
Pure stdlib + numpy. No downloads. No tokenizer files. No model hub.
|
| 18 |
+
The "tokenizer" is a 12-line local function. The "model" is a small
|
| 19 |
+
MLP trained in seconds on data generated by the tools themselves.
|
| 20 |
+
|
| 21 |
+
The output is deliberately marked:
|
| 22 |
+
|
| 23 |
+
observed provenance [ok: jacobi_converged -> participation_ratio]
|
| 24 |
+
inferred provenance [CONVERGED? p=0.87] <-- this tool
|
| 25 |
+
OOD reconstruction [CONVERGED?? OOD p=1.00]
|
| 26 |
+
|
| 27 |
+
A downstream consumer can require observed provenance and refuse
|
| 28 |
+
inferred provenance. The '?' is the honesty marker. The '??' plus
|
| 29 |
+
'OOD' means the input is outside the training distribution and the
|
| 30 |
+
confidence is not meaningful.
|
| 31 |
+
|
| 32 |
+
Findings this file demonstrates
|
| 33 |
+
-------------------------------
|
| 34 |
+
|
| 35 |
+
The token task is a clean positive result: the residual against a
|
| 36 |
+
free local estimator equals the template offset exactly, so a tiny
|
| 37 |
+
model learns the three-way classification at ~100% accuracy. This is
|
| 38 |
+
arithmetic dressed up as learning; the model is packaging, not magic.
|
| 39 |
+
|
| 40 |
+
The numeric task is a clean negative result: once the leaking solver-
|
| 41 |
+
config features are removed, a bare value + matrix size carries very
|
| 42 |
+
little recoverable convergence information. Accuracy lands near the
|
| 43 |
+
majority-class baseline. This is a finding, not a failure. The value
|
| 44 |
+
of the tool is its *calibration* and its *OOD marker* — it says
|
| 45 |
+
"[? p=0.61]" on the inputs where reconstruction is genuinely
|
| 46 |
+
uncertain, and "[?? OOD p=1.00]" on the inputs where the confidence
|
| 47 |
+
is not meaningful at all.
|
| 48 |
+
|
| 49 |
+
Runnable. Requires only numpy.
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
from __future__ import annotations
|
| 53 |
+
|
| 54 |
+
import math
|
| 55 |
+
import random
|
| 56 |
+
import re
|
| 57 |
+
from dataclasses import dataclass
|
| 58 |
+
from typing import List, Sequence, Tuple
|
| 59 |
+
|
| 60 |
+
import numpy as np
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# =====================================================================
|
| 64 |
+
# §1 Utilities
|
| 65 |
+
# =====================================================================
|
| 66 |
+
|
| 67 |
+
def decimal_digits(x: float) -> int:
|
| 68 |
+
"""Significant decimal digits visible in repr(x)."""
|
| 69 |
+
s = f"{x:.15g}"
|
| 70 |
+
if "e" in s or "E" in s:
|
| 71 |
+
mantissa = s.split("e")[0].split("E")[0]
|
| 72 |
+
else:
|
| 73 |
+
mantissa = s
|
| 74 |
+
if "." in mantissa:
|
| 75 |
+
frac = mantissa.split(".")[1].rstrip("0")
|
| 76 |
+
return len(frac)
|
| 77 |
+
return 0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def punct_ratio(text: str) -> float:
|
| 81 |
+
if not text:
|
| 82 |
+
return 0.0
|
| 83 |
+
n = sum(1 for ch in text if not ch.isalnum() and not ch.isspace())
|
| 84 |
+
return n / len(text)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def digit_ratio(text: str) -> float:
|
| 88 |
+
if not text:
|
| 89 |
+
return 0.0
|
| 90 |
+
return sum(1 for ch in text if ch.isdigit()) / len(text)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# =====================================================================
|
| 94 |
+
# §2 Inlined numerical primitives
|
| 95 |
+
# (in the real repo these are imports from numerical_provenance)
|
| 96 |
+
# =====================================================================
|
| 97 |
+
|
| 98 |
+
def jacobi_eigen(A: List[List[float]], max_iter: int,
|
| 99 |
+
tol: float = 1e-10) -> Tuple[List[float], dict]:
|
| 100 |
+
n = len(A)
|
| 101 |
+
B = [row[:] for row in A]
|
| 102 |
+
converged = False
|
| 103 |
+
iters = 0
|
| 104 |
+
|
| 105 |
+
for it in range(max_iter):
|
| 106 |
+
iters = it + 1
|
| 107 |
+
off = 0.0
|
| 108 |
+
p, q = 0, 1
|
| 109 |
+
for i in range(n):
|
| 110 |
+
for j in range(i + 1, n):
|
| 111 |
+
if abs(B[i][j]) > off:
|
| 112 |
+
off = abs(B[i][j])
|
| 113 |
+
p, q = i, j
|
| 114 |
+
if off < tol:
|
| 115 |
+
converged = True
|
| 116 |
+
break
|
| 117 |
+
theta = 0.5 * math.atan2(2 * B[p][q], B[p][p] - B[q][q])
|
| 118 |
+
c, s = math.cos(theta), math.sin(theta)
|
| 119 |
+
for i in range(n):
|
| 120 |
+
if i == p or i == q:
|
| 121 |
+
continue
|
| 122 |
+
bip, biq = B[i][p], B[i][q]
|
| 123 |
+
B[i][p] = c * bip - s * biq
|
| 124 |
+
B[p][i] = B[i][p]
|
| 125 |
+
B[i][q] = s * bip + c * biq
|
| 126 |
+
B[q][i] = B[i][q]
|
| 127 |
+
bpp, bqq, bpq = B[p][p], B[q][q], B[p][q]
|
| 128 |
+
B[p][p] = c * c * bpp - 2 * c * s * bpq + s * s * bqq
|
| 129 |
+
B[q][q] = s * s * bpp + 2 * c * s * bpq + c * c * bqq
|
| 130 |
+
B[p][q] = B[q][p] = 0.0
|
| 131 |
+
|
| 132 |
+
off_sq = 0.0
|
| 133 |
+
for i in range(n):
|
| 134 |
+
for j in range(i + 1, n):
|
| 135 |
+
off_sq += 2 * B[i][j] ** 2
|
| 136 |
+
residual = math.sqrt(off_sq)
|
| 137 |
+
|
| 138 |
+
eigenvalues = sorted([B[i][i] for i in range(n)], reverse=True)
|
| 139 |
+
return eigenvalues, {
|
| 140 |
+
"converged": converged,
|
| 141 |
+
"iterations": iters,
|
| 142 |
+
"residual": residual,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def participation_ratio(eigs: Sequence[float]) -> float:
|
| 147 |
+
pos = [e for e in eigs if e > 1e-12]
|
| 148 |
+
if not pos:
|
| 149 |
+
return 0.0
|
| 150 |
+
s = sum(pos)
|
| 151 |
+
ss = sum(e * e for e in pos)
|
| 152 |
+
return (s * s) / ss if ss > 0 else 0.0
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def build_symmetric(n: int, rng: random.Random) -> List[List[float]]:
|
| 156 |
+
A = [[0.0] * n for _ in range(n)]
|
| 157 |
+
for i in range(n):
|
| 158 |
+
for j in range(i, n):
|
| 159 |
+
v = rng.gauss(0, 1)
|
| 160 |
+
A[i][j] = v
|
| 161 |
+
A[j][i] = v
|
| 162 |
+
return A
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# =====================================================================
|
| 166 |
+
# §3 Inlined token primitives
|
| 167 |
+
# (in the real repo these are imports from token_provenance)
|
| 168 |
+
# =====================================================================
|
| 169 |
+
|
| 170 |
+
_TOKEN_RE = re.compile(r"[A-Za-z]+|\d+|[^\sA-Za-z\d]")
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def bpe_like(text: str) -> int:
|
| 174 |
+
"""Local, free, no-download token estimator.
|
| 175 |
+
|
| 176 |
+
This is the "tokenizer" the reconstructor uses. Alpha runs split
|
| 177 |
+
into ~4-char chunks, digits into ~2-char chunks, punctuation
|
| 178 |
+
counts as one. It is not exact — but it is the *reference* the
|
| 179 |
+
residual features subtract from.
|
| 180 |
+
"""
|
| 181 |
+
n = 0
|
| 182 |
+
for tok in _TOKEN_RE.findall(text):
|
| 183 |
+
if tok.isalpha():
|
| 184 |
+
n += max(1, (len(tok) + 3) // 4)
|
| 185 |
+
elif tok.isdigit():
|
| 186 |
+
n += max(1, (len(tok) + 1) // 2)
|
| 187 |
+
else:
|
| 188 |
+
n += 1
|
| 189 |
+
return n
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# =====================================================================
|
| 193 |
+
# §4 Feature extraction
|
| 194 |
+
# =====================================================================
|
| 195 |
+
|
| 196 |
+
NUM_FEAT_NAMES = (
|
| 197 |
+
"log10|value|",
|
| 198 |
+
"decimal_digits",
|
| 199 |
+
"n",
|
| 200 |
+
"value / (n+1)",
|
| 201 |
+
"value_round",
|
| 202 |
+
"value_small",
|
| 203 |
+
"value_in_range_n",
|
| 204 |
+
"value_cluster",
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
TOK_FEAT_NAMES = (
|
| 208 |
+
"count",
|
| 209 |
+
"count / chars",
|
| 210 |
+
"count / words",
|
| 211 |
+
"count - bpe_like",
|
| 212 |
+
"count - bpe_like - 11",
|
| 213 |
+
"count - bpe_like - 7",
|
| 214 |
+
"punct_ratio",
|
| 215 |
+
"digit_ratio",
|
| 216 |
+
"chars",
|
| 217 |
+
"words",
|
| 218 |
+
"bpe_like",
|
| 219 |
+
"count % 8",
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def numeric_features(value: float, n: int) -> np.ndarray:
|
| 224 |
+
"""Observables about a bare numerical output.
|
| 225 |
+
|
| 226 |
+
Deliberately excludes max_iter and tol: those are solver config,
|
| 227 |
+
not observable at reconstruction time in the honest setting. The
|
| 228 |
+
convergence state must be inferred from the *shape* of the value
|
| 229 |
+
and the matrix size, not from the request that produced it.
|
| 230 |
+
|
| 231 |
+
If a feature that encodes max_iter or tol is added here, accuracy
|
| 232 |
+
jumps to ~99% and the model is cheating. The self-test checks for
|
| 233 |
+
that.
|
| 234 |
+
"""
|
| 235 |
+
log_v = math.log10(abs(value) + 1e-30)
|
| 236 |
+
digits = decimal_digits(value)
|
| 237 |
+
|
| 238 |
+
# PR <= n always. A value outside [1, n] is suspicious.
|
| 239 |
+
in_range = 1.0 if 1.0 <= abs(value) <= float(n) + 1e-9 else 0.0
|
| 240 |
+
|
| 241 |
+
# Converged participation ratios cluster near divisors of n
|
| 242 |
+
# (eigenvalue spectra of random symmetric matrices have effective
|
| 243 |
+
# rank roughly proportional to n). Distance to the nearest
|
| 244 |
+
# expected cluster, normalized by n.
|
| 245 |
+
clusters = (n, n / 2, n / 4, n / 8)
|
| 246 |
+
cluster_dist = min(abs(value - c) for c in clusters) / max(1.0, n)
|
| 247 |
+
|
| 248 |
+
return np.array([
|
| 249 |
+
log_v,
|
| 250 |
+
digits,
|
| 251 |
+
float(n),
|
| 252 |
+
value / (n + 1.0),
|
| 253 |
+
1.0 if abs(value - round(value)) < 1e-9 else 0.0,
|
| 254 |
+
1.0 if abs(value) < 1.0 else 0.0,
|
| 255 |
+
in_range,
|
| 256 |
+
cluster_dist,
|
| 257 |
+
], dtype=np.float64)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def token_features(count: int, text: str) -> np.ndarray:
|
| 261 |
+
"""Observables about a bare token count.
|
| 262 |
+
|
| 263 |
+
The residual features are exact arithmetic. The template offset
|
| 264 |
+
is a fixed integer, so the model learns a threshold lookup and
|
| 265 |
+
reports 100% accuracy. This is not a bug — it is a finding. The
|
| 266 |
+
token side is arithmetic dressed as learning.
|
| 267 |
+
"""
|
| 268 |
+
chars = len(text)
|
| 269 |
+
words = len(text.split())
|
| 270 |
+
local = bpe_like(text)
|
| 271 |
+
return np.array([
|
| 272 |
+
float(count),
|
| 273 |
+
count / max(1, chars),
|
| 274 |
+
count / max(1, words),
|
| 275 |
+
float(count - local),
|
| 276 |
+
float(count - local - 11),
|
| 277 |
+
float(count - local - 7),
|
| 278 |
+
punct_ratio(text),
|
| 279 |
+
digit_ratio(text),
|
| 280 |
+
float(chars),
|
| 281 |
+
float(words),
|
| 282 |
+
float(local),
|
| 283 |
+
float(count % 8),
|
| 284 |
+
], dtype=np.float64)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# =====================================================================
|
| 288 |
+
# §5 Data generation
|
| 289 |
+
# =====================================================================
|
| 290 |
+
|
| 291 |
+
NUM_CLASSES = ("converged", "unconverged_short", "unconverged_long")
|
| 292 |
+
TOK_CLASSES = ("bpe", "bpe+chatml", "bpe+llama2")
|
| 293 |
+
|
| 294 |
+
# Bounds of the training distribution for n. Any input outside this
|
| 295 |
+
# range is flagged OOD by reconstruct_numeric.
|
| 296 |
+
N_TRAIN_RANGE = (10, 40)
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def gen_numeric_examples(n_examples: int, seed: int = 0):
|
| 300 |
+
"""Generate (features, label) pairs for the numeric task.
|
| 301 |
+
|
| 302 |
+
The label generator *knows* max_iter and tol. The feature
|
| 303 |
+
extractor does not. This is the honest setting: the config that
|
| 304 |
+
produced the value is not recoverable from the value.
|
| 305 |
+
"""
|
| 306 |
+
rng = random.Random(seed)
|
| 307 |
+
X, y = [], []
|
| 308 |
+
while len(X) < n_examples:
|
| 309 |
+
n = rng.choice([10, 20, 30, 40])
|
| 310 |
+
max_iter = rng.choice([5, 50, 200, 2000, 20000])
|
| 311 |
+
tol = rng.choice([1e-3, 1e-6, 1e-10])
|
| 312 |
+
A = build_symmetric(n, rng)
|
| 313 |
+
eigs, info = jacobi_eigen(A, max_iter, tol)
|
| 314 |
+
value = participation_ratio(eigs)
|
| 315 |
+
|
| 316 |
+
if info["converged"]:
|
| 317 |
+
label = 0
|
| 318 |
+
elif max_iter <= 200:
|
| 319 |
+
label = 1
|
| 320 |
+
else:
|
| 321 |
+
label = 2
|
| 322 |
+
|
| 323 |
+
X.append(numeric_features(value, n))
|
| 324 |
+
y.append(label)
|
| 325 |
+
return np.stack(X), np.array(y, dtype=np.int64)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
_TOK_SAMPLES = [
|
| 329 |
+
"What is 2+2?",
|
| 330 |
+
"Translate 'hello' to Spanish.",
|
| 331 |
+
"Explain gradient descent.",
|
| 332 |
+
"How do I make sourdough bread?",
|
| 333 |
+
"Please prove step by step that the halting problem is "
|
| 334 |
+
"undecidable and analyze the consequences for formal verification.",
|
| 335 |
+
"Derive the Euler-Lagrange equation from the principle of "
|
| 336 |
+
"stationary action.",
|
| 337 |
+
"Summarize the Fano plane in one sentence.",
|
| 338 |
+
"What is the participation ratio?",
|
| 339 |
+
"Give me a synonym for 'happy'.",
|
| 340 |
+
"Write a haiku about autumn.",
|
| 341 |
+
]
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def gen_token_examples(n_examples: int, seed: int = 0):
|
| 345 |
+
rng = random.Random(seed)
|
| 346 |
+
X, y = [], []
|
| 347 |
+
while len(X) < n_examples:
|
| 348 |
+
text = rng.choice(_TOK_SAMPLES)
|
| 349 |
+
template = rng.choice(["bpe", "bpe+chatml", "bpe+llama2"])
|
| 350 |
+
offset = {"bpe": 0, "bpe+chatml": 11, "bpe+llama2": 7}[template]
|
| 351 |
+
count = bpe_like(text) + offset
|
| 352 |
+
label = {"bpe": 0, "bpe+chatml": 1, "bpe+llama2": 2}[template]
|
| 353 |
+
X.append(token_features(count, text))
|
| 354 |
+
y.append(label)
|
| 355 |
+
return np.stack(X), np.array(y, dtype=np.int64)
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
# =====================================================================
|
| 359 |
+
# §6 Normalization
|
| 360 |
+
# =====================================================================
|
| 361 |
+
|
| 362 |
+
@dataclass
|
| 363 |
+
class Normalizer:
|
| 364 |
+
mu: np.ndarray
|
| 365 |
+
sigma: np.ndarray
|
| 366 |
+
|
| 367 |
+
@classmethod
|
| 368 |
+
def fit(cls, X: np.ndarray) -> "Normalizer":
|
| 369 |
+
mu = X.mean(axis=0)
|
| 370 |
+
sigma = X.std(axis=0)
|
| 371 |
+
sigma[sigma < 1e-9] = 1.0
|
| 372 |
+
return cls(mu, sigma)
|
| 373 |
+
|
| 374 |
+
def __call__(self, X: np.ndarray) -> np.ndarray:
|
| 375 |
+
return (X - self.mu) / self.sigma
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
# =====================================================================
|
| 379 |
+
# §7 MLP (numpy only)
|
| 380 |
+
# =====================================================================
|
| 381 |
+
|
| 382 |
+
class MLP:
|
| 383 |
+
"""Two-layer MLP with tanh hidden activation.
|
| 384 |
+
|
| 385 |
+
Sizes are deliberately tiny: 8 -> 32 -> 3. With 8 inputs that is
|
| 386 |
+
8*32 + 32 + 32*3 + 3 = 387 parameters. Trainable in numpy in
|
| 387 |
+
under a second on CPU.
|
| 388 |
+
"""
|
| 389 |
+
|
| 390 |
+
def __init__(self, feat_dim: int, hidden: int, n_classes: int,
|
| 391 |
+
seed: int = 0):
|
| 392 |
+
rng = np.random.default_rng(seed)
|
| 393 |
+
self.W1 = rng.standard_normal((feat_dim, hidden)) * 0.1
|
| 394 |
+
self.b1 = np.zeros(hidden)
|
| 395 |
+
self.W2 = rng.standard_normal((hidden, n_classes)) * 0.1
|
| 396 |
+
self.b2 = np.zeros(n_classes)
|
| 397 |
+
self.n_classes = n_classes
|
| 398 |
+
self.temperature = 1.0
|
| 399 |
+
|
| 400 |
+
def params(self):
|
| 401 |
+
return [self.W1, self.b1, self.W2, self.b2]
|
| 402 |
+
|
| 403 |
+
def forward(self, X: np.ndarray):
|
| 404 |
+
h = np.tanh(X @ self.W1 + self.b1)
|
| 405 |
+
logits = h @ self.W2 + self.b2
|
| 406 |
+
return h, logits
|
| 407 |
+
|
| 408 |
+
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
| 409 |
+
_, logits = self.forward(X)
|
| 410 |
+
z = logits / max(1e-6, self.temperature)
|
| 411 |
+
z = z - z.max(axis=1, keepdims=True)
|
| 412 |
+
e = np.exp(z)
|
| 413 |
+
return e / e.sum(axis=1, keepdims=True)
|
| 414 |
+
|
| 415 |
+
def predict(self, X: np.ndarray) -> np.ndarray:
|
| 416 |
+
return self.predict_proba(X).argmax(axis=1)
|
| 417 |
+
|
| 418 |
+
def loss_and_grad(self, X: np.ndarray, y: np.ndarray):
|
| 419 |
+
h, logits = self.forward(X)
|
| 420 |
+
n = len(y)
|
| 421 |
+
logits = logits - logits.max(axis=1, keepdims=True)
|
| 422 |
+
e = np.exp(logits)
|
| 423 |
+
p = e / e.sum(axis=1, keepdims=True)
|
| 424 |
+
|
| 425 |
+
loss = -np.log(p[np.arange(n), y] + 1e-12).mean()
|
| 426 |
+
|
| 427 |
+
dz = p.copy()
|
| 428 |
+
dz[np.arange(n), y] -= 1.0
|
| 429 |
+
dz /= n
|
| 430 |
+
|
| 431 |
+
dW2 = h.T @ dz
|
| 432 |
+
db2 = dz.sum(axis=0)
|
| 433 |
+
dh = dz @ self.W2.T
|
| 434 |
+
dpre = dh * (1.0 - h * h)
|
| 435 |
+
dW1 = X.T @ dpre
|
| 436 |
+
db1 = dpre.sum(axis=0)
|
| 437 |
+
return loss, [dW1, db1, dW2, db2]
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def train_mlp(model: MLP, X: np.ndarray, y: np.ndarray,
|
| 441 |
+
epochs: int = 200, batch: int = 64,
|
| 442 |
+
lr: float = 3e-3, seed: int = 0) -> List[float]:
|
| 443 |
+
rng = np.random.default_rng(seed)
|
| 444 |
+
m = [np.zeros_like(p) for p in model.params()]
|
| 445 |
+
v = [np.zeros_like(p) for p in model.params()]
|
| 446 |
+
t = 0
|
| 447 |
+
b1, b2, eps = 0.9, 0.999, 1e-8
|
| 448 |
+
losses = []
|
| 449 |
+
|
| 450 |
+
n = len(y)
|
| 451 |
+
for _ in range(epochs):
|
| 452 |
+
idx = rng.permutation(n)
|
| 453 |
+
for start in range(0, n, batch):
|
| 454 |
+
sel = idx[start:start + batch]
|
| 455 |
+
loss, grads = model.loss_and_grad(X[sel], y[sel])
|
| 456 |
+
t += 1
|
| 457 |
+
for i, (p, g) in enumerate(zip(model.params(), grads)):
|
| 458 |
+
m[i] = b1 * m[i] + (1 - b1) * g
|
| 459 |
+
v[i] = b2 * v[i] + (1 - b2) * g * g
|
| 460 |
+
mhat = m[i] / (1 - b1 ** t)
|
| 461 |
+
vhat = v[i] / (1 - b2 ** t)
|
| 462 |
+
p -= lr * mhat / (np.sqrt(vhat) + eps)
|
| 463 |
+
losses.append(loss)
|
| 464 |
+
return losses
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
# =====================================================================
|
| 468 |
+
# §8 Calibration
|
| 469 |
+
# =====================================================================
|
| 470 |
+
|
| 471 |
+
def expected_calibration_error(probs: np.ndarray, labels: np.ndarray,
|
| 472 |
+
n_bins: int = 10) -> float:
|
| 473 |
+
"""ECE: weighted |confidence - accuracy| across probability bins."""
|
| 474 |
+
conf = probs.max(axis=1)
|
| 475 |
+
pred = probs.argmax(axis=1)
|
| 476 |
+
correct = (pred == labels).astype(np.float64)
|
| 477 |
+
bins = np.linspace(0.0, 1.0, n_bins + 1)
|
| 478 |
+
total = 0.0
|
| 479 |
+
for lo, hi in zip(bins[:-1], bins[1:]):
|
| 480 |
+
mask = (conf >= lo) & (conf < hi)
|
| 481 |
+
if mask.sum() == 0:
|
| 482 |
+
continue
|
| 483 |
+
avg_conf = conf[mask].mean()
|
| 484 |
+
avg_acc = correct[mask].mean()
|
| 485 |
+
total += mask.sum() * abs(avg_conf - avg_acc)
|
| 486 |
+
return total / len(labels)
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
def fit_temperature(model: MLP, X_val: np.ndarray,
|
| 490 |
+
y_val: np.ndarray) -> float:
|
| 491 |
+
"""Find T > 0 minimizing NLL of softmax(logits / T).
|
| 492 |
+
|
| 493 |
+
A saturated softmax returns p=1.00 on everything, which is a lie.
|
| 494 |
+
Temperature scaling turns that into a calibrated number.
|
| 495 |
+
"""
|
| 496 |
+
_, logits = model.forward(X_val)
|
| 497 |
+
best_T, best_nll = 1.0, float("inf")
|
| 498 |
+
for T in np.linspace(0.5, 10.0, 40):
|
| 499 |
+
z = logits / T
|
| 500 |
+
z = z - z.max(axis=1, keepdims=True)
|
| 501 |
+
e = np.exp(z)
|
| 502 |
+
p = e / e.sum(axis=1, keepdims=True)
|
| 503 |
+
nll = -np.log(p[np.arange(len(y_val)), y_val] + 1e-12).mean()
|
| 504 |
+
if nll < best_nll:
|
| 505 |
+
best_nll, best_T = nll, float(T)
|
| 506 |
+
return best_T
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
# =====================================================================
|
| 510 |
+
# §9 Serializer
|
| 511 |
+
# =====================================================================
|
| 512 |
+
|
| 513 |
+
@dataclass
|
| 514 |
+
class Reconstruction:
|
| 515 |
+
label: str
|
| 516 |
+
confidence: float
|
| 517 |
+
marker: str
|
| 518 |
+
ood: bool = False
|
| 519 |
+
|
| 520 |
+
def tag(self) -> str:
|
| 521 |
+
if self.ood:
|
| 522 |
+
return f"[{self.label}?? OOD p={self.confidence:.2f}]"
|
| 523 |
+
return f"[{self.label}{self.marker} p={self.confidence:.2f}]"
|
| 524 |
+
|
| 525 |
+
def __str__(self) -> str:
|
| 526 |
+
return self.tag()
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
def serialize(label: str, confidence: float,
|
| 530 |
+
ood: bool = False,
|
| 531 |
+
high_conf: float = 0.90) -> Reconstruction:
|
| 532 |
+
marker = " " if confidence >= high_conf else "?"
|
| 533 |
+
return Reconstruction(label=label, confidence=confidence,
|
| 534 |
+
marker=marker, ood=ood)
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def reconstruct_numeric(model: MLP, norm: Normalizer,
|
| 538 |
+
value: float, n: int) -> Reconstruction:
|
| 539 |
+
ood = not (N_TRAIN_RANGE[0] <= n <= N_TRAIN_RANGE[1])
|
| 540 |
+
feats = numeric_features(value, n)
|
| 541 |
+
probs = model.predict_proba(norm(feats[None, :]))[0]
|
| 542 |
+
k = int(probs.argmax())
|
| 543 |
+
return serialize(NUM_CLASSES[k], float(probs[k]), ood=ood)
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
def reconstruct_token(model: MLP, norm: Normalizer,
|
| 547 |
+
count: int, text: str) -> Reconstruction:
|
| 548 |
+
feats = token_features(count, text)
|
| 549 |
+
probs = model.predict_proba(norm(feats[None, :]))[0]
|
| 550 |
+
k = int(probs.argmax())
|
| 551 |
+
return serialize(TOK_CLASSES[k], float(probs[k]))
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
# =====================================================================
|
| 555 |
+
# §10 Self-test
|
| 556 |
+
# =====================================================================
|
| 557 |
+
|
| 558 |
+
def self_test(verbose: bool = True) -> Tuple[int, int]:
|
| 559 |
+
checks = []
|
| 560 |
+
|
| 561 |
+
# Feature shapes are stable.
|
| 562 |
+
f = numeric_features(3.14, 20)
|
| 563 |
+
checks.append(("numeric features: 8 dims", f.shape == (8,)))
|
| 564 |
+
checks.append(("numeric features: finite", bool(np.all(np.isfinite(f)))))
|
| 565 |
+
|
| 566 |
+
t = token_features(54, "hello world")
|
| 567 |
+
checks.append(("token features: 12 dims", t.shape == (12,)))
|
| 568 |
+
checks.append(("token features: finite", bool(np.all(np.isfinite(t)))))
|
| 569 |
+
|
| 570 |
+
# The residual feature is the whole trick.
|
| 571 |
+
text = "The quick brown fox jumps over the lazy dog."
|
| 572 |
+
local = bpe_like(text)
|
| 573 |
+
f_none = token_features(local, text)
|
| 574 |
+
f_chat = token_features(local + 11, text)
|
| 575 |
+
f_llama = token_features(local + 7, text)
|
| 576 |
+
checks.append(("residual: raw bpe is ~0",
|
| 577 |
+
abs(f_none[3]) < 1e-9))
|
| 578 |
+
checks.append(("residual: chatml is ~11",
|
| 579 |
+
abs(f_chat[3] - 11) < 1e-9))
|
| 580 |
+
checks.append(("residual: llama2 is ~7",
|
| 581 |
+
abs(f_llama[3] - 7) < 1e-9))
|
| 582 |
+
|
| 583 |
+
# Jacobi sanity: short budget does not converge, long does.
|
| 584 |
+
rng = random.Random(0)
|
| 585 |
+
A = build_symmetric(20, rng)
|
| 586 |
+
_, short = jacobi_eigen(A, max_iter=5, tol=1e-10)
|
| 587 |
+
_, long_ = jacobi_eigen(A, max_iter=20000, tol=1e-10)
|
| 588 |
+
checks.append(("jacobi: short does not converge",
|
| 589 |
+
short["converged"] is False))
|
| 590 |
+
checks.append(("jacobi: long converges",
|
| 591 |
+
long_["converged"] is True))
|
| 592 |
+
|
| 593 |
+
# Data generation.
|
| 594 |
+
Xn, yn = gen_numeric_examples(300, seed=1)
|
| 595 |
+
Xt, yt = gen_token_examples(300, seed=1)
|
| 596 |
+
checks.append(("gen_numeric: >=2 classes present",
|
| 597 |
+
len(set(yn.tolist())) >= 2))
|
| 598 |
+
checks.append(("gen_token: 3 classes present",
|
| 599 |
+
set(yt.tolist()) == {0, 1, 2}))
|
| 600 |
+
|
| 601 |
+
# Leakage guard: numeric features must NOT contain max_iter or tol.
|
| 602 |
+
# If accuracy on the honest task exceeds 0.85, a leaking feature
|
| 603 |
+
# has crept back in.
|
| 604 |
+
norm_n = Normalizer.fit(Xn)
|
| 605 |
+
m_n = MLP(Xn.shape[1], 16, 3, seed=2)
|
| 606 |
+
train_mlp(m_n, norm_n(Xn), yn, epochs=80, batch=32, seed=3)
|
| 607 |
+
acc_honest = (m_n.predict(norm_n(Xn)) == yn).mean()
|
| 608 |
+
checks.append(("numeric task: no leaking features (acc < 0.85)",
|
| 609 |
+
acc_honest < 0.85))
|
| 610 |
+
checks.append(("numeric task: better than chance (acc > 0.40)",
|
| 611 |
+
acc_honest > 0.40))
|
| 612 |
+
|
| 613 |
+
# Token task: must be near-perfect, because it is arithmetic.
|
| 614 |
+
norm_t = Normalizer.fit(Xt)
|
| 615 |
+
m_t = MLP(Xt.shape[1], 16, 3, seed=2)
|
| 616 |
+
train_mlp(m_t, norm_t(Xt), yt, epochs=80, batch=32, seed=3)
|
| 617 |
+
acc_t = (m_t.predict(norm_t(Xt)) == yt).mean()
|
| 618 |
+
checks.append(("token task: near-perfect (acc > 0.95)", acc_t > 0.95))
|
| 619 |
+
|
| 620 |
+
# ECE within range.
|
| 621 |
+
probs = m_t.predict_proba(norm_t(Xt))
|
| 622 |
+
e = expected_calibration_error(probs, yt)
|
| 623 |
+
checks.append(("ECE is a probability", 0.0 <= e <= 1.0))
|
| 624 |
+
|
| 625 |
+
# Serializer marks high confidence without '?'.
|
| 626 |
+
r_hi = serialize("bpe+chatml", 0.95)
|
| 627 |
+
r_lo = serialize("bpe+chatml", 0.60)
|
| 628 |
+
r_ood = serialize("converged", 0.99, ood=True)
|
| 629 |
+
checks.append(("serialize: high conf no '?'", r_hi.marker == " "))
|
| 630 |
+
checks.append(("serialize: low conf has '?'", r_lo.marker == "?"))
|
| 631 |
+
checks.append(("serialize: OOD emits '??'",
|
| 632 |
+
"??" in r_ood.tag() and "OOD" in r_ood.tag()))
|
| 633 |
+
|
| 634 |
+
# Temperature scaling is a positive finite number.
|
| 635 |
+
T = fit_temperature(m_t, norm_t(Xt), yt)
|
| 636 |
+
checks.append(("temperature fit: positive finite", 0.5 <= T <= 10.0))
|
| 637 |
+
|
| 638 |
+
passed = sum(1 for _, ok in checks if ok)
|
| 639 |
+
if verbose:
|
| 640 |
+
print()
|
| 641 |
+
print("=" * 74)
|
| 642 |
+
print("SELF-TEST")
|
| 643 |
+
print("=" * 74)
|
| 644 |
+
for name, ok in checks:
|
| 645 |
+
mark = "PASS" if ok else "FAIL"
|
| 646 |
+
print(f" [{mark}] {name}")
|
| 647 |
+
print()
|
| 648 |
+
print(f" {passed}/{len(checks)} correct")
|
| 649 |
+
return passed, len(checks)
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
# =====================================================================
|
| 653 |
+
# §11 Demo
|
| 654 |
+
# =====================================================================
|
| 655 |
+
|
| 656 |
+
def _section(title: str, width: int = 74) -> None:
|
| 657 |
+
print()
|
| 658 |
+
print("=" * width)
|
| 659 |
+
print(title)
|
| 660 |
+
print("=" * width)
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
DEMO_NUMERIC = [
|
| 664 |
+
# (value, n, human note) — note reflects the class boundaries
|
| 665 |
+
# actually used in gen_numeric_examples: converged / short / long
|
| 666 |
+
(5.9971, 20, "long budget, tight tol"),
|
| 667 |
+
(3.1400, 20, "5 iterations"),
|
| 668 |
+
(8.9539, 40, "budget 500 for n=40"),
|
| 669 |
+
(1.7109, 20, "loose tol, long budget"),
|
| 670 |
+
(2.0000, 10, "5 iterations"),
|
| 671 |
+
(4.5000, 30, "tight tol, budget 200"),
|
| 672 |
+
(97.705, 448, "OUT OF DISTRIBUTION (n=448)"),
|
| 673 |
+
]
|
| 674 |
+
|
| 675 |
+
DEMO_TOKENS = [
|
| 676 |
+
("What is 2+2?", 5, "raw bpe count for a short prompt"),
|
| 677 |
+
("What is 2+2?", 16, "same text, +11 -> chatml"),
|
| 678 |
+
("What is 2+2?", 12, "same text, +7 -> llama2"),
|
| 679 |
+
]
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
def demo() -> None:
|
| 683 |
+
print()
|
| 684 |
+
print("=" * 74)
|
| 685 |
+
print("PROVENANCE RECONSTRUCT — infer the encoding a bare value lost")
|
| 686 |
+
print("=" * 74)
|
| 687 |
+
|
| 688 |
+
passed, total = self_test(verbose=True)
|
| 689 |
+
if passed < total:
|
| 690 |
+
print("\n self-test failed; continuing anyway for inspection.")
|
| 691 |
+
|
| 692 |
+
_section("PART 0 Train the numeric reconstructor")
|
| 693 |
+
print(" Features deliberately EXCLUDE max_iter and tol.")
|
| 694 |
+
print(" Those are solver config, not observable at reconstruction time.")
|
| 695 |
+
print(" If they were included, accuracy would be ~99% by leakage.")
|
| 696 |
+
print()
|
| 697 |
+
Xn_tr, yn_tr = gen_numeric_examples(2000, seed=10)
|
| 698 |
+
Xn_te, yn_te = gen_numeric_examples(400, seed=11)
|
| 699 |
+
norm_n = Normalizer.fit(Xn_tr)
|
| 700 |
+
model_n = MLP(Xn_tr.shape[1], 32, 3, seed=20)
|
| 701 |
+
train_mlp(model_n, norm_n(Xn_tr), yn_tr, epochs=250, batch=64, seed=21)
|
| 702 |
+
model_n.temperature = fit_temperature(model_n, norm_n(Xn_te), yn_te)
|
| 703 |
+
|
| 704 |
+
acc_n = (model_n.predict(norm_n(Xn_te)) == yn_te).mean()
|
| 705 |
+
probs_n = model_n.predict_proba(norm_n(Xn_te))
|
| 706 |
+
ece_n = expected_calibration_error(probs_n, yn_te)
|
| 707 |
+
n_params = sum(p.size for p in model_n.params())
|
| 708 |
+
|
| 709 |
+
# Majority-class baseline.
|
| 710 |
+
counts = np.bincount(yn_tr, minlength=3)
|
| 711 |
+
baseline = counts.max() / counts.sum()
|
| 712 |
+
|
| 713 |
+
print(f" training examples : {len(yn_tr)}")
|
| 714 |
+
print(f" test examples : {len(yn_te)}")
|
| 715 |
+
print(f" parameters : {n_params}")
|
| 716 |
+
print(f" majority baseline : {baseline * 100:.1f}%")
|
| 717 |
+
print(f" test accuracy : {acc_n * 100:.1f}%")
|
| 718 |
+
print(f" temperature : {model_n.temperature:.2f}")
|
| 719 |
+
print(f" expected calib err : {ece_n:.3f}")
|
| 720 |
+
print(f" classes : {', '.join(NUM_CLASSES)}")
|
| 721 |
+
print()
|
| 722 |
+
if acc_n - baseline < 0.15:
|
| 723 |
+
print(" FINDING: this is a negative result. A bare value + matrix")
|
| 724 |
+
print(" size carries little recoverable convergence information.")
|
| 725 |
+
print(" The residual and iteration count are the only reliable")
|
| 726 |
+
print(" signals, and they were lost with the provenance.")
|
| 727 |
+
else:
|
| 728 |
+
print(" Note: accuracy is meaningfully above baseline. Check that")
|
| 729 |
+
print(" no leaking feature was reintroduced.")
|
| 730 |
+
|
| 731 |
+
_section("PART 1 Train the token reconstructor")
|
| 732 |
+
Xt_tr, yt_tr = gen_token_examples(2000, seed=10)
|
| 733 |
+
Xt_te, yt_te = gen_token_examples(400, seed=11)
|
| 734 |
+
norm_t = Normalizer.fit(Xt_tr)
|
| 735 |
+
model_t = MLP(Xt_tr.shape[1], 32, 3, seed=20)
|
| 736 |
+
train_mlp(model_t, norm_t(Xt_tr), yt_tr, epochs=250, batch=64, seed=21)
|
| 737 |
+
model_t.temperature = fit_temperature(model_t, norm_t(Xt_te), yt_te)
|
| 738 |
+
|
| 739 |
+
acc_t = (model_t.predict(norm_t(Xt_te)) == yt_te).mean()
|
| 740 |
+
probs_t = model_t.predict_proba(norm_t(Xt_te))
|
| 741 |
+
ece_t = expected_calibration_error(probs_t, yt_te)
|
| 742 |
+
n_params_t = sum(p.size for p in model_t.params())
|
| 743 |
+
|
| 744 |
+
print(f" training examples : {len(yt_tr)}")
|
| 745 |
+
print(f" test examples : {len(yt_te)}")
|
| 746 |
+
print(f" parameters : {n_params_t}")
|
| 747 |
+
print(f" test accuracy : {acc_t * 100:.1f}%")
|
| 748 |
+
print(f" temperature : {model_t.temperature:.2f}")
|
| 749 |
+
print(f" expected calib err : {ece_t:.3f}")
|
| 750 |
+
print(f" classes : {', '.join(TOK_CLASSES)}")
|
| 751 |
+
print()
|
| 752 |
+
print(" FINDING: this is a positive result, but a trivial one. The")
|
| 753 |
+
print(" residual (count - bpe_like) equals the template offset exactly.")
|
| 754 |
+
print(" The model learns a threshold lookup. It is arithmetic dressed")
|
| 755 |
+
print(" as learning; the model is packaging, not magic.")
|
| 756 |
+
|
| 757 |
+
_section("PART 2 Reconstruct provenance for bare numerical outputs")
|
| 758 |
+
print(f" {'value':>10} {'n':>4} {'note':<36} reconstruction")
|
| 759 |
+
print(" " + "-" * 80)
|
| 760 |
+
for value, n, note in DEMO_NUMERIC:
|
| 761 |
+
r = reconstruct_numeric(model_n, norm_n, value, n)
|
| 762 |
+
print(f" {value:>10.4f} {n:>4} {note:<36} {r.tag()}")
|
| 763 |
+
|
| 764 |
+
_section("PART 3 Reconstruct provenance for bare token counts")
|
| 765 |
+
print(f" {'text':<24} {'count':>5} {'note':<34} reconstruction")
|
| 766 |
+
print(" " + "-" * 84)
|
| 767 |
+
for text, count, note in DEMO_TOKENS:
|
| 768 |
+
r = reconstruct_token(model_t, norm_t, count, text)
|
| 769 |
+
display = text[:21] + "..." if len(text) > 21 else text
|
| 770 |
+
print(f" {display:<24} {count:>5} {note:<34} {r.tag()}")
|
| 771 |
+
|
| 772 |
+
_section("PART 4 The residual trick, isolated")
|
| 773 |
+
print(" Subtract a free local estimate from the observed count.")
|
| 774 |
+
print(" The residual lands on 0, +7, or +11 -- the template offset.")
|
| 775 |
+
print(" This is why the token task is trivially learnable.")
|
| 776 |
+
print()
|
| 777 |
+
text = "What is 2+2?"
|
| 778 |
+
local = bpe_like(text)
|
| 779 |
+
print(f" text : {text!r}")
|
| 780 |
+
print(f" local bpe_like(text) : {local}")
|
| 781 |
+
print()
|
| 782 |
+
print(f" {'observed count':>14} {'residual':>9} inferred template")
|
| 783 |
+
print(" " + "-" * 50)
|
| 784 |
+
for observed, template in [(local, "none"),
|
| 785 |
+
(local + 7, "llama2"),
|
| 786 |
+
(local + 11, "chatml")]:
|
| 787 |
+
r = reconstruct_token(model_t, norm_t, observed, text)
|
| 788 |
+
print(f" {observed:>14} {observed - local:>+9} "
|
| 789 |
+
f"{r.tag()} (truth: {template})")
|
| 790 |
+
|
| 791 |
+
_section("PART 5 Honesty: observed, inferred, OOD")
|
| 792 |
+
print(" numerical_provenance.py emits OBSERVED provenance:")
|
| 793 |
+
print(f" PValue(97.705, UNCONVERGED, via='jacobi_short')")
|
| 794 |
+
print(f" report -> <refused: 'jacobi_short' did not converge; "
|
| 795 |
+
f"residual=8.47, tol=1e-10>")
|
| 796 |
+
print()
|
| 797 |
+
print(" token_provenance.py emits OBSERVED provenance:")
|
| 798 |
+
print(f" TCount(54, ok, via='toy-bpe') encoding=toy-bpe+chatml(+11)")
|
| 799 |
+
print(f" report -> 54 [toy-bpe+chatml(+11)]")
|
| 800 |
+
print()
|
| 801 |
+
print(" provenance_reconstruct.py emits INFERRED provenance:")
|
| 802 |
+
r_in = reconstruct_numeric(model_n, norm_n, 5.9971, 20)
|
| 803 |
+
print(f" 5.9971, n=20 -> {r_in.tag()}")
|
| 804 |
+
print()
|
| 805 |
+
print(" ...and OOD provenance for inputs outside the training range:")
|
| 806 |
+
r_ood = reconstruct_numeric(model_n, norm_n, 97.705, 448)
|
| 807 |
+
print(f" 97.705, n=448 -> {r_ood.tag()}")
|
| 808 |
+
print()
|
| 809 |
+
print(" A downstream consumer can require OBSERVED provenance and")
|
| 810 |
+
print(" refuse INFERRED provenance. The markers:")
|
| 811 |
+
print(" [LABEL p=0.95] high-confidence inferred")
|
| 812 |
+
print(" [LABEL? p=0.61] low-confidence inferred")
|
| 813 |
+
print(" [LABEL?? OOD p=1.00] out of distribution; p is meaningless")
|
| 814 |
+
|
| 815 |
+
_section("PART 6 What this file demonstrates")
|
| 816 |
+
print("""
|
| 817 |
+
Positive result (token side):
|
| 818 |
+
The residual against a free local estimator equals the template
|
| 819 |
+
offset exactly. A tiny model learns the classification at ~100%
|
| 820 |
+
because the task is arithmetic, not inference.
|
| 821 |
+
|
| 822 |
+
Negative result (numeric side):
|
| 823 |
+
A bare value plus its matrix size carries little recoverable
|
| 824 |
+
convergence information. Accuracy lands near the majority-class
|
| 825 |
+
baseline. The residual and iteration count are the only reliable
|
| 826 |
+
signals, and they were lost with the provenance.
|
| 827 |
+
|
| 828 |
+
The value of the tool is therefore not its accuracy on the numeric
|
| 829 |
+
side. It is its CALIBRATION (temperature-scaled confidence) and its
|
| 830 |
+
OOD MARKER (the double '??' plus 'OOD' when the input is outside the
|
| 831 |
+
training range). A reconstructor that says "[? p=0.61]" is more
|
| 832 |
+
useful than one that lies at p=1.00.
|
| 833 |
+
|
| 834 |
+
The family:
|
| 835 |
+
numerical_provenance.py -> PValue (observed)
|
| 836 |
+
token_provenance.py -> TCount (observed)
|
| 837 |
+
provenance_reconstruct.py -> tag (inferred, calibrated, OOD-aware)
|
| 838 |
+
""")
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
# =====================================================================
|
| 842 |
+
# §12 Entry point
|
| 843 |
+
# =====================================================================
|
| 844 |
+
|
| 845 |
+
if __name__ == "__main__":
|
| 846 |
+
demo()
|