|
| 1 | +"""The field cube cache, as something the kernel can be given. |
| 2 | +
|
| 3 | +WHY THIS FILE EXISTS AND WHY IT IS SEPARATE, which is the same reason artifacts_pg is |
| 4 | +separate: the kernel must be able to run with no cache at all (an offline CLI, a test on a |
| 5 | +bare interpreter), so the cache arrives as an INJECTED collaborator with a declared |
| 6 | +interface — `lookup(method_id, payload) -> HandlerResult | None` — and the kernel neither |
| 7 | +knows nor cares that a Postgres view and a molfile hash are involved. |
| 8 | +
|
| 9 | +WHAT THIS UNBLOCKS, and it is the whole point of PR-08: /field's 200-line orchestration |
| 10 | +cannot be deleted while the kernel is missing what that orchestration does. The kernel had |
| 11 | +no cache, so replacing the route with a kernel call would have turned every cache hit back |
| 12 | +into a 6-minute SCF — a regression wearing a refactor's clothes. The route's behaviour has to |
| 13 | +land in the kernel FIRST, and then the route becomes a thin adapter whose deletion the v1 |
| 14 | +golden can measure. |
| 15 | +
|
| 16 | +THE KEY IS THE ROUTE'S KEY, deliberately and not by coincidence: sha256 of the molfile bytes, |
| 17 | +the kind, and the basis ('none' for classical). Anything else would mean a cube written by the |
| 18 | +route could not be found by the kernel and vice versa, and the two would slowly fill the |
| 19 | +table with each other's misses. Read through app.v_field_cube_servable — the method-currency |
| 20 | +view — so a row produced by superseded source is invisible rather than wrong. |
| 21 | +
|
| 22 | +The SPIN CAVEAT is inherited on purpose: an explicit spin makes the SAME molfile give a |
| 23 | +DIFFERENT field, and the durable key has no room for it. Spin-overridden requests therefore |
| 24 | +bypass the cache in BOTH directions, exactly as the route does. Serving a high-spin heme to a |
| 25 | +request that asked for the singlet is the failure this asymmetry prevents. |
| 26 | +""" |
| 27 | +from __future__ import annotations |
| 28 | + |
| 29 | +import hashlib |
| 30 | +import sys |
| 31 | +import threading |
| 32 | +from datetime import datetime, timezone |
| 33 | +from typing import Any |
| 34 | + |
| 35 | +from invocation import HandlerResult |
| 36 | + |
| 37 | +# The methods this cache can serve, and the `kind` each maps to. Explicit rather than derived |
| 38 | +# from the method_id's last segment: fields.region.* are NOT cacheable (no molfile exists to |
| 39 | +# key them on) and a derivation would have silently included them. |
| 40 | +CACHEABLE_KIND = { |
| 41 | + 'fields.mep': 'mep', |
| 42 | + 'fields.qm.homo': 'homo', |
| 43 | + 'fields.qm.lumo': 'lumo', |
| 44 | + 'fields.qm.density': 'density', |
| 45 | + 'fields.qm.mep_qm': 'mep_qm', |
| 46 | +} |
| 47 | +# mlp is deliberately absent: it is not in app.field_kind's enum and costs ~0.03 s, so a row |
| 48 | +# for it would be a schema violation bought for nothing. That was the route's decision and |
| 49 | +# it is restated here rather than inherited silently. |
| 50 | + |
| 51 | + |
| 52 | +class FieldCubeCache: |
| 53 | + """Read and asynchronously write the durable cube cache. |
| 54 | +
|
| 55 | + Both callbacks are injected, so this adapter has no database dependency. Writes reuse |
| 56 | + the daemon's existing background-safe writer and never sit inside the invocation's |
| 57 | + critical path; local short-lived processes use a non-daemon thread so a promised write |
| 58 | + is allowed to commit during interpreter shutdown. |
| 59 | + """ |
| 60 | + |
| 61 | + def __init__(self, get_cube, *, put_cube=None, prepare_mol=None, |
| 62 | + canonicalise=None) -> None: |
| 63 | + # `get_cube(molfile_sha: bytes, kind: str, basis: str) -> (cube, meta) | None`, passed |
| 64 | + # in rather than imported, so this module does not depend on field_server and can be |
| 65 | + # exercised with a stub. |
| 66 | + self._get_cube = get_cube |
| 67 | + self._put_cube = put_cube |
| 68 | + self._prepare_mol = prepare_mol |
| 69 | + self._canonicalise = canonicalise |
| 70 | + self.counters = {'looked_up': 0, 'hit': 0, 'miss': 0, 'skipped_spin': 0, |
| 71 | + 'skipped_uncacheable': 0, 'error': 0, |
| 72 | + 'write_queued': 0, 'write_ok': 0, 'write_error': 0} |
| 73 | + |
| 74 | + def lookup(self, method_id: str, payload: dict) -> HandlerResult | None: |
| 75 | + kind = CACHEABLE_KIND.get(method_id) |
| 76 | + if kind is None: |
| 77 | + self.counters['skipped_uncacheable'] += 1 |
| 78 | + return None |
| 79 | + params = dict(payload.get('parameters') or {}) |
| 80 | + if params.get('spin') is not None: |
| 81 | + # NOT a miss — a deliberate bypass, counted separately so the two cannot be |
| 82 | + # confused when someone asks why the hit rate dropped. |
| 83 | + self.counters['skipped_spin'] += 1 |
| 84 | + return None |
| 85 | + content = ((payload.get('molecule') or {}).get('content') or '') |
| 86 | + if not content: |
| 87 | + return None |
| 88 | + basis = 'none' if kind == 'mep' else params.get('basis', 'sto-3g') |
| 89 | + molfile_sha = hashlib.sha256(content.encode()).digest() |
| 90 | + self.counters['looked_up'] += 1 |
| 91 | + try: |
| 92 | + hit = self._get_cube(molfile_sha, kind, basis) |
| 93 | + except Exception as e: # noqa: BLE001 |
| 94 | + # A cache that raises must not fail the invocation: the science can still run. |
| 95 | + # Counted and LOUD, because a cache that silently stops working looks exactly |
| 96 | + # like a cache that is cold. |
| 97 | + self.counters['error'] += 1 |
| 98 | + print(f'[cache] lookup failed for {method_id} ({type(e).__name__}: {e}) — ' |
| 99 | + f'computing instead', file=sys.stderr, flush=True) |
| 100 | + return None |
| 101 | + if hit is None: |
| 102 | + self.counters['miss'] += 1 |
| 103 | + return None |
| 104 | + cube, meta = hit |
| 105 | + if self._canonicalise is not None: |
| 106 | + cube = self._canonicalise(cube) |
| 107 | + self.counters['hit'] += 1 |
| 108 | + return self._as_handler_result(method_id, kind, basis, cube, meta) |
| 109 | + |
| 110 | + def store(self, method_id: str, payload: dict, out: HandlerResult, *, |
| 111 | + seconds: float, job_id: str | None = None) -> None: |
| 112 | + """Queue a validated computed field for durable persistence. |
| 113 | +
|
| 114 | + The invocation never waits for PostgreSQL. This preserves the existing interaction |
| 115 | + contract while moving ownership of the behaviour out of the HTTP route. The callback |
| 116 | + remains injected, so this module has no database or daemon dependency. |
| 117 | + """ |
| 118 | + kind = CACHEABLE_KIND.get(method_id) |
| 119 | + if kind is None or self._put_cube is None or out.cache != 'computed': |
| 120 | + return |
| 121 | + params = dict(payload.get('parameters') or {}) |
| 122 | + if params.get('spin') is not None: |
| 123 | + return |
| 124 | + content = ((payload.get('molecule') or {}).get('content') or '') |
| 125 | + if not content: |
| 126 | + return |
| 127 | + cube_entry = next((data for role, data in out.artifacts |
| 128 | + if role == 'field.cube'), None) |
| 129 | + if cube_entry is None: |
| 130 | + return |
| 131 | + cube = cube_entry.decode() |
| 132 | + basis = 'none' if kind == 'mep' else params.get('basis', 'sto-3g') |
| 133 | + molfile_sha = hashlib.sha256(content.encode()).digest() |
| 134 | + meta = self._legacy_meta(kind, basis, out, seconds) |
| 135 | + self.counters['write_queued'] += 1 |
| 136 | + |
| 137 | + def persist() -> None: |
| 138 | + try: |
| 139 | + mol = self._prepare_mol(content) if self._prepare_mol is not None else None |
| 140 | + self._put_cube(molfile_sha, kind, basis, cube, meta, |
| 141 | + mol=mol, job_id=job_id) |
| 142 | + self.counters['write_ok'] += 1 |
| 143 | + except Exception as e: # noqa: BLE001 |
| 144 | + self.counters['write_error'] += 1 |
| 145 | + print(f'[cache] write failed for {method_id} ' |
| 146 | + f'({type(e).__name__}: {e}); the result was served but not persisted', |
| 147 | + file=sys.stderr, flush=True) |
| 148 | + |
| 149 | + # Non-daemon is load-bearing for LocalTransport: a CLI is a short process, so a |
| 150 | + # daemon thread can be killed between printing the envelope and committing the row. |
| 151 | + # The HTTP daemon still returns immediately; only process shutdown waits for a write |
| 152 | + # it already promised to attempt. |
| 153 | + threading.Thread(target=persist, name=f'dirac-cache-{kind}', daemon=False).start() |
| 154 | + |
| 155 | + @staticmethod |
| 156 | + def _legacy_meta(kind: str, basis: str, out: HandlerResult, |
| 157 | + seconds: float) -> dict: |
| 158 | + """Translate a validated canonical result into the existing DB writer codec.""" |
| 159 | + result = out.result |
| 160 | + field = result.get('field') or {} |
| 161 | + grid = field.get('grid') or {} |
| 162 | + extrema = field.get('extrema') or {} |
| 163 | + box = field.get('box') or {} |
| 164 | + model = result.get('model') or {} |
| 165 | + wf = result.get('wavefunction') or {} |
| 166 | + raw = dict((out.cache_record or {}).get('meta') or {}) |
| 167 | + raw.update({ |
| 168 | + 'kind': kind, 'basis': basis, |
| 169 | + 'dims': grid.get('dimensions'), |
| 170 | + 'spacing': grid.get('spacing_angstrom'), |
| 171 | + 'vmin': extrema.get('min'), 'vmax': extrema.get('max'), |
| 172 | + 'single_signed': field.get('single_signed'), |
| 173 | + 'iso_fixed': box.get('iso_fixed'), |
| 174 | + 'iso_sized_for': box.get('iso_sized_for'), |
| 175 | + 'contour_closes_in_box': box.get('contour_closes_in_box'), |
| 176 | + 'pad_used_angstrom': box.get('pad_angstrom'), |
| 177 | + 'grid_capped': box.get('capped'), 'wall_max': box.get('wall_seconds'), |
| 178 | + 'charges': model.get('charge_model'), |
| 179 | + 'net_charge': model.get('net_charge'), |
| 180 | + 'sigma_hole_representable': model.get('sigma_hole_representable'), |
| 181 | + 'method': wf.get('method') or model.get('charge_model'), |
| 182 | + 'converged': wf.get('converged'), |
| 183 | + 'scf_energy_ha': wf.get('scf_energy_hartree'), |
| 184 | + 'scf_cycles': wf.get('scf_cycles'), |
| 185 | + 'nbasis': wf.get('n_basis_functions'), |
| 186 | + 'homo_ev': wf.get('homo_ev'), 'lumo_ev': wf.get('lumo_ev'), |
| 187 | + 'ecp': wf.get('ecp_elements') or out.provenance.get('ecp') or [], |
| 188 | + 'natoms': out.provenance.get('n_atoms'), |
| 189 | + 'charge': out.provenance.get('charge'), |
| 190 | + 'spin': out.provenance.get('spin'), |
| 191 | + 'scf_seconds': out.provenance.get('scf_seconds'), |
| 192 | + 'cube_seconds': out.provenance.get('cube_seconds'), |
| 193 | + 'toolkit_wrote_at': out.provenance.get('toolkit_wrote_at'), |
| 194 | + 'computed_at': out.provenance.get('computed_at') |
| 195 | + or datetime.now(timezone.utc).isoformat(), |
| 196 | + 'total_seconds': seconds, 'cache': 'computed', |
| 197 | + }) |
| 198 | + return {k: v for k, v in raw.items() if v is not None} |
| 199 | + |
| 200 | + @staticmethod |
| 201 | + def _as_handler_result(method_id: str, kind: str, basis: str, cube: str, |
| 202 | + meta: dict) -> HandlerResult: |
| 203 | + """A cache hit must be INDISTINGUISHABLE from a computation, in shape and in values. |
| 204 | +
|
| 205 | + This is where PR-03's whole lesson lands: the two paths agreed on the key set long |
| 206 | + before they agreed on the VALUES, and a hit that reports twelve fewer facts is a |
| 207 | + poorer answer served faster. So the hit is reshaped through the SAME projection the |
| 208 | + handler uses — imported from handlers rather than re-derived, because a second |
| 209 | + projection is a second home and this one would drift toward whichever path someone |
| 210 | + was debugging. |
| 211 | + """ |
| 212 | + import catalog |
| 213 | + import handlers |
| 214 | + from invocation import InvocationContext |
| 215 | + spec = catalog.default_catalog().get(method_id) |
| 216 | + ctx = InvocationContext(method_id=method_id, spec=spec) |
| 217 | + return handlers.field_result( |
| 218 | + ctx, kind, basis, cube, meta, |
| 219 | + mol_n_atoms=meta.get('natoms') or meta.get('_n_atoms'), |
| 220 | + toolkit_wrote_at=meta.get('toolkit_wrote_at'), |
| 221 | + parameters_used={'basis': basis} if kind != 'mep' else {}, |
| 222 | + cache='db') |
0 commit comments