Source code for eon.amsel_superbasin_gate

"""amsel discover_decide for a superbasin graph and for one state's table.

``discover_decide_for_superbasin`` is the only gate. It calls
``amsel.discover_decide_status`` and returns one status from
``DISCOVER_DECIDE_STATUSES``.

* ``accepted`` / ``retightened`` — the candidate is one basin.
* ``split_required`` — use the primary transient set anchored at the entry.
* ``rejected_no_metastable_basin`` — ordinary single-state KMC.
* ``fallback_single`` — a failed amsel call (``available`` is true).
* ``unavailable`` — amsel is not importable, or ``on_error`` is
  ``unavailable_mcamc`` (``available`` is false).

``[amsel] discover_decide`` does not require ``use_mcamc``.
``amsel_discover_exit`` reads the current state's process table. A barrier
strictly below ``e_min_init`` is in-basin. A higher barrier is an exit.
A table with no in-basin edge still exits: the transient set is the entry
state and the products are absorbing. A product column of -1 is that exit
too: the absorbing label is a 32-bit id, and the hop creates the product
state from the process id. The exit time and channel come from
MRM (mean time) or FPTA (one sample). The kernels are the ``amsel``
package. A missing package logs ``unavailable`` and returns no exit.
"""
from __future__ import annotations

import hashlib
import json
import logging
import math
from pathlib import Path
from typing import Any, Callable, Mapping

logger = logging.getLogger("superbasin.amsel_gate")

# One status vocabulary. ``unavailable`` pairs with available false.
# ``fallback_single`` pairs with available true. The pair
# (unavailable, true) is not a result of this module.
DISCOVER_DECIDE_STATUSES = (
    "unavailable",
    "fallback_single",
    "accepted",
    "retightened",
    "split_required",
    "rejected_no_metastable_basin",
    "rejected",
    "split_cached",
)
_EXIT_STATUSES = ("accepted", "retightened", "split_required")
_EXIT_KERNELS = ("mrm", "fpta")
# amsel StateId is u32. State directories are numbered from 0. An unlinked
# product stays out of that range until the hop creates the directory.
_UNLINKED_STATE_BASE = 1 << 31


[docs] class AmselSuperbasinReject(RuntimeError): """Raised when discover_decide rejects the candidate superbasin.""" def __init__(self, status: str, detail: str = ""): self.status = status super().__init__(detail or status)
def _process_barrier_eV(proc: Mapping[str, Any]) -> float: for key in ("barrier", "saddle_energy", "barrier_eV"): if key in proc and proc[key] is not None: try: return float(proc[key]) except (TypeError, ValueError): pass # Fallback: infer from rate is unreliable; use large barrier so edge is # "high TS" unless rate is huge — prefer explicit barrier fields. return 1.0
[docs] def build_graph_from_superbasin(superbasin: Any, entry_number: int) -> tuple[ list[int], list[tuple[int, int, float]], list[float], ]: """Return (candidate_states, rate_triples, ts_energies) for amsel.""" candidates = [int(n) for n in superbasin.state_numbers] if int(entry_number) not in candidates: candidates = [int(entry_number)] + candidates rates: list[tuple[int, int, float]] = [] barriers: list[float] = [] for number in superbasin.state_numbers: procs = superbasin.state_dict[number].get_process_table() for process_key, proc in list(procs.items()): rate = float(proc.get("rate", 0.0) or 0.0) if rate <= 0.0: continue product = _product_id(proc, process_key) if product is None: continue rates.append((int(number), int(product), rate)) barriers.append(_process_barrier_eV(proc)) return candidates, rates, barriers
[docs] def discover_decide_for_superbasin( superbasin: Any, entry_state: Any, *, e_min_init: float = 0.5, e_min_step: float = 0.05, e_min_floor: float = 0.05, cv_threshold: float = 10.0, on_error: str = "fallback_single", ) -> dict[str, Any]: """Run amsel.discover_decide_status; return a structured result dict. Keys: status (str), primary_transient (list[int]|None), raw (tuple|None), available (bool). If amsel is not importable, available=False and status is ``unavailable`` (caller should proceed with legacy MCAMC). ``on_error`` decides what a failing amsel call returns: ``raise`` re-raises, ``unavailable_mcamc`` reports amsel as unavailable so MCAMC runs as before, and ``fallback_single`` (the default) reports ``fallback_single`` so the caller takes an ordinary single-state step. """ try: from amsel import discover_decide_status except ImportError: return { "available": False, "status": "unavailable", "primary_transient": None, "raw": None, "reason": "amsel not importable", } entry = int(entry_state.number) candidates, rate_triples, barriers = build_graph_from_superbasin( superbasin, entry ) if not rate_triples: return { "available": True, "status": "rejected_no_metastable_basin", "primary_transient": None, "raw": None, "reason": "no positive-rate processes in superbasin tables", } # Align barrier vector with rate triples (required arity) while len(barriers) < len(rate_triples): barriers.append(0.5) barriers = barriers[: len(rate_triples)] # The cutoff separates fast in-basin edges from exits. It is not lifted # to the largest barrier: that would make every edge transient and leave # the basin without an absorbing border. e_init = float(e_min_init) try: raw = discover_decide_status( entry, [int(c) for c in candidates], [(int(a), int(b), float(r)) for a, b, r in rate_triples], [float(x) for x in barriers], e_init, float(e_min_step), float(e_min_floor), float(cv_threshold), ) except Exception as exc: logger.warning("amsel discover_decide_status failed: %s", exc) policy = str(on_error or "fallback_single") if policy == "raise": raise if policy == "unavailable_mcamc": return { "available": False, "status": "unavailable", "primary_transient": None, "raw": None, "reason": f"{type(exc).__name__}: {exc}", } # fallback_single: do not look like a successful AMSEl skip return { "available": True, "status": "fallback_single", "primary_transient": None, "raw": None, "reason": f"{type(exc).__name__}: {exc}", } status = str(raw[0]) if isinstance(raw, (list, tuple)) and raw else str(raw) primary = None if isinstance(raw, (list, tuple)) and len(raw) > 1: # DiscoverDecisionStatus: (status, transient, absorbing, rates, ...) try: primary = [int(x) for x in raw[1]] except Exception: primary = None return { "available": True, "status": status, "primary_transient": primary, "raw": raw, "reason": "", }
def _split_cache_path(superbasin: Any) -> str | None: path = getattr(superbasin, "path", None) if not path: return None return str(path) + ".amsel_split.json"
[docs] def load_persisted_split(superbasin: Any) -> list[int] | None: cache = _split_cache_path(superbasin) if not cache or not Path(cache).is_file(): return None try: with Path(cache).open(encoding="utf-8") as fh: data = json.load(fh) keep = [int(x) for x in data.get("keep", [])] return keep or None except (OSError, ValueError, TypeError, json.JSONDecodeError): return None
[docs] def persist_split(superbasin: Any, keep: list[int]) -> None: cache = _split_cache_path(superbasin) if not cache: return try: with Path(cache).open("w", encoding="utf-8") as fh: json.dump({"keep": [int(x) for x in keep]}, fh) except OSError as exc: logger.warning("amsel persist_split failed: %s", exc)
[docs] def apply_gate_to_superbasin( superbasin: Any, entry_state: Any, decision: Mapping[str, Any], ) -> str: """Mutate superbasin in-place for split_required; raise on reject. Returns the status string for logging. """ status = str(decision.get("status", "unavailable")) if not decision.get("available") or status in ( "unavailable", "accepted", "retightened", "fallback_single", ): return status cached = load_persisted_split(superbasin) if cached: keep = [n for n in superbasin.state_numbers if int(n) in set(cached)] if keep and keep != list(superbasin.state_numbers): superbasin.state_numbers = keep superbasin.states = [superbasin.state_dict[n] for n in keep] return "split_cached" if status == "split_required": primary = decision.get("primary_transient") or [] if not primary: logger.warning( "amsel split_required but no primary_transient; keeping full basin" ) return status primary_set = set(int(x) for x in primary) # Entry must remain for MCAMC indexing (st2i[entry_state.number]). entry_n = int(entry_state.number) primary_set.add(entry_n) # Restrict member list to primary transient states that exist keep = [n for n in superbasin.state_numbers if int(n) in primary_set] if not keep: logger.warning( "amsel primary_transient %s disjoint from superbasin; keeping full", primary, ) return status logger.info( "amsel discover_decide=split_required: restricting superbasin %s -> %s", superbasin.state_numbers, keep, ) superbasin.state_numbers = keep superbasin.states = [superbasin.state_dict[n] for n in keep] # Drop dict entries not in keep (optional consistency) for n in list(superbasin.state_dict.keys()): if n not in keep: del superbasin.state_dict[n] # Persist so in-memory membership matches on-disk after resume. if hasattr(superbasin, "write_data"): try: superbasin.write_data() except Exception as exc: logger.warning("amsel split: write_data failed: %s", exc) persist_split(superbasin, [int(n) for n in keep]) return status if status in ("rejected_no_metastable_basin", "rejected"): raise AmselSuperbasinReject( status, "amsel discover_decide rejected superbasin %s (entry %s): %s" % (superbasin.state_numbers, entry_state.number, decision.get("reason", "")), ) return status
[docs] def status_pair(status: str, available: bool) -> bool: """Return true when ``(status, available)`` is in the one vocabulary.""" text = str(status) if text not in DISCOVER_DECIDE_STATUSES: return False if text == "unavailable": return available is False if text == "fallback_single": return available is True return True
class _ProcessView: """State id with a fixed process table. Border states carry an empty table.""" def __init__(self, number: int, table: Mapping[Any, Any] | None = None): self.number = int(number) self._table = {} if table is None else table def get_process_table(self): return self._table
[docs] class AmselExit: """One basin exit chosen by MRM or FPTA.""" __slots__ = ( "exit_number", "proc_id", "product_number", "mean_time", "time_is_sample", "barrier", "rate", "kernel", ) def __init__( self, exit_number: int, proc_id: int, product_number: int, mean_time: float, time_is_sample: bool, barrier: float, rate: float, kernel: str, ): self.exit_number = int(exit_number) self.proc_id = proc_id self.product_number = int(product_number) self.mean_time = float(mean_time) self.time_is_sample = bool(time_is_sample) self.barrier = float(barrier) self.rate = float(rate) self.kernel = str(kernel)
def _resolve_state(resolve_state: Callable[[int], Any] | None, number: int) -> Any: if resolve_state is None: return None try: return resolve_state(int(number)) except Exception: return None def _unlinked_state_id(process_key: Any, proc: Mapping[str, Any]) -> int: """32-bit absorbing label for a product column that is still -1. The process id does not fit in ``amsel``'s ``u32`` state id. The label is only the graph id. The hop creates the product state from the process key. """ text = "\0".join( ( str(process_key), str(proc.get("barrier")), str(proc.get("product_energy")), str(proc.get("saddle_energy")), ) ) digest = hashlib.blake2s(text.encode("utf-8"), digest_size=4).digest() offset = int.from_bytes(digest, "little") & 0x7FFFFFFF return _UNLINKED_STATE_BASE + offset def _product_id(proc: Mapping[str, Any], process_key: Any = None) -> int | None: product = proc.get("product") if product is None: return None try: pid = int(product) except (TypeError, ValueError): return None if pid < 0: return _unlinked_state_id(process_key, proc) return pid
[docs] def basin_view_from_state( entry_state: Any, resolve_state: Callable[[int], Any] | None, e_min_init: float, ) -> Any: """States reached by barriers below ``e_min_init``, plus exit products. Exit products are candidate ids with an empty process table, so their own edges are not fed to discover_decide. A barrier strictly below ``e_min_init`` enqueues the product when the state list can resolve it. """ from types import SimpleNamespace tables: dict[int, Any] = {int(entry_state.number): entry_state} pending = [entry_state] border: list[int] = [] cutoff = float(e_min_init) while pending: state = pending.pop() for process_key, proc in list(state.get_process_table().items()): pid = _product_id(proc, process_key) if pid is None: continue if _process_barrier_eV(proc) < cutoff: if pid in tables: continue other = None if pid < _UNLINKED_STATE_BASE: other = _resolve_state(resolve_state, pid) if other is None: other = _ProcessView(pid, {}) tables[pid] = other pending.append(other) elif pid not in tables and pid not in border: border.append(pid) numbers = list(tables) for pid in border: if pid not in tables: tables[pid] = _ProcessView(pid, {}) numbers.append(pid) return SimpleNamespace( state_numbers=numbers, state_dict=tables, states=[tables[n] for n in numbers], id=None, path=None, )
def _has_in_basin_edge(view: Any, e_min_init: float) -> bool: cutoff = float(e_min_init) for state in view.state_dict.values(): for process_key, proc in state.get_process_table().items(): if _product_id(proc, process_key) is None: continue if _process_barrier_eV(proc) < cutoff: return True return False def _log_status(decision: Mapping[str, Any]) -> str: status = str(decision.get("status", "unavailable")) logger.info( "amsel discover_decide status=%s available=%s primary=%s", status, decision.get("available"), decision.get("primary_transient"), ) return status def _partition(decision: Mapping[str, Any]) -> tuple[list[int], list[int], list] | None: raw = decision.get("raw") if not isinstance(raw, (list, tuple)) or len(raw) < 4: return None try: transient = [int(x) for x in raw[1]] absorbing = [int(x) for x in raw[2]] rates = [(int(a), int(b), float(r)) for a, b, r in raw[3]] except (TypeError, ValueError): return None if not transient or not absorbing or not rates: return None return transient, absorbing, rates def _sample_index(weights: list[float], draw: float) -> int | None: total = 0.0 for weight in weights: if weight > 0.0 and math.isfinite(weight): total += weight if total <= 0.0: return None mark = min(max(float(draw), 0.0), 1.0) * total walked = 0.0 last = None for index, weight in enumerate(weights): if weight > 0.0 and math.isfinite(weight): walked += weight last = index if walked >= mark: return index return last def _invoke_kernel( name: str, transient: list[int], absorbing: list[int], rates: list, entry: int, draw: float, ) -> tuple[float, list[float], bool] | None: import amsel if name == "mrm" and hasattr(amsel, "mrm"): tau, rate_to_absorbing, _x = amsel.mrm(transient, absorbing, rates, int(entry)) return float(tau), [float(x) for x in rate_to_absorbing], False if name == "fpta" and hasattr(amsel, "fpta"): t_exit, weights = amsel.fpta( transient, absorbing, rates, int(entry), float(draw) ) return float(t_exit), [float(x) for x in weights], True if not hasattr(amsel, "AmcProblem"): return None problem = amsel.AmcProblem( transient=transient, absorbing=absorbing, rates=rates ) if name == "mrm": result = problem.mrm(int(entry)) return float(result.tau_total), [float(x) for x in result.rate_to_absorbing], False result = problem.fpta(int(entry), float(draw)) return float(result.t_exit), [float(x) for x in result.weights], True def _kernel_exit( prefer: str, transient: list[int], absorbing: list[int], rates: list, entry: int, uniform: Callable[[], float], ) -> tuple[str, int, float, bool] | None: """Pick one absorbing state. MRM is the mean clock. FPTA is one sample.""" order = (prefer, "fpta" if prefer == "mrm" else "mrm") errors: list[str] = [] for name in order: if name not in _EXIT_KERNELS: continue try: draw = float(uniform()) # FPTA rejects the closed ends of the unit interval. draw = min(max(draw, 1e-16), 1.0 - 1e-16) got = _invoke_kernel(name, transient, absorbing, rates, entry, draw) except Exception as exc: errors.append("%s: %s: %s" % (name, type(exc).__name__, exc)) continue if got is None: errors.append("%s: missing" % name) continue clock, weights, time_is_sample = got if len(weights) != len(absorbing) or not math.isfinite(clock) or clock < 0.0: errors.append("%s: bad result" % name) continue index = _sample_index(weights, float(uniform())) if index is None: errors.append("%s: empty weights" % name) continue return name, int(absorbing[index]), float(clock), bool(time_is_sample) if errors: logger.warning("amsel exit kernels failed: %s", "; ".join(errors)) return None def _direct_escape_partition( view: Any, entry: int, e_min_init: float ) -> tuple[list[int], list[int], list] | None: """Entry state plus its exit edges, when nothing is below the cutoff. The transient set is that one state. Products of barriers at or above ``e_min_init`` are absorbing. A non-positive rate is dropped. """ cutoff = float(e_min_init) state = view.state_dict.get(int(entry)) if state is None: return None absorbing: list[int] = [] seen: set[int] = set() rates: list[tuple[int, int, float]] = [] for process_key, proc in state.get_process_table().items(): pid = _product_id(proc, process_key) if pid is None or pid == int(entry): continue if _process_barrier_eV(proc) < cutoff: continue rate = float(proc.get("rate", 0.0) or 0.0) if rate <= 0.0: continue if pid not in seen: seen.add(pid) absorbing.append(pid) rates.append((int(entry), int(pid), rate)) if not absorbing or not rates: return None return [int(entry)], absorbing, rates def _exit_process( state_dict: Mapping[int, Any], transient: list[int], absorbing_id: int, e_min_init: float, ) -> tuple[int, Any, float, float] | None: """Fastest exit process from the transient set into ``absorbing_id``.""" cutoff = float(e_min_init) best: tuple[float, int, Any, float] | None = None for number in transient: state = state_dict.get(int(number)) if state is None: continue for process_key, proc in state.get_process_table().items(): if _product_id(proc, process_key) != int(absorbing_id): continue barrier = _process_barrier_eV(proc) if barrier < cutoff: continue rate = float(proc.get("rate", 0.0) or 0.0) if best is None or rate > best[0]: best = (rate, int(number), process_key, barrier) if best is None: return None rate, number, pid, barrier = best return number, pid, barrier, rate def _amsel_floats(config: Any) -> dict[str, Any]: return { "e_min_init": float(getattr(config, "amsel_e_min_init", 0.5)), "e_min_step": float(getattr(config, "amsel_e_min_step", 0.05)), "e_min_floor": float(getattr(config, "amsel_e_min_floor", 0.05)), "cv_threshold": float(getattr(config, "amsel_cv_threshold", 10.0)), "on_error": str(getattr(config, "amsel_on_error", "fallback_single")), }
[docs] def amsel_discover_exit( entry_state: Any, resolve_state: Callable[[int], Any] | None, config: Any, uniform: Callable[[], float] | None = None, ) -> AmselExit | None: """Log ``amsel discover_decide status`` and maybe return an MRM or FPTA exit. The log line is written for every call, including a table with one state and a missing ``amsel`` package. An in-basin edge is a barrier strictly below ``e_min_init``. With such an edge, an exit is returned when discover accepts the basin, retightens it, or splits it. With no such edge, a barrier at or above the cutoff is a direct exit. The transient set is the entry state, the products are absorbing, and the logged status is ``accepted``. A product column of -1 uses a 32-bit absorbing label. The process id is unchanged. ``debug_use_mean_time`` selects MRM. Otherwise the clock is one FPTA sample. The other kernel is used when the selected one is missing. A missing package logs ``unavailable`` and returns no exit. """ if uniform is None: import random uniform = random.random knobs = _amsel_floats(config) view = basin_view_from_state(entry_state, resolve_state, knobs["e_min_init"]) decision = discover_decide_for_superbasin( view, entry_state, e_min_init=knobs["e_min_init"], e_min_step=knobs["e_min_step"], e_min_floor=knobs["e_min_floor"], cv_threshold=knobs["cv_threshold"], on_error=knobs["on_error"], ) entry = int(entry_state.number) # A lone exit has no fast edge for discover to call a basin. The # absorbing states are the products, and the transient set is the entry. # A missing package stays ``unavailable`` and does not hop. direct = None if str(decision.get("status", "unavailable")) != "unavailable" and not _has_in_basin_edge( view, knobs["e_min_init"] ): direct = _direct_escape_partition(view, entry, knobs["e_min_init"]) if direct is not None: transient, absorbing, rates = direct decision = { "available": True, "status": "accepted", "primary_transient": list(transient), "raw": ("accepted", list(transient), list(absorbing), list(rates)), } status = _log_status(decision) if not status_pair(status, bool(decision.get("available"))): logger.warning( "amsel discover_decide status=%s available=%s is outside the vocabulary", status, decision.get("available"), ) if status not in _EXIT_STATUSES or not decision.get("available"): return None if direct is None: parts = _partition(decision) if parts is None: return None transient, absorbing, rates = parts if entry not in transient: transient = [entry] + [n for n in transient if n != entry] prefer = "mrm" if bool(getattr(config, "debug_use_mean_time", False)) else "fpta" chosen = _kernel_exit(prefer, transient, absorbing, rates, entry, uniform) if chosen is None: return None kernel, absorbing_id, clock, time_is_sample = chosen proc = _exit_process(view.state_dict, transient, absorbing_id, knobs["e_min_init"]) if proc is None: logger.warning( "amsel %s absorbing state %s has no exit process at or above e_min_init %.3f eV", kernel, absorbing_id, knobs["e_min_init"], ) return None exit_number, proc_id, barrier, rate = proc logger.info( "amsel discover_decide exit kernel=%s product=%s time=%.6e", kernel, absorbing_id, clock, ) return AmselExit( exit_number=exit_number, proc_id=proc_id, product_number=absorbing_id, mean_time=clock, time_is_sample=time_is_sample, barrier=barrier, rate=rate, kernel=kernel, )