Spaces:
Running
Running
chore(sync): mirror backend .py + Dockerfile to Space (hf-sync-backend)
Browse filesAutomated 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.
- Dockerfile +4 -0
- serve.py +22 -0
- szl_governed_ipinn.py +412 -0
- 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
|