betterwithage commited on
Commit
435995b
·
verified ·
1 Parent(s): cfd94cd

chore(sync): mirror backend .py + Dockerfile to Space (hf-sync-backend)

Browse files

Automated backend sync from szl-holdings/a11oy main via hf-sync-backend.
Updated (differed from the Space): Dockerfile, serve.py, szl_governed_ipinn.py, szl_pinn_inverse.py
Deleted (gone from the repo + Dockerfile COPY set): (none)

Keeps the Space-built backend (serve.py + the Dockerfile-COPY'd .py
modules) identical to GitHub main so the Space never rebuilds from a
stale backend, new endpoints don't 404 there, and orphaned modules
removed from the repo don't linger in the Space tree.

Files changed (4) hide show
  1. Dockerfile +4 -0
  2. serve.py +22 -0
  3. szl_governed_ipinn.py +412 -0
  4. szl_pinn_inverse.py +578 -0
Dockerfile CHANGED
@@ -321,6 +321,10 @@ COPY a11oy_uds_portability_nav.py ./
321
  # module honestly serves a SAMPLE certificate until Forge writes real ones on the box.
322
  COPY szl_pinn_bounds.py ./
323
  COPY physical_bounds_certificate.json agentic_decision_trail.json physical_bounds_certificate.dsse.json ./
 
 
 
 
324
  # PNT / quantum-sensing mesh (pure-stdlib closed-form web path; serves /api/a11oy/v1/pnt/*).
325
  # szl_pnt_mesh.py loads the 4 engine modules dynamically via importlib, so ALL FIVE MUST be
326
  # COPY'd or serve.py's guarded import falls back to a stub (merged-but-not-live) in the HF
 
321
  # module honestly serves a SAMPLE certificate until Forge writes real ones on the box.
322
  COPY szl_pinn_bounds.py ./
323
  COPY physical_bounds_certificate.json agentic_decision_trail.json physical_bounds_certificate.dsse.json ./
324
+ # Governed Inverse-PINN engine (governed-inverse-pinn) — adds POST /api/a11oy/v1/pinn/identify
325
+ # (+ GET demo, GET /pinn/health). Both modules MUST be COPY'd or serve.py's guarded import
326
+ # falls back (merged-but-not-live) in the HF image. NumPy-only (no torch/DeepXDE/scipy added).
327
+ COPY szl_pinn_inverse.py szl_governed_ipinn.py ./
328
  # PNT / quantum-sensing mesh (pure-stdlib closed-form web path; serves /api/a11oy/v1/pnt/*).
329
  # szl_pnt_mesh.py loads the 4 engine modules dynamically via importlib, so ALL FIVE MUST be
330
  # COPY'd or serve.py's guarded import falls back to a stub (merged-but-not-live) in the HF
serve.py CHANGED
@@ -643,6 +643,28 @@ try:
643
  except Exception as _szl_pinn_e: # pragma: no cover
644
  print(f"[a11oy] Agentic-PINN + physical-bounds mesh NOT registered: {_szl_pinn_e!r}", file=__import__("sys").stderr)
645
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  # ── Compliance crosswalk MESH (compliance-mesh) — closes the audited gap where the
647
  # doctrine-v11 → NIST AI RMF / ISO 42001 / EU AI Act crosswalk module existed
648
  # (szl_compliance_mesh.py + compliance_crosswalk.py, REAL honest data with
 
643
  except Exception as _szl_pinn_e: # pragma: no cover
644
  print(f"[a11oy] Agentic-PINN + physical-bounds mesh NOT registered: {_szl_pinn_e!r}", file=__import__("sys").stderr)
645
 
646
+ # ── Governed Inverse-PINN engine (governed-inverse-pinn) — adds the INVERSE
647
+ # discovery surface POST /api/a11oy/v1/pinn/identify (+ GET demo, GET /pinn/health,
648
+ # alias prefix /v1/pinn). Discovers unknown PHYSICAL parameters of an ODE/PDE from
649
+ # data with an HONEST self-doubt gate: a parameter the data cannot identify is
650
+ # labelled RED/UNIDENTIFIABLE and the engine REFUSES to assert a value. NumPy-only
651
+ # (no torch/DeepXDE/scipy): spectral surrogate with exact analytic derivatives,
652
+ # ridge-LS data fit, exact LS for linear params, Adam GD on the physics residual,
653
+ # FIM identifiability, bootstrap 95% CI, three-state GREEN/YELLOW/RED convergence.
654
+ # Values are MODELED (a fit to data, never MEASURED); F19/Bekenstein is a
655
+ # locked-proven inequality APPLIED (not re-claimed); Λ=Conjecture 1 (advisory ≤0.99);
656
+ # DSSE receipt is honest-UNSIGNED until the on-metal cosign key signs it (never faked).
657
+ # Additive, try/except-guarded, registered BEFORE the /api/a11oy/{path:path} Node
658
+ # proxy + SPA catch-all (defined at the file tail) so it wins ordered matching. The
659
+ # guard is HARD: any import/register failure logs and continues — a11oy boots even if
660
+ # this engine is broken, and the engine NEVER raises into app startup.
661
+ try:
662
+ import szl_governed_ipinn as _szl_governed_ipinn
663
+ _szl_ipinn_routes = _szl_governed_ipinn.register(app, ns="a11oy")
664
+ print(f"[a11oy] Governed Inverse-PINN registered: POST /api/a11oy/v1/pinn/identify (+ /pinn/health) {_szl_ipinn_routes}", file=__import__("sys").stderr)
665
+ except Exception as _szl_ipinn_e: # pragma: no cover
666
+ print(f"[a11oy] Governed Inverse-PINN NOT registered (a11oy continues): {_szl_ipinn_e!r}", file=__import__("sys").stderr)
667
+
668
  # ── Compliance crosswalk MESH (compliance-mesh) — closes the audited gap where the
669
  # doctrine-v11 → NIST AI RMF / ISO 42001 / EU AI Act crosswalk module existed
670
  # (szl_compliance_mesh.py + compliance_crosswalk.py, REAL honest data with
szl_governed_ipinn.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # (c) 2026 Lutar, Stephen P. - SZL Holdings - ORCID 0009-0001-0110-4173
4
+ #
5
+ # szl_governed_ipinn.py — Governed wrapper + HTTP surface for the SZL Inverse-PINN
6
+ # engine (szl_pinn_inverse). Taxonomy: services (frontier discovery) + provenance.
7
+ #
8
+ # Doctrine v11 LOCKED 749/14/163 @ c7c0ba17 · Lambda = Conjecture 1 (advisory).
9
+ #
10
+ # WHAT THIS ADDS over the bare engine
11
+ # 1. governed_discover(spec): runs the engine and returns, PER discovered
12
+ # parameter: value, 95% CI, a three-state convergence label (GREEN/YELLOW/
13
+ # RED) with the EXACT numeric criteria, the physics residual, and a
14
+ # Bekenstein/F19 information-cost ratio (PHYSICALLY_PLAUSIBLE / IMPLAUSIBLE).
15
+ # A parameter the data cannot identify is RED/UNIDENTIFIABLE and the engine
16
+ # REFUSES to assert a value for it (value withheld, reason given).
17
+ # 2. A Lambda advisory (Conjecture 1) in [0, 0.99] — NEVER 1.0.
18
+ # 3. A DSSE-signable receipt dict (organ="a11oy-pinn"); signed with the real
19
+ # cosign key when present, otherwise an HONEST UNSIGNED envelope (never a
20
+ # fabricated signature). The ledger write itself is NOT done here — we call
21
+ # record_pinn_receipt(receipt), a no-op-safe hook that Dev C wires to
22
+ # szl_lake_ingest.record_receipt.
23
+ # 4. POST /api/a11oy/v1/pinn/identify — the endpoint. A built-in demo
24
+ # ("demo":"duffing") returns a real GREEN alpha ~ 1.0 out of the box.
25
+ #
26
+ # HONEST LABELS: all discovered values are MODELED (a fit to data), never
27
+ # MEASURED. F19 (Bekenstein bound) is one of the 8 locked-proven inequalities
28
+ # {F1,F4,F7,F11,F12,F18,F19,F22}@c7c0ba17 — a PROVEN inequality, never an
29
+ # assertion; its APPLICATION to an information-cost ratio here is MODELED.
30
+ # The locked-proven count is 8. Lambda = Conjecture 1. No user-visible codenames.
31
+
32
+ import math
33
+ import time
34
+ import json
35
+
36
+ import numpy as np
37
+
38
+ from szl_pinn_inverse import (
39
+ SZLInversePINN, SZLInversePINNTrainer, SZLSpectralSurrogate,
40
+ duffing_residual, integrate_duffing,
41
+ CAUSAL_GREEN, CAUSAL_RED, GRAD_GREEN, KAPPA_IDENT, KAPPA_RED, FISHER_FLOOR,
42
+ MIN_DATA_POINTS,
43
+ )
44
+
45
+ RECEIPT_SCHEMA = "szl.lake.receipt/v1"
46
+ RECEIPT_ORGAN = "a11oy-pinn"
47
+ RECEIPT_PAYLOAD_TYPE = "application/vnd.szl.ipinn+json"
48
+ LOCKED_PROVEN = ("F1", "F4", "F7", "F11", "F12", "F18", "F19", "F22")
49
+ LOCKED_PROVEN_AT = "c7c0ba17"
50
+
51
+ # Built-in, code-safe systems. We NEVER eval user-supplied residual source — a
52
+ # request selects a named built-in physics, or passes data to identify against
53
+ # one. (Security rule: no arbitrary code-as-action on this path.)
54
+ _BUILTIN_SYSTEMS = {
55
+ "duffing": {
56
+ "residual": duffing_residual,
57
+ "unknowns": ["alpha"],
58
+ "linear": ["alpha"],
59
+ "bounds": {"alpha": (-10.0, 10.0)},
60
+ "inits": {"alpha": 0.4},
61
+ "truth": {"alpha": 1.0},
62
+ "desc": "Duffing oscillator m x'' + c x' + delta x + alpha x^3 = F cos(omega t)",
63
+ },
64
+ }
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Bekenstein / F19 information-cost ratio.
69
+ # I_eta = log2(sigma_prior / sigma_posterior) bits of info gained
70
+ # I_max = 2*pi*R*E / (hbar*c) / ln(2) Bekenstein bound (bits)
71
+ # ratio = I_eta / I_max > 1 => IMPLAUSIBLE
72
+ # F19 is the PROVEN Bekenstein inequality (locked-8). The numbers below are a
73
+ # MODELED application with SAMPLE R, E unless the caller supplies real ones.
74
+ # ---------------------------------------------------------------------------
75
+ _HBAR = 1.054571817e-34 # J*s
76
+ _C = 2.99792458e8 # m/s
77
+
78
+
79
+ def bekenstein_ratio(sigma_prior, sigma_posterior, radius_m=1.0, energy_j=1.0):
80
+ sp = max(float(sigma_prior), 1e-30)
81
+ sq = max(float(sigma_posterior), 1e-30)
82
+ info_bits = math.log2(sp / sq) if sp > sq else 0.0
83
+ i_max = (2.0 * math.pi * float(radius_m) * float(energy_j)) / (_HBAR * _C) / math.log(2.0)
84
+ ratio = info_bits / i_max if i_max > 0 else float("inf")
85
+ return {
86
+ "info_bits": info_bits,
87
+ "bekenstein_max_bits": i_max,
88
+ "ratio": ratio,
89
+ "label": "PHYSICALLY_PLAUSIBLE" if ratio <= 1.0 else "PHYSICALLY_IMPLAUSIBLE",
90
+ "radius_m": float(radius_m),
91
+ "energy_j": float(energy_j),
92
+ "basis": ("F19 Bekenstein bound = PROVEN inequality (locked-8 @ %s); "
93
+ "this application is MODELED with SAMPLE R,E unless supplied"
94
+ % LOCKED_PROVEN_AT),
95
+ }
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Lambda advisory (Conjecture 1) — weighted geometric mean of honest factors,
100
+ # HARD-capped at 0.99. Never 1.0, never presented as proven.
101
+ # ---------------------------------------------------------------------------
102
+ def compute_lambda(label, frac_asserted, data_rms, delta_param_rel):
103
+ f_label = {"GREEN": 0.9, "YELLOW": 0.6, "RED": 0.2}.get(label, 0.2)
104
+ f_ident = 0.05 + 0.95 * float(np.clip(frac_asserted, 0.0, 1.0))
105
+ f_data = float(np.clip(math.exp(-5.0 * max(data_rms, 0.0)), 0.05, 1.0))
106
+ f_stab = float(np.clip(1.0 / (1.0 + max(delta_param_rel, 0.0)), 0.05, 1.0))
107
+ geom = (f_label * f_ident * f_data * f_stab) ** 0.25
108
+ return {
109
+ "value": round(min(geom, 0.99), 4),
110
+ "status": "ADVISORY",
111
+ "basis": "Lambda = Conjecture 1 (advisory, capped <= 0.99; NEVER a proof)",
112
+ "factors": {"label": f_label, "identifiable": round(f_ident, 4),
113
+ "data_fit": round(f_data, 4), "stability": round(f_stab, 4)},
114
+ }
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # JSON-safe coercion. Starlette's JSONResponse uses allow_nan=False, so a
119
+ # non-finite float (e.g. kappa(FIM)=inf — the honest non-identifiable signal)
120
+ # would 500 the response. We convert non-finite floats to honest string tokens
121
+ # and numpy scalars to plain Python, so the receipt and the wire agree.
122
+ # ---------------------------------------------------------------------------
123
+ def _json_safe(obj):
124
+ if isinstance(obj, dict):
125
+ return {k: _json_safe(v) for k, v in obj.items()}
126
+ if isinstance(obj, (list, tuple)):
127
+ return [_json_safe(v) for v in obj]
128
+ if isinstance(obj, np.generic):
129
+ obj = obj.item()
130
+ if isinstance(obj, float):
131
+ if math.isinf(obj):
132
+ return "Infinity" if obj > 0 else "-Infinity"
133
+ if math.isnan(obj):
134
+ return "NaN"
135
+ return obj
136
+
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # The no-op-safe ledger hook. Dev C wires this to szl_lake_ingest.record_receipt;
140
+ # until then (and when running standalone) it degrades honestly to not-recorded.
141
+ # ---------------------------------------------------------------------------
142
+ def record_pinn_receipt(receipt):
143
+ """Hook for Dev C. Signature: record_pinn_receipt(receipt: dict) -> dict.
144
+ Attempts an in-process ledger append via szl_lake_ingest.record_receipt with
145
+ organ="a11oy-pinn"; never raises, returns a status dict."""
146
+ try:
147
+ import szl_lake_ingest # type: ignore
148
+ res = szl_lake_ingest.record_receipt(receipt, organ=RECEIPT_ORGAN)
149
+ return {"recorded": True, "backend": "szl_lake_ingest.record_receipt",
150
+ "result": res if isinstance(res, dict) else str(res)}
151
+ except Exception as e: # noqa: BLE001 — honest degrade, never fatal
152
+ return {"recorded": False, "reason": "ledger hook not wired (%r)" % e,
153
+ "note": "Dev C wires record_pinn_receipt -> szl_lake_ingest"}
154
+
155
+
156
+ def build_ipinn_receipt(system, method, params_block, convergence, lambda_adv, sign=True):
157
+ payload = {
158
+ "schema": RECEIPT_SCHEMA,
159
+ "organ": RECEIPT_ORGAN,
160
+ "kind": "inverse_pinn_identify",
161
+ "ts": time.time(),
162
+ "system": system,
163
+ "method": method,
164
+ "label_provenance": "MODELED (fit to data; not MEASURED)",
165
+ "discovered": params_block,
166
+ "convergence": convergence,
167
+ "lambda_advisory": lambda_adv,
168
+ "doctrine": {
169
+ "locked_proven_count": 8,
170
+ "locked_proven": list(LOCKED_PROVEN),
171
+ "locked_at": LOCKED_PROVEN_AT,
172
+ "lambda": "Conjecture 1",
173
+ "f19": "Bekenstein bound = PROVEN inequality (locked-8); application MODELED",
174
+ },
175
+ }
176
+ receipt = {"payload": payload}
177
+ if sign:
178
+ try:
179
+ import szl_dsse # type: ignore
180
+ env = szl_dsse.sign_payload(payload, RECEIPT_PAYLOAD_TYPE)
181
+ receipt["dsse"] = env
182
+ receipt["signed"] = bool(env.get("signatures"))
183
+ except Exception as e: # noqa: BLE001
184
+ receipt["dsse"] = {"signed": False,
185
+ "reason": "szl_dsse unavailable (%r)" % e}
186
+ receipt["signed"] = False
187
+ else:
188
+ receipt["signed"] = False
189
+ return receipt
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # The governed discovery orchestrator.
194
+ # ---------------------------------------------------------------------------
195
+ def _coerce_data(spec):
196
+ """Return (t, y, noise_sigma, system_key, used_demo). Either a built-in demo
197
+ ('demo':'duffing') generating synthetic data, or caller-supplied
198
+ {'data': {'t': [...], 'x': [...]}, 'system': 'duffing'}."""
199
+ demo = spec.get("demo")
200
+ system_key = (spec.get("system") or demo or "duffing")
201
+ if isinstance(system_key, str):
202
+ system_key = system_key.strip().lower()
203
+ if system_key not in _BUILTIN_SYSTEMS:
204
+ raise ValueError("unsupported system %r; supported: %s (or pass demo='duffing')"
205
+ % (system_key, list(_BUILTIN_SYSTEMS)))
206
+ data = spec.get("data")
207
+ if demo or not data:
208
+ opts = spec.get("options") or {}
209
+ n = int(opts.get("n_points", 160))
210
+ n = max(MIN_DATA_POINTS, min(n, 600))
211
+ t1 = float(opts.get("t_max", 10.0))
212
+ t = np.linspace(0.0, t1, n)
213
+ truth = _BUILTIN_SYSTEMS[system_key]["truth"]
214
+ x = integrate_duffing(t, alpha=truth.get("alpha", 1.0))
215
+ noise = float(opts.get("noise", 0.0))
216
+ if noise > 0:
217
+ x = x + np.random.default_rng(0).normal(0.0, noise, size=x.shape)
218
+ return t, x, max(noise, 1e-3), system_key, True
219
+ # caller-supplied data
220
+ t = np.asarray(data["t"], float).reshape(-1)
221
+ y = np.asarray(data.get("x", data.get("y")), float).reshape(-1)
222
+ if t.shape[0] != y.shape[0]:
223
+ raise ValueError("data.t and data.x must have equal length")
224
+ return t, y, 1e-3, system_key, False
225
+
226
+
227
+ def governed_discover(spec):
228
+ spec = dict(spec or {})
229
+ try:
230
+ t, y, noise_sigma, system_key, used_demo = _coerce_data(spec)
231
+ except (ValueError, KeyError, TypeError) as ce:
232
+ return {"ok": False, "error": str(ce), "honesty": _honesty()}
233
+ if t.shape[0] < MIN_DATA_POINTS:
234
+ return {"ok": False,
235
+ "error": "need >= %d data points (got %d)" % (MIN_DATA_POINTS, t.shape[0]),
236
+ "honesty": _honesty()}
237
+ sysdef = _BUILTIN_SYSTEMS[system_key]
238
+ requested = spec.get("unknowns") or list(sysdef["unknowns"])
239
+ # the residual only knows about its own params; unknowns not in the residual
240
+ # (e.g. "ghost") are admitted on purpose so the self-doubt gate can REFUSE them.
241
+ inits = dict(sysdef["inits"])
242
+ bounds = dict(sysdef["bounds"])
243
+ linear = list(sysdef["linear"])
244
+ for nm in requested:
245
+ if nm not in inits:
246
+ inits[nm] = 0.4
247
+ bounds.setdefault(nm, (-10.0, 10.0))
248
+ linear.append(nm) # treat unknowns as linear unless engine proves otherwise
249
+
250
+ opts = spec.get("options") or {}
251
+ n_modes = int(opts.get("n_modes", 24))
252
+ epochs = int(opts.get("epochs", 50))
253
+ restarts = int(opts.get("restarts", 6))
254
+
255
+ surrogate = SZLSpectralSurrogate(n_modes=n_modes, poly_deg=3)
256
+ model = SZLInversePINN(sysdef["residual"], {k: inits[k] for k in requested},
257
+ surrogate=surrogate, param_bounds=bounds,
258
+ linear_params=[k for k in linear if k in requested])
259
+ trainer = SZLInversePINNTrainer(model, t, y, t, noise_sigma=noise_sigma, seed=1)
260
+ try:
261
+ record = trainer.fit(epochs=epochs)
262
+ except ValueError as ve:
263
+ return {"ok": False, "error": str(ve), "honesty": _honesty()}
264
+ results = trainer.param_results(n_restarts=restarts, epochs=max(20, epochs // 2))
265
+
266
+ # prior std per param (for the Bekenstein info-gain): uniform-prior std over bounds.
267
+ discovered = []
268
+ n_asserted = 0
269
+ for pr in results:
270
+ lo, hi = bounds.get(pr.name, (-10.0, 10.0))
271
+ sigma_prior = (hi - lo) / math.sqrt(12.0) if math.isfinite(hi - lo) else 10.0
272
+ sigma_post = pr.std if pr.std > 0 else max(abs(pr.ci_high - pr.ci_low) / 3.92, 1e-6)
273
+ bek = bekenstein_ratio(sigma_prior, sigma_post,
274
+ radius_m=float(opts.get("radius_m", 1.0)),
275
+ energy_j=float(opts.get("energy_j", 1.0)))
276
+ block = {
277
+ "name": pr.name,
278
+ "asserted": pr.asserted,
279
+ "value": (round(pr.value, 6) if pr.asserted else None),
280
+ "ci95": ([round(pr.ci_low, 6), round(pr.ci_high, 6)] if pr.asserted else None),
281
+ "std": round(pr.std, 6),
282
+ "fisher_information": pr.fisher,
283
+ "identifiable": pr.identifiable,
284
+ "convergence_label": record.label if pr.asserted else "RED",
285
+ "bekenstein": bek,
286
+ "label": "MODELED",
287
+ }
288
+ if not pr.asserted:
289
+ if pr.fisher < FISHER_FLOOR:
290
+ why = ("Fisher information %.2e is below the floor %.0e — the data carry "
291
+ "no information about this parameter" % (pr.fisher, FISHER_FLOOR))
292
+ else:
293
+ why = ("the FIM is ill-conditioned (kappa=%.2e >= %.0e) — the parameters "
294
+ "are jointly non-identifiable" % (record.kappa_fim, KAPPA_RED))
295
+ block["refusal"] = ("UNIDENTIFIABLE: %s. The engine REFUSES to assert this "
296
+ "parameter." % why)
297
+ else:
298
+ n_asserted += 1
299
+ discovered.append(block)
300
+
301
+ frac_asserted = n_asserted / max(1, len(results))
302
+ convergence = {
303
+ "label": record.label,
304
+ "criteria": record.criteria,
305
+ "min_causal_weight": round(record.min_causal_weight, 6),
306
+ "grad_norm": record.grad_norm,
307
+ "kappa_fim": record.kappa_fim,
308
+ "residual_rms": round(record.residual_rms, 6),
309
+ "data_rms": round(record.data_rms, 6),
310
+ "epochs_run": record.epochs_run,
311
+ "thresholds": {
312
+ "causal_green": CAUSAL_GREEN, "causal_red": CAUSAL_RED,
313
+ "grad_green": GRAD_GREEN, "kappa_ident": KAPPA_IDENT, "kappa_red": KAPPA_RED,
314
+ },
315
+ }
316
+ convergence = _json_safe(convergence)
317
+ discovered = _json_safe(discovered)
318
+ lambda_adv = compute_lambda(record.label, frac_asserted,
319
+ record.data_rms, record.delta_param_rel)
320
+ method = {
321
+ "engine": "szl_pinn_inverse.SZLInversePINN",
322
+ "surrogate": "spectral basis (Fourier %d modes + poly deg 3), exact analytic "
323
+ "derivatives; NumPy-only (no torch/DeepXDE)" % n_modes,
324
+ "param_solve": "exact least-squares for linear params; Adam GD on physics "
325
+ "residual for nonlinear; FIM identifiability self-doubt gate",
326
+ "data_label": "MODELED synthetic (demo)" if used_demo else "caller-supplied",
327
+ "system": sysdef["desc"],
328
+ }
329
+ receipt = build_ipinn_receipt({"key": system_key, "desc": sysdef["desc"]},
330
+ method, discovered, convergence, lambda_adv,
331
+ sign=bool(opts.get("sign", True)))
332
+ ledger = record_pinn_receipt(receipt)
333
+
334
+ return {
335
+ "ok": True,
336
+ "system": system_key,
337
+ "convergence": convergence,
338
+ "discovered": discovered,
339
+ "lambda_advisory": lambda_adv,
340
+ "receipt": receipt,
341
+ "ledger": ledger,
342
+ "method": method,
343
+ "honesty": _honesty(),
344
+ }
345
+
346
+
347
+ def _honesty():
348
+ return {
349
+ "values": "MODELED (fit to data; not MEASURED)",
350
+ "locked_proven_count": 8,
351
+ "locked_proven": list(LOCKED_PROVEN),
352
+ "lambda": "Conjecture 1 (advisory, <= 0.99)",
353
+ "f19": "Bekenstein bound = PROVEN inequality (locked-8); application MODELED",
354
+ "self_doubt_gate": ("a non-identifiable parameter (Fisher < %.0e or kappa(FIM) "
355
+ ">= %.0e) is labelled RED/UNIDENTIFIABLE and NOT asserted"
356
+ % (FISHER_FLOOR, KAPPA_RED)),
357
+ }
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # HTTP surface — POST /api/a11oy/v1/pinn/identify (+ GET health/info).
362
+ # Registered BEFORE the SPA catch-all (front-inserted by serve.py).
363
+ # ---------------------------------------------------------------------------
364
+ def register(app, ns="a11oy"):
365
+ from fastapi.responses import JSONResponse
366
+ from fastapi import Request
367
+
368
+ async def _identify(request: Request):
369
+ try:
370
+ try:
371
+ spec = await request.json()
372
+ except Exception:
373
+ spec = {}
374
+ if not isinstance(spec, dict):
375
+ spec = {}
376
+ if not spec:
377
+ spec = {"demo": "duffing"}
378
+ out = governed_discover(spec)
379
+ code = 200 if out.get("ok") else 400
380
+ label = out.get("convergence", {}).get("label", "NA")
381
+ return JSONResponse(out, status_code=code,
382
+ headers={"x-szl-pinn-label": str(label),
383
+ "x-szl-organ": RECEIPT_ORGAN})
384
+ except Exception as e: # noqa: BLE001
385
+ return JSONResponse({"ok": False, "error": "%r" % e, "honesty": _honesty()},
386
+ status_code=500)
387
+
388
+ async def _health():
389
+ return JSONResponse({
390
+ "ok": True, "organ": RECEIPT_ORGAN,
391
+ "endpoint": "POST /api/%s/v1/pinn/identify" % ns,
392
+ "supported_systems": list(_BUILTIN_SYSTEMS),
393
+ "demo": "POST {\"demo\":\"duffing\"} -> GREEN alpha ~ 1.0",
394
+ "honesty": _honesty(),
395
+ })
396
+
397
+ prefixes = ["/api/%s/v1/pinn" % ns, "/v1/pinn"]
398
+ routes = []
399
+ for p in prefixes:
400
+ app.add_api_route("%s/identify" % p, _identify, methods=["POST", "GET"],
401
+ include_in_schema=True)
402
+ app.add_api_route("%s/health" % p, _health, methods=["GET"],
403
+ include_in_schema=True)
404
+ routes += ["%s/identify" % p, "%s/health" % p]
405
+ return routes
406
+
407
+
408
+ if __name__ == "__main__":
409
+ out = governed_discover({"demo": "duffing"})
410
+ print(json.dumps({k: out[k] for k in ("ok", "system", "convergence", "discovered",
411
+ "lambda_advisory", "ledger")},
412
+ indent=2, default=float)[:2000])
szl_pinn_inverse.py ADDED
@@ -0,0 +1,578 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # (c) 2026 Lutar, Stephen P. - SZL Holdings - ORCID 0009-0001-0110-4173
4
+ #
5
+ # szl_pinn_inverse.py — SZL Governed Inverse-PINN engine (a11oy frontier).
6
+ # Taxonomy home: services (frontier discovery surface) + provenance (signed receipt).
7
+ #
8
+ # Doctrine v11 LOCKED 749/14/163 @ c7c0ba17 · Lambda = Conjecture 1 (advisory only).
9
+ #
10
+ # WHAT THIS IS
11
+ # A self-contained INVERSE physics-informed solver that discovers unknown
12
+ # PHYSICAL PARAMETERS of an ODE/PDE from data, with an HONEST self-doubt gate:
13
+ # a parameter the data cannot identify is labelled RED / UNIDENTIFIABLE and the
14
+ # engine REFUSES to assert a value for it (Fisher-information gate, below).
15
+ #
16
+ # OWN CODE / PERMISSIVE DEPS ONLY (NumPy BSD-3)
17
+ # No torch, no DeepXDE (LGPL), nothing proprietary. Every equation is
18
+ # RE-IMPLEMENTED from the public literature (per-equation citation map in
19
+ # team/frontier/PINN_BACKEND.md), not copied from any GPL/LGPL package.
20
+ #
21
+ # The surrogate that represents x(t) is a LINEAR spectral basis (Fourier modes
22
+ # + a low-order polynomial trend). A linear basis is the pragmatic, robust
23
+ # choice for an autograd-free NumPy build: its first/second time-derivatives
24
+ # are EXACT and analytic (no fragile finite differencing, no second-order
25
+ # backprop), the data fit is a single regularised least-squares solve (fast,
26
+ # CPU-only, seconds), and the physics residual is then well-conditioned. A
27
+ # tanh-MLP surrogate (SZLPinnNet) with exact analytic input-derivatives is also
28
+ # provided for callers who prefer it, but the governed endpoint defaults to the
29
+ # spectral basis because it is the one that converges reliably on a cpu-basic
30
+ # Space without a heavy autodiff dependency.
31
+ #
32
+ # Parameters that enter the residual LINEARLY (e.g. Duffing alpha) are solved by
33
+ # exact least squares; any others are refined by gradient descent on the
34
+ # physics residual. Identifiability is then checked via the Fisher Information
35
+ # Matrix BEFORE any value is asserted.
36
+ #
37
+ # HONEST LABELS: every numeric result here is MODELED (a fit to data), never
38
+ # MEASURED. The convergence label is GREEN / YELLOW / RED with the EXACT numeric
39
+ # criteria from team/frontier/ARXIV_LEADERS.md — see _classify_convergence().
40
+ # The half-state ("looks done but isn't") is unacceptable.
41
+
42
+ import math
43
+ from dataclasses import dataclass, field
44
+ from typing import Callable, Dict, List, Optional, Sequence, Tuple
45
+
46
+ import numpy as np
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # EXACT convergence thresholds — from ARXIV_LEADERS.md (three-state GREEN/YELLOW/
50
+ # RED rules). These are the contract the front-end + receipt rely on; do NOT
51
+ # loosen them without updating the spec.
52
+ # ---------------------------------------------------------------------------
53
+ CAUSAL_GREEN = 0.99 # min(w_causal) > 0.99 -> temporally converged
54
+ CAUSAL_RED = 0.50 # min(w_causal) <= 0.50 -> diverged
55
+ GRAD_GREEN = 1e-5 # ||grad L_A (params)|| < 1e-5 -> stationary
56
+ KAPPA_IDENT = 1e6 # kappa(FIM) < 1e6 -> identifiable
57
+ KAPPA_RED = 1e8 # kappa(FIM) >= 1e8 -> non-identifiable (RED)
58
+ FISHER_FLOOR = 1e-8 # per-param Fisher information floor (self-doubt gate)
59
+ EPSILON_CAUSAL = 0.01 # epsilon in w_causal = exp(-eps * cumsum r^2)
60
+ MIN_DATA_POINTS = 10 # below this the engine refuses to assert anything
61
+
62
+ __all__ = [
63
+ "SZLSpectralSurrogate",
64
+ "SZLPinnNet",
65
+ "SZLInversePINN",
66
+ "SZLInversePINNTrainer",
67
+ "ConvergenceRecord",
68
+ "ParamResult",
69
+ "duffing_residual",
70
+ "integrate_duffing",
71
+ ]
72
+
73
+
74
+ # ===========================================================================
75
+ # 1a. Spectral-basis surrogate (DEFAULT) — exact analytic time-derivatives.
76
+ # ===========================================================================
77
+ class SZLSpectralSurrogate:
78
+ """x(t) ~ sum_p a_p t^p + sum_k [ b_k sin(w_k tau) + c_k cos(w_k tau) ],
79
+ tau = t - t0, w_k = 2*pi*k / span. A LINEAR-in-coefficients model: the
80
+ design matrices for x, dx/dt, d2x/dt2 are exact and analytic, so the physics
81
+ residual needs no finite differencing of the surrogate. Re-implemented from
82
+ standard Fourier/Chebyshev collocation theory (own code)."""
83
+
84
+ def __init__(self, n_modes: int = 24, poly_deg: int = 3, ridge: float = 1e-6):
85
+ self.K = int(n_modes)
86
+ self.D = int(poly_deg)
87
+ self.ridge = float(ridge)
88
+ self.coef: Optional[np.ndarray] = None
89
+ self.t0 = 0.0
90
+ self.span = 1.0
91
+
92
+ def n_features(self) -> int:
93
+ return (self.D + 1) + 2 * self.K
94
+
95
+ def design(self, t: np.ndarray, order: int = 0) -> np.ndarray:
96
+ t = np.asarray(t, float).reshape(-1)
97
+ tau = t - self.t0
98
+ cols: List[np.ndarray] = []
99
+ for p in range(self.D + 1):
100
+ if order == 0:
101
+ cols.append(t ** p)
102
+ elif order == 1:
103
+ cols.append(p * t ** (p - 1) if p >= 1 else np.zeros_like(t))
104
+ else:
105
+ cols.append(p * (p - 1) * t ** (p - 2) if p >= 2 else np.zeros_like(t))
106
+ for k in range(1, self.K + 1):
107
+ w = 2.0 * math.pi * k / self.span
108
+ if order == 0:
109
+ cols += [np.sin(w * tau), np.cos(w * tau)]
110
+ elif order == 1:
111
+ cols += [w * np.cos(w * tau), -w * np.sin(w * tau)]
112
+ else:
113
+ cols += [-w * w * np.sin(w * tau), -w * w * np.cos(w * tau)]
114
+ return np.stack(cols, axis=1)
115
+
116
+ def fit(self, t: np.ndarray, y: np.ndarray):
117
+ t = np.asarray(t, float).reshape(-1)
118
+ y = np.asarray(y, float).reshape(-1)
119
+ self.t0 = float(t.min())
120
+ self.span = float(t.max() - t.min()) or 1.0
121
+ A = self.design(t, 0)
122
+ G = A.T @ A + self.ridge * np.eye(A.shape[1])
123
+ self.coef = np.linalg.solve(G, A.T @ y)
124
+ return self
125
+
126
+ def derivatives(self, t: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
127
+ if self.coef is None:
128
+ raise RuntimeError("surrogate not fitted")
129
+ x = self.design(t, 0) @ self.coef
130
+ dx = self.design(t, 1) @ self.coef
131
+ ddx = self.design(t, 2) @ self.coef
132
+ return x, dx, ddx
133
+
134
+ def predict(self, t: np.ndarray) -> np.ndarray:
135
+ return self.design(t, 0) @ self.coef
136
+
137
+
138
+ # ===========================================================================
139
+ # 1b. Optional tanh-MLP surrogate — exact analytic input-derivatives.
140
+ # Provided for spec-completeness/generality; the governed endpoint defaults
141
+ # to SZLSpectralSurrogate (more robust autograd-free convergence on CPU).
142
+ # ===========================================================================
143
+ class SZLPinnNet:
144
+ """1-D -> 1-D tanh MLP with EXACT first/second analytic derivatives of the
145
+ output w.r.t. the scalar input, plus manual reverse-mode backprop of the data
146
+ MSE. Re-implemented from first principles (chain rule for tanh layers;
147
+ Rumelhart et al. backprop). NumPy only."""
148
+
149
+ def __init__(self, layers: Sequence[int] = (1, 32, 32, 1), seed: int = 0):
150
+ self.layers = list(layers)
151
+ rng = np.random.default_rng(seed)
152
+ self.W: List[np.ndarray] = []
153
+ self.b: List[np.ndarray] = []
154
+ for nin, nout in zip(self.layers[:-1], self.layers[1:]):
155
+ self.W.append(rng.normal(0.0, math.sqrt(1.0 / nin), size=(nin, nout)))
156
+ self.b.append(np.zeros((1, nout)))
157
+ self.in_mean = 0.0
158
+ self.in_scale = 1.0
159
+ self.out_mean = 0.0
160
+ self.out_scale = 1.0
161
+ self._cache: dict = {}
162
+
163
+ def set_norm(self, t, y):
164
+ self.in_mean = float(np.mean(t)); self.in_scale = float(np.std(t)) or 1.0
165
+ self.out_mean = float(np.mean(y)); self.out_scale = float(np.std(y)) or 1.0
166
+
167
+ def _raw_forward(self, tn: np.ndarray) -> np.ndarray:
168
+ a = tn.reshape(-1, 1)
169
+ zs, acts = [], [a]
170
+ for i, (W, b) in enumerate(zip(self.W, self.b)):
171
+ z = a @ W + b
172
+ zs.append(z)
173
+ a = np.tanh(z) if i < len(self.W) - 1 else z
174
+ acts.append(a)
175
+ self._cache = {"zs": zs, "acts": acts}
176
+ return a.reshape(-1)
177
+
178
+ def predict(self, t: np.ndarray) -> np.ndarray:
179
+ tn = (np.asarray(t, float) - self.in_mean) / self.in_scale
180
+ return self.out_mean + self.out_scale * self._raw_forward(tn)
181
+
182
+ def derivatives(self, t: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
183
+ tn = (np.asarray(t, float) - self.in_mean) / self.in_scale
184
+ a = tn.reshape(-1, 1)
185
+ da = np.ones_like(a)
186
+ dda = np.zeros_like(a)
187
+ for i, (W, b) in enumerate(zip(self.W, self.b)):
188
+ z = a @ W + b
189
+ dz = da @ W
190
+ ddz = dda @ W
191
+ if i < len(self.W) - 1:
192
+ th = np.tanh(z)
193
+ tp = 1.0 - th * th
194
+ tpp = -2.0 * th * tp
195
+ a = th
196
+ dda = tpp * dz * dz + tp * ddz
197
+ da = tp * dz
198
+ else:
199
+ a = z; da = dz; dda = ddz
200
+ s = self.out_scale
201
+ x = self.out_mean + s * a.reshape(-1)
202
+ dx = s * da.reshape(-1) / self.in_scale
203
+ ddx = s * dda.reshape(-1) / (self.in_scale ** 2)
204
+ return x, dx, ddx
205
+
206
+ def data_grads(self, t, y):
207
+ tn = (np.asarray(t, float) - self.in_mean) / self.in_scale
208
+ yn = (np.asarray(y, float) - self.out_mean) / self.out_scale
209
+ x = self._raw_forward(tn)
210
+ n = x.shape[0]
211
+ resid = (x - yn)
212
+ loss = float(0.5 * np.mean(resid * resid))
213
+ g = (resid / n).reshape(-1, 1)
214
+ acts = self._cache["acts"]; zs = self._cache["zs"]
215
+ gW = [None] * len(self.W); gb = [None] * len(self.b)
216
+ delta = g
217
+ for i in reversed(range(len(self.W))):
218
+ gW[i] = acts[i].T @ delta
219
+ gb[i] = delta.sum(axis=0, keepdims=True)
220
+ if i > 0:
221
+ delta = (delta @ self.W[i].T) * (1.0 - np.tanh(zs[i - 1]) ** 2)
222
+ return gW, gb, loss
223
+
224
+ def fit(self, t, y, epochs: int = 1500, lr: float = 5e-3):
225
+ self.set_norm(t, y)
226
+ mW = [np.zeros_like(w) for w in self.W]; vW = [np.zeros_like(w) for w in self.W]
227
+ mb = [np.zeros_like(b) for b in self.b]; vb = [np.zeros_like(b) for b in self.b]
228
+ b1, b2, e = 0.9, 0.999, 1e-8
229
+ for it in range(int(epochs)):
230
+ gW, gb, _ = self.data_grads(t, y)
231
+ i1 = it + 1
232
+ for j in range(len(self.W)):
233
+ mW[j] = b1 * mW[j] + (1 - b1) * gW[j]; vW[j] = b2 * vW[j] + (1 - b2) * gW[j] ** 2
234
+ self.W[j] -= lr * (mW[j] / (1 - b1 ** i1)) / (np.sqrt(vW[j] / (1 - b2 ** i1)) + e)
235
+ mb[j] = b1 * mb[j] + (1 - b1) * gb[j]; vb[j] = b2 * vb[j] + (1 - b2) * gb[j] ** 2
236
+ self.b[j] -= lr * (mb[j] / (1 - b1 ** i1)) / (np.sqrt(vb[j] / (1 - b2 ** i1)) + e)
237
+ return self
238
+
239
+
240
+ # ===========================================================================
241
+ # 2. The inverse model — surrogate + learnable physical parameters + residual.
242
+ # ===========================================================================
243
+ class SZLInversePINN:
244
+ """Bundles a surrogate (spectral by default), a dict of learnable physical
245
+ parameters, and a user-supplied residual callable r = f(t,x,dx,ddx,params)."""
246
+
247
+ def __init__(self, residual_fn: Callable[..., np.ndarray],
248
+ param_inits: Dict[str, float],
249
+ surrogate=None,
250
+ param_bounds: Optional[Dict[str, Tuple[float, float]]] = None,
251
+ linear_params: Optional[Sequence[str]] = None):
252
+ self.surrogate = surrogate if surrogate is not None else SZLSpectralSurrogate()
253
+ self.residual_fn = residual_fn
254
+ self.params: Dict[str, float] = dict(param_inits)
255
+ self.param_bounds = dict(param_bounds or {})
256
+ self.linear_params = list(linear_params or [])
257
+
258
+ def param_values(self) -> Dict[str, float]:
259
+ return dict(self.params)
260
+
261
+ def derivatives(self, t: np.ndarray):
262
+ return self.surrogate.derivatives(t)
263
+
264
+ def residual(self, t: np.ndarray) -> np.ndarray:
265
+ x, dx, ddx = self.surrogate.derivatives(t)
266
+ return np.asarray(self.residual_fn(t, x, dx, ddx, self.params), dtype=float)
267
+
268
+
269
+ # ===========================================================================
270
+ # 3. Records.
271
+ # ===========================================================================
272
+ @dataclass
273
+ class ParamResult:
274
+ name: str
275
+ value: float
276
+ ci_low: float
277
+ ci_high: float
278
+ std: float
279
+ fisher: float
280
+ identifiable: bool
281
+ asserted: bool # False -> engine REFUSES (RED / UNIDENTIFIABLE)
282
+
283
+
284
+ @dataclass
285
+ class ConvergenceRecord:
286
+ label: str # GREEN | YELLOW | RED
287
+ min_causal_weight: float
288
+ grad_norm: float
289
+ kappa_fim: float
290
+ delta_param_rel: float
291
+ residual_rms: float
292
+ data_rms: float
293
+ epochs_run: int
294
+ criteria: Dict[str, str] = field(default_factory=dict)
295
+ note: str = ""
296
+
297
+
298
+ # ===========================================================================
299
+ # 4. The trainer — surrogate fit + param solve (LS for linear, GD for nonlinear)
300
+ # + SA-PINN weights + causal weights + FIM self-doubt gate + ensemble CI.
301
+ # ===========================================================================
302
+ class SZLInversePINNTrainer:
303
+ def __init__(self, model: SZLInversePINN,
304
+ t_data: np.ndarray, y_data: np.ndarray,
305
+ t_colloc: Optional[np.ndarray] = None,
306
+ w_phys: float = 1.0,
307
+ lr_param: float = 5e-2,
308
+ noise_sigma: Optional[float] = None,
309
+ seed: int = 0):
310
+ self.m = model
311
+ self.t_data = np.asarray(t_data, float).reshape(-1)
312
+ self.y_data = np.asarray(y_data, float).reshape(-1)
313
+ self.t_colloc = (np.asarray(t_colloc, float).reshape(-1)
314
+ if t_colloc is not None else self.t_data.copy())
315
+ self.w_phys = float(w_phys)
316
+ self.lr_param = float(lr_param)
317
+ self.noise_sigma = noise_sigma
318
+ self.rng = np.random.default_rng(seed)
319
+ self.sa = np.ones_like(self.t_colloc) # SA-PINN self-adaptive weights
320
+ self.param_history: List[Dict[str, float]] = []
321
+
322
+ # ---- residual on the collocation grid ----
323
+ def _phys_residual(self) -> np.ndarray:
324
+ return self.m.residual(self.t_colloc)
325
+
326
+ # ---- exact least squares for params that enter r LINEARLY ----
327
+ def _ls_solve_linear(self):
328
+ names = list(self.m.linear_params)
329
+ if not names:
330
+ return
331
+ x, dx, ddx = self.m.derivatives(self.t_colloc)
332
+ base = dict(self.m.params)
333
+ G = np.zeros((self.t_colloc.shape[0], len(names)))
334
+ for j, nm in enumerate(names):
335
+ p = dict(base); p[nm] = base[nm] + 1.0
336
+ r1 = np.asarray(self.m.residual_fn(self.t_colloc, x, dx, ddx, p), float)
337
+ p[nm] = base[nm]
338
+ r0 = np.asarray(self.m.residual_fn(self.t_colloc, x, dx, ddx, p), float)
339
+ G[:, j] = r1 - r0
340
+ p0 = dict(base)
341
+ for nm in names:
342
+ p0[nm] = 0.0
343
+ r_at_zero = np.asarray(self.m.residual_fn(self.t_colloc, x, dx, ddx, p0), float)
344
+ w = np.sqrt(self.sa)
345
+ try:
346
+ sol, *_ = np.linalg.lstsq(G * w[:, None], -r_at_zero * w, rcond=None)
347
+ for j, nm in enumerate(names):
348
+ v = float(sol[j])
349
+ lo, hi = self.m.param_bounds.get(nm, (-np.inf, np.inf))
350
+ self.m.params[nm] = float(min(max(v, lo), hi))
351
+ except np.linalg.LinAlgError:
352
+ pass
353
+
354
+ # ---- gradient descent for NONLINEAR params (Adam on physics residual) ----
355
+ def _gd_nonlinear(self, epochs: int):
356
+ names = [k for k in self.m.params if k not in self.m.linear_params]
357
+ if not names:
358
+ return
359
+ mom = {k: 0.0 for k in names}; vel = {k: 0.0 for k in names}
360
+ b1, b2, e = 0.9, 0.999, 1e-8
361
+ for it in range(int(epochs)):
362
+ r = self._phys_residual()
363
+ self.sa = np.clip(self.sa + 0.01 * np.abs(r), 1.0, 50.0)
364
+ x, dx, ddx = self.m.derivatives(self.t_colloc)
365
+ i1 = it + 1
366
+ for nm in names:
367
+ d = max(1e-6, 1e-4 * (abs(self.m.params[nm]) + 1.0))
368
+ p = dict(self.m.params); p[nm] += d
369
+ rp = np.asarray(self.m.residual_fn(self.t_colloc, x, dx, ddx, p), float)
370
+ grad = float(np.mean(self.sa * r * (rp - r) / d)) * self.w_phys
371
+ mom[nm] = b1 * mom[nm] + (1 - b1) * grad
372
+ vel[nm] = b2 * vel[nm] + (1 - b2) * grad * grad
373
+ step = self.lr_param * (mom[nm] / (1 - b1 ** i1)) / (math.sqrt(vel[nm] / (1 - b2 ** i1)) + e)
374
+ v = self.m.params[nm] - step
375
+ lo, hi = self.m.param_bounds.get(nm, (-np.inf, np.inf))
376
+ self.m.params[nm] = float(min(max(v, lo), hi))
377
+ self.param_history.append(dict(self.m.params))
378
+
379
+ # ---- causal temporal weights: w_i = exp(-eps * sum_{k<i} r_k^2) ----
380
+ def _causal_weights(self) -> np.ndarray:
381
+ order = np.argsort(self.t_colloc)
382
+ r2 = self._phys_residual()[order] ** 2
383
+ cum = np.concatenate([[0.0], np.cumsum(r2)[:-1]]) # strictly earlier pts
384
+ w = np.exp(-EPSILON_CAUSAL * cum)
385
+ out = np.empty_like(w)
386
+ out[order] = w
387
+ return out
388
+
389
+ # ---- parameter gradient norm of the physics loss ----
390
+ def _param_grad_norm(self) -> float:
391
+ r = self._phys_residual()
392
+ x, dx, ddx = self.m.derivatives(self.t_colloc)
393
+ gs = []
394
+ for nm in self.m.params:
395
+ d = max(1e-6, 1e-4 * (abs(self.m.params[nm]) + 1.0))
396
+ p = dict(self.m.params); p[nm] += d
397
+ rp = np.asarray(self.m.residual_fn(self.t_colloc, x, dx, ddx, p), float)
398
+ gs.append(float(np.mean(r * (rp - r) / d)) * self.w_phys)
399
+ return float(np.linalg.norm(gs))
400
+
401
+ # ---- FIM identifiability — the SELF-DOUBT GATE ----
402
+ def fisher_information(self) -> Tuple[np.ndarray, np.ndarray, float]:
403
+ """FIM = J^T J / (Nc * sigma^2), J_{i,j} = d r_i / d eta_j at the solution.
404
+ A near-zero column => the data carries no information about that parameter
405
+ => UNIDENTIFIABLE. Returns (FIM, per-param Fisher diag, kappa(FIM))."""
406
+ x, dx, ddx = self.m.derivatives(self.t_colloc)
407
+ names = list(self.m.params)
408
+ nc = self.t_colloc.shape[0]
409
+ J = np.zeros((nc, len(names)))
410
+ for j, nm in enumerate(names):
411
+ d = max(1e-6, 1e-4 * (abs(self.m.params[nm]) + 1.0))
412
+ p = dict(self.m.params); p[nm] += d
413
+ rp = np.asarray(self.m.residual_fn(self.t_colloc, x, dx, ddx, p), float)
414
+ p[nm] = self.m.params[nm] - d
415
+ rm = np.asarray(self.m.residual_fn(self.t_colloc, x, dx, ddx, p), float)
416
+ J[:, j] = (rp - rm) / (2 * d)
417
+ sig2 = (self.noise_sigma ** 2) if self.noise_sigma else max(
418
+ float(np.var(self.m.surrogate.predict(self.t_data) - self.y_data)), 1e-8)
419
+ fim = (J.T @ J) / (nc * sig2)
420
+ diag = np.diag(fim).copy()
421
+ try:
422
+ s = np.linalg.svd(fim, compute_uv=False)
423
+ smax = float(s[0]); smin = float(s[-1])
424
+ kappa = (smax / smin) if smin > 0 else float("inf")
425
+ except np.linalg.LinAlgError:
426
+ kappa = float("inf")
427
+ return fim, diag, kappa
428
+
429
+ # ---- full fit ----
430
+ def fit(self, epochs: int = 600) -> ConvergenceRecord:
431
+ if self.t_data.shape[0] < MIN_DATA_POINTS:
432
+ raise ValueError(
433
+ f"need >= {MIN_DATA_POINTS} data points to assert anything "
434
+ f"(got {self.t_data.shape[0]})")
435
+ self.m.surrogate.fit(self.t_data, self.y_data)
436
+ # nonlinear params first (uses fixed surrogate derivatives), then exact LS
437
+ self._gd_nonlinear(epochs)
438
+ self._ls_solve_linear()
439
+ return self._build_record(epochs)
440
+
441
+ def _delta_param_rel(self, window: int = 20) -> float:
442
+ if len(self.param_history) < window + 1:
443
+ return 0.0 if self.m.linear_params else float("inf")
444
+ recent = self.param_history[-window:]
445
+ rels = []
446
+ for k in self.m.params:
447
+ if k in self.m.linear_params:
448
+ continue
449
+ vals = np.array([h[k] for h in recent])
450
+ denom = max(abs(np.mean(vals)), 1e-9)
451
+ rels.append(float(np.max(np.abs(np.diff(vals))) / denom))
452
+ return float(max(rels)) if rels else 0.0
453
+
454
+ def _build_record(self, epochs: int) -> ConvergenceRecord:
455
+ wc = self._causal_weights()
456
+ min_wc = float(np.min(wc)) if wc.size else 0.0
457
+ gnorm = self._param_grad_norm()
458
+ _, diag, kappa = self.fisher_information()
459
+ dpr = self._delta_param_rel()
460
+ r = self._phys_residual()
461
+ rms = float(np.sqrt(np.mean(r * r)))
462
+ d_rms = float(np.sqrt(np.mean((self.m.surrogate.predict(self.t_data) - self.y_data) ** 2)))
463
+ # self-doubt: any per-param Fisher below the floor forces RED.
464
+ min_fisher = float(np.min(diag)) if diag.size else 0.0
465
+ label, crit = _classify_convergence(min_wc, gnorm, kappa, dpr, min_fisher)
466
+ return ConvergenceRecord(
467
+ label=label, min_causal_weight=min_wc, grad_norm=gnorm,
468
+ kappa_fim=kappa, delta_param_rel=dpr, residual_rms=rms,
469
+ data_rms=d_rms, epochs_run=int(epochs), criteria=crit)
470
+
471
+ # ---- per-parameter governed results (value, CI, identifiability, assert) ----
472
+ def param_results(self, n_restarts: int = 6, epochs: int = 400) -> List[ParamResult]:
473
+ ci = self.ensemble_ci(n_restarts=n_restarts, epochs=epochs)
474
+ _, diag, kappa = self.fisher_information()
475
+ names = list(self.m.params)
476
+ out: List[ParamResult] = []
477
+ for j, nm in enumerate(names):
478
+ mean, std, lo, hi = ci[nm]
479
+ fisher = float(diag[j])
480
+ identifiable = (fisher >= FISHER_FLOOR) and (kappa < KAPPA_RED) and math.isfinite(kappa)
481
+ out.append(ParamResult(
482
+ name=nm, value=float(self.m.params[nm]),
483
+ ci_low=lo, ci_high=hi, std=std, fisher=fisher,
484
+ identifiable=identifiable, asserted=identifiable))
485
+ return out
486
+
487
+ # ---- ensemble CI (E-PINN): bootstrap-resample data -> refit -> resolve ----
488
+ def ensemble_ci(self, n_restarts: int = 6, epochs: int = 400
489
+ ) -> Dict[str, Tuple[float, float, float, float]]:
490
+ names = list(self.m.params)
491
+ samples: Dict[str, List[float]] = {k: [self.m.params[k]] for k in names}
492
+ n = self.t_data.shape[0]
493
+ for s in range(1, max(1, n_restarts)):
494
+ idx = self.rng.integers(0, n, size=n) # bootstrap resample
495
+ mdl = SZLInversePINN(
496
+ self.m.residual_fn,
497
+ {k: (self.m.params[k] if k in self.m.linear_params
498
+ else self.rng.normal(self.m.params[k], 0.25 * abs(self.m.params[k]) + 0.1))
499
+ for k in names},
500
+ surrogate=type(self.m.surrogate)(
501
+ getattr(self.m.surrogate, "K", 24), getattr(self.m.surrogate, "D", 3),
502
+ getattr(self.m.surrogate, "ridge", 1e-6))
503
+ if isinstance(self.m.surrogate, SZLSpectralSurrogate) else SZLSpectralSurrogate(),
504
+ param_bounds=self.m.param_bounds, linear_params=self.m.linear_params)
505
+ tr = SZLInversePINNTrainer(
506
+ mdl, self.t_data[idx], self.y_data[idx], self.t_colloc,
507
+ self.w_phys, self.lr_param, self.noise_sigma, seed=s + 13)
508
+ try:
509
+ tr.fit(epochs=epochs)
510
+ for k in names:
511
+ samples[k].append(mdl.params[k])
512
+ except Exception:
513
+ pass
514
+ out: Dict[str, Tuple[float, float, float, float]] = {}
515
+ for k in names:
516
+ arr = np.array(samples[k], float)
517
+ mean = float(np.mean(arr))
518
+ std = float(np.std(arr, ddof=1)) if arr.size > 1 else 0.0
519
+ out[k] = (mean, std, mean - 1.96 * std, mean + 1.96 * std)
520
+ return out
521
+
522
+
523
+ # ===========================================================================
524
+ # 5. Three-state convergence classifier — EXACT criteria from ARXIV_LEADERS.
525
+ # ===========================================================================
526
+ def _classify_convergence(min_wc: float, grad_norm: float, kappa: float,
527
+ delta_param_rel: float, min_fisher: float
528
+ ) -> Tuple[str, Dict[str, str]]:
529
+ crit = {
530
+ "min_causal_weight": f"{min_wc:.4f} (GREEN>{CAUSAL_GREEN}, RED<={CAUSAL_RED})",
531
+ "grad_norm": f"{grad_norm:.2e} (GREEN<{GRAD_GREEN:.0e})",
532
+ "kappa_fim": f"{kappa:.2e} (IDENT<{KAPPA_IDENT:.0e}, RED>={KAPPA_RED:.0e})",
533
+ "min_fisher": f"{min_fisher:.2e} (floor {FISHER_FLOOR:.0e}; below=UNIDENTIFIABLE)",
534
+ "delta_param_rel": f"{delta_param_rel:.2e}",
535
+ }
536
+ # RED dominates: divergence, non-identifiability, or a below-floor Fisher.
537
+ if (min_wc <= CAUSAL_RED) or (kappa >= KAPPA_RED) or (not math.isfinite(kappa)) \
538
+ or (min_fisher < FISHER_FLOOR):
539
+ return "RED", crit
540
+ # GREEN requires ALL exact gates.
541
+ if (min_wc > CAUSAL_GREEN) and (grad_norm < GRAD_GREEN) and (kappa < KAPPA_IDENT):
542
+ return "GREEN", crit
543
+ return "YELLOW", crit
544
+
545
+
546
+ # ===========================================================================
547
+ # 6. Built-in Duffing system — the runnable demo physics.
548
+ # m x'' + c x' + delta x + alpha x^3 = F cos(omega t); alpha is the unknown.
549
+ # ===========================================================================
550
+ def duffing_residual(t, x, dx, ddx, params,
551
+ m=1.0, c=0.2, delta=1.0, F=0.5, omega=1.0):
552
+ alpha = params.get("alpha", 0.0)
553
+ ghost = params.get("ghost", 0.0) # enters with coefficient 0 -> UNIDENTIFIABLE
554
+ return (m * ddx + c * dx + delta * x + alpha * (x ** 3)
555
+ - F * np.cos(omega * t) + 0.0 * ghost)
556
+
557
+
558
+ def integrate_duffing(t, m=1.0, c=0.2, delta=1.0, alpha=1.0, F=0.5, omega=1.0,
559
+ x0=0.0, v0=0.0):
560
+ """RK4 integration of the Duffing oscillator (own NumPy integrator; no scipy
561
+ dependency). Returns x(t) on the given grid."""
562
+ t = np.asarray(t, float)
563
+ def deriv(state, tt):
564
+ x, v = state
565
+ a = (F * math.cos(omega * tt) - c * v - delta * x - alpha * x ** 3) / m
566
+ return np.array([v, a])
567
+ xs = np.empty_like(t)
568
+ state = np.array([x0, v0], float)
569
+ xs[0] = state[0]
570
+ for i in range(1, len(t)):
571
+ h = t[i] - t[i - 1]
572
+ k1 = deriv(state, t[i - 1])
573
+ k2 = deriv(state + 0.5 * h * k1, t[i - 1] + 0.5 * h)
574
+ k3 = deriv(state + 0.5 * h * k2, t[i - 1] + 0.5 * h)
575
+ k4 = deriv(state + h * k3, t[i])
576
+ state = state + (h / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
577
+ xs[i] = state[0]
578
+ return xs