Skip to content

Commit aec7686

Browse files
committed
[feat.api.Omega.D8+] PR-08: the kernel owns durable field-cache reads and writes, with full-precision parity and an unchanged v1 wire
1 parent b3329ab commit aec7686

6 files changed

Lines changed: 541 additions & 34 deletions

File tree

‎backend/cache_fields.py‎

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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

Comments
 (0)