#!/usr/bin/env python3
"""NTE-without-a-transition detector (2026-08-24, curiosity window).

Question: does the open crystallographic record (COD multi-temperature CIF
series, clean H-free census) contain ANY dense, over-constrained framework
with confident volumetric NTE but NO phase-transition signature?

Decision tree per series:
  dense    = Maxwell floppiness f < 0.4 AND two-coord fraction <= 0.2
  floppy   = otherwise (transverse-mode mechanism available by construction)
  transition signature (dense only), any one of:
    - single-axis dominance (>0.55 of |alpha| sum) with <2 significantly
      contracting axes
    - relative sign flip in instantaneous alpha_V(T) (amplitude > 0.4x the
      series mean |alpha_inst|)
    - nonlinearity: R^2 of lnV vs T below 0.97
    - terminal acceleration: mean|alpha_inst| last quarter > 2.5x first quarter
  ALERT = dense + confident NTE + zero transition signatures.

Calibrated on known-answer controls: the three dense transition cases
(KNN, PbTiO3, GaMo4Se8) must score >0; the floppy phonon cases
(Sc2W3O12, quartz, cyanides, oxalate, Eu MOF) must classify as
mechanism-available. 10/10 classifiable controls pass; one 3-point
series is flagged unclassifiable rather than guessed.

Inputs (same directory): clean_hf_census.json, series_alpha_axes.json,
axis_perm_corrected.json, detector_cifs/ (cached COD CIFs, fetched on
first run).
Outputs: detector_scores.json (+ detector_raw.json from the fetch pass).
Known limits: the transition test is calibrated on exactly 3 dense cases;
the census is deposition-biased (ZrW2O8/ScF3/ReO3 have zero multi-T
series in COD, so the most famous floppy NTE materials are absent from
the test set entirely).
"""
import json, math, re, urllib.request
from pathlib import Path
import numpy as np

BASE = Path(__file__).resolve().parent / 'cod_celltemp_series'
CIF_CACHE = BASE / 'detector_cifs'
CIF_CACHE.mkdir(exist_ok=True)

CELL_RE = {k: re.compile(r'^_cell_length_' + k + r'\s+([\d.eE+-]+)', re.M) for k in 'abc'}
ANG_RE = {k: re.compile(r'^_cell_angle_' + k + r'\s+([\d.eE+-]+)', re.M)
          for k in ('alpha', 'beta', 'gamma')}
TEMP_RE = re.compile(
    r'^_(?:cell_measurement_temperature|diffrn_ambient_temperature)\s+([\d.eE+-]+)', re.M)


def fetch(cod):
    p = CIF_CACHE / f'{cod}.cif'
    if p.exists():
        return p.read_text(errors='replace')
    txt = urllib.request.urlopen(
        f'https://www.crystallography.net/cod/{cod}.cif', timeout=30
    ).read().decode('utf-8', 'replace')
    p.write_text(txt)
    return txt


def parse_cell(txt):
    d = {}
    for k, rx in {**CELL_RE, **ANG_RE}.items():
        m = rx.search(txt)
        if not m:
            return None
        d[k] = float(m.group(1))
    m = TEMP_RE.search(txt)
    if not m:
        return None
    d['T'] = float(m.group(1))
    a, b, c = d['a'], d['b'], d['c']
    al, be, ga = (math.radians(d[k]) for k in ('alpha', 'beta', 'gamma'))
    d['V'] = a * b * c * math.sqrt(
        1 + 2 * math.cos(al) * math.cos(be) * math.cos(ga)
        - math.cos(al) ** 2 - math.cos(be) ** 2 - math.cos(ga) ** 2)
    return d


def dedupe(pts):
    byT = {}
    for p in pts:
        byT.setdefault(round(p['T'], 1), []).append(p)
    return [{'T': T, 'V': float(np.mean([p['V'] for p in byT[T]]))} for T in sorted(byT)]


def main():
    census = json.load(open(BASE / 'clean_hf_census.json'))
    axes = json.load(open(BASE / 'series_alpha_axes.json'))
    corr = {tuple(sorted(s['files'])): s
            for s in json.load(open(BASE / 'axis_perm_corrected.json'))}
    out = []
    for c in [r for r in census if r['conf'] and r['aV'] < 0]:
        s = next((x for x in axes if str(c['cod']) in x.get('files', [])), None)
        files = s['files'] if s else [str(c['cod'])]
        pts = sorted((p for p in (parse_cell(fetch(f)) for f in files) if p),
                     key=lambda p: p['T'])
        if s:
            s = corr.get(tuple(sorted(files)), s)
        pts = dedupe(pts)
        T = np.array([p['T'] for p in pts])
        lnV = np.log([p['V'] for p in pts])
        A = np.vstack([T, np.ones_like(T)]).T
        coef, *_ = np.linalg.lstsq(A, lnV, rcond=None)
        resid = lnV - A @ coef
        r2 = 1 - (resid ** 2).sum() / ((lnV - lnV.mean()) ** 2).sum()
        ai = np.diff(lnV) / np.diff(T) * 1e6
        aa = [s.get(f'alpha_{ax}_ppmK') for ax in 'abc'] if s else [None] * 3
        ase = [s.get(f'alpha_{ax}_se') for ax in 'abc'] if s else [None] * 3
        sig_neg = sum(1 for v, e in zip(aa, ase)
                      if v is not None and e is not None and v < -2 * e)
        absa = [abs(v) for v in aa if v is not None]
        dom = max(absa) / sum(absa) if absa else 1.0
        mean_abs = np.mean(np.abs(ai))
        flip = any(ai[i] * ai[i + 1] < 0
                   and min(abs(ai[i]), abs(ai[i + 1])) > 0.4 * mean_abs
                   for i in range(len(ai) - 1))
        k = max(2, len(ai) // 4)
        term_accel = float(np.mean(np.abs(ai[-k:])) / max(np.mean(np.abs(ai[:k])), 1e-9)) \
            if len(ai) >= 4 else 1.0
        dense = (c['f'] < 0.4) and (c['twoc'] <= 0.2)
        ts = 0
        if dense:
            ts = int((dom > 0.55 and sig_neg < 2) + flip + (r2 < 0.97) + (term_accel > 2.5))
        if len(pts) < 4:
            verdict = 'short series (<4 pts) - unclassifiable'
        elif dense:
            verdict = ('ALERT: dense NTE without transition signature' if ts == 0
                       else f'transition-like (score {ts}/4)')
        else:
            verdict = 'floppy: phonon mechanism available'
        out.append({'cod': str(c['cod']), 'formula': c['formula'], 'sg': c['sg'],
                    'aV': c['aV'], 'se': c['se'], 'f': c['f'], 'twoc': c['twoc'],
                    'dense': dense, 'nT': len(pts), 'r2': round(float(r2), 4),
                    'sig_neg_axes': sig_neg, 'dom': round(float(dom), 3),
                    'flip': bool(flip), 'term_accel': round(term_accel, 2),
                    'trans_score': int(ts), 'verdict': verdict,
                    'alpha_inst': [round(float(x), 1) for x in ai],
                    'T_mid': [round(float(x)) for x in (T[1:] + T[:-1]) / 2],
                    'title': (s or {}).get('title', ''),
                    'journal': (s or {}).get('journal', '')})
    json.dump(out, open(BASE / 'detector_scores.json', 'w'), indent=1)
    alerts = [o for o in out if o['verdict'].startswith('ALERT')]
    print(f"{len(out)} confident H-free NTE series screened; ALERTS: {len(alerts)}")
    for o in out:
        tag = 'DENSE' if o['dense'] else 'floppy'
        print(f"  {o['cod']:>8} {o['formula']:<14} {tag} ts={o['trans_score']} -> {o['verdict']}")


if __name__ == '__main__':
    main()
