zeechimp commited on
Commit
0c9939a
·
verified ·
1 Parent(s): 12c74e3

Upload provenance_reconstruct.py

Browse files
Files changed (1) hide show
  1. 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()