Skip to content

Commit 28748de

Browse files
committed
[feat.physics.Omega.D8+] unify physics under durable job execution
WHY: surface and torsion descriptors claimed job semantics while a separate process-local queue owned their execution. The shared kernel now registers their real source identity, accepts durable queued jobs, persists artifacts, and exposes wait/cancel through one v2 control plane.
1 parent c711a30 commit 28748de

15 files changed

Lines changed: 506 additions & 82 deletions

‎backend/field_server.py‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ def _handle_admin(path: str):
129129
BOHR = 0.529177210859 # Å per Bohr
130130
MAX_QM_ATOMS = 120 # with hydrogens; STO-3G HF beyond this is not interactive
131131
DEFAULT_BASIS = 'sto-3g'
132+
IODINE_BASIS = 'def2-svp'
132133

133134
# ── the wall-clock bound ────────────────────────────────────────────────────
134135
#
@@ -332,12 +333,30 @@ def handle_v2(method: str, path: str, body: dict | None) -> tuple[int, dict] | N
332333
'meta': {'envelope': 2,
333334
'durability': getattr(svc, 'job_durability', None)}}
334335

336+
if method == 'POST' and path == '/v2/jobs':
337+
body = body or {}
338+
mid = body.get('method_id')
339+
if not mid or 'input' not in body:
340+
return _v2_error(
341+
'PARSE', 'job submission requires method_id and input',
342+
got_keys=sorted(body))
343+
kw = {k: body[k] for k in
344+
('inline_max', 'budget_seconds', 'request_id')
345+
if body.get(k) is not None}
346+
return 202, _kernel().submit(mid, body['input'], **kw)
347+
335348
if path.startswith('/v2/jobs/'):
336349
rest = urllib.parse.unquote(path[len('/v2/jobs/'):]).strip('/')
337350
if method == 'POST' and rest.endswith('/cancel'):
338351
job_id = rest[:-len('/cancel')].strip('/')
339352
return 200, {'ok': True, 'data': _kernel().cancel_job(job_id),
340353
'meta': {'envelope': 2}}
354+
if method == 'GET' and rest.endswith('/wait'):
355+
job_id = rest[:-len('/wait')].strip('/')
356+
timeout = float((query.get('timeout') or ['300'])[0])
357+
return 200, {'ok': True,
358+
'data': _kernel().wait_job(job_id, timeout=timeout),
359+
'meta': {'envelope': 2}}
341360
if method == 'GET' and rest and '/' not in rest:
342361
return 200, {'ok': True, 'data': _kernel().get_job(rest),
343362
'meta': {'envelope': 2}}
@@ -507,12 +526,11 @@ def db_init() -> bool:
507526
# that decides whether a cached SCF is still valid.
508527
try:
509528
import method_registry
510-
_method_ids = method_registry.register_all(_db, sys.modules[__name__],
511-
_toolkit_ids.get('pyscf'))
529+
_method_ids = method_registry.register_all(
530+
_db, sys.modules[__name__], _toolkit_ids)
512531
_method_versions = {
513-
mid: method_registry.unit_version(sys.modules[__name__],
514-
u['fns'], u['consts'])[0]
515-
for mid, u in method_registry.UNITS.items()}
532+
row['method_id']: row['version']
533+
for row in method_registry.plan(sys.modules[__name__])}
516534
print(f'[db] {len(_method_ids)} compute units registered '
517535
f'({len(_method_versions)} source digests)', file=sys.stderr, flush=True)
518536
global _jobs
@@ -1352,10 +1370,10 @@ def run_scf(mol: Chem.Mol, basis: str, max_seconds: float = DEFAULT_MAX_SECONDS,
13521370
# the v1 golden does not move.
13531371
raise failures.DiracUnsupported(
13541372
f'basis {basis} does not cover {"/".join(uncovered)} — '
1355-
'def2-svp is the only whitelisted basis with iodine',
1373+
f'{IODINE_BASIS} is the only whitelisted basis with iodine',
13561374
details={'basis': basis, 'unsupported_elements': sorted(uncovered),
1357-
'supported_alternatives': ['def2-svp']},
1358-
hint={'parameters': {'basis': 'def2-svp'}})
1375+
'supported_alternatives': [IODINE_BASIS]},
1376+
hint={'parameters': {'basis': IODINE_BASIS}})
13591377
if len(syms) > MAX_QM_ATOMS:
13601378
raise failures.DiracTooLarge(
13611379
f'{len(syms)} atoms (with H) exceeds the interactive QM cap of {MAX_QM_ATOMS}',

‎backend/handlers.py‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"""
2828
from __future__ import annotations
2929

30+
import json
3031
from typing import Any
3132

3233
import failures
@@ -551,3 +552,113 @@ def region_handler(payload: dict, ctx: InvocationContext) -> HandlerResult:
551552
warnings=warnings,
552553
parameters_used={'dielectric': dielectric, 'spacing': spacing},
553554
cache='computed')
555+
556+
557+
def surface_handler(payload: dict, ctx: InvocationContext) -> HandlerResult:
558+
"""Both QM surface methods through the canonical invocation kernel."""
559+
import numpy as np
560+
from physics import mep_surface as MS
561+
562+
molecule = payload['molecule']['content']
563+
params = dict(payload.get('parameters') or {})
564+
basis = params.get('basis', MS.DEFAULT_BASIS)
565+
budget = ctx.budget_seconds or params.get('max_seconds') or MS.DEFAULT_MAX_SECONDS
566+
try:
567+
if ctx.method_id == 'surface.mep':
568+
out = MS.compute_surface_mep(
569+
molecule, basis=basis,
570+
isovalue=float(params.get('isovalue', MS.DEFAULT_ISOVALUE)),
571+
points_per_atom=int(params.get('points_per_atom', 120)),
572+
max_seconds=float(budget), xc=params.get('xc'),
573+
use_gpu=params.get('use_gpu', 'auto'))
574+
points = np.ascontiguousarray(out['points'], dtype='<f4')
575+
values = np.ascontiguousarray(out['values'], dtype='<f4')
576+
meta = out['meta']
577+
result = {'summary': {'n_points': int(len(values)),
578+
'extrema': out['extrema'], 'meta': meta}}
579+
artifacts = [('surface.points', points.tobytes()),
580+
('surface.values', values.tobytes())]
581+
elif ctx.method_id == 'surface.mep_at':
582+
points = np.asarray(payload['points'], dtype=float)
583+
values, meta = MS.mep_at_points(
584+
molecule, points, basis=basis, max_seconds=float(budget))
585+
values = np.ascontiguousarray(values, dtype='<f4')
586+
result = {'summary': {'n_points': int(len(values)),
587+
'min': float(values.min()),
588+
'max': float(values.max()), 'meta': meta}}
589+
artifacts = [('surface.values', values.tobytes())]
590+
else: # pragma: no cover
591+
raise failures.DiracInternal(f'unknown surface method {ctx.method_id}')
592+
except Exception as exc: # noqa: BLE001
593+
_raise_physics_failure(exc)
594+
return HandlerResult(
595+
result=result, artifacts=artifacts,
596+
provenance={'n_atoms': meta.get('n_atoms'),
597+
'charge': meta.get('charge'), 'spin': meta.get('spin'),
598+
'scf_seconds': meta.get('scf_seconds')},
599+
parameters_used={'basis': basis, 'max_seconds': float(budget)},
600+
cache='computed')
601+
602+
603+
def surface_estimate(payload: dict) -> dict:
604+
from physics import mep_surface as MS
605+
try:
606+
basis = (payload.get('parameters') or {}).get('basis', MS.DEFAULT_BASIS)
607+
nao = MS.nao_for(payload['molecule']['content'], basis)
608+
seconds = MS.estimated_scf_seconds(nao)
609+
return {'available': True, 'n_basis_functions_estimated': nao,
610+
'seconds': round(seconds, 2),
611+
'confidence': 'same fitted SCF cost model used by the runtime preflight'}
612+
except Exception as exc: # noqa: BLE001
613+
return {'available': False, 'reason': f'{type(exc).__name__}: {exc}'}
614+
615+
616+
def torsion_handler(payload: dict, ctx: InvocationContext) -> HandlerResult:
617+
from physics.torsion import compute_torsion_strain
618+
params = dict(payload.get('parameters') or {})
619+
try:
620+
out = compute_torsion_strain(
621+
payload['molecule']['content'], steps=int(params.get('steps', 24)),
622+
relax_hydrogens=bool(params.get('relax_hydrogens', True)),
623+
max_torsions=int(params.get('max_torsions', 12)),
624+
variant=params.get('variant', 'MMFF94s'))
625+
except Exception as exc: # noqa: BLE001
626+
_raise_physics_failure(exc)
627+
profile = json.dumps(out, sort_keys=True, separators=(',', ':')).encode()
628+
meta = out.get('meta') or {}
629+
return HandlerResult(
630+
result={'summary': {'total_strain_kcal': out['total_strain_kcal'],
631+
'total_verdict': out['total_verdict'],
632+
'n_scanned': meta.get('n_scanned'), 'meta': meta}},
633+
artifacts=[('torsion.profile', profile)],
634+
provenance={'n_atoms': None},
635+
parameters_used={'steps': int(params.get('steps', 24)),
636+
'relax_hydrogens': bool(params.get('relax_hydrogens', True)),
637+
'max_torsions': int(params.get('max_torsions', 12)),
638+
'variant': params.get('variant', 'MMFF94s')},
639+
cache='computed')
640+
641+
642+
def torsion_estimate(payload: dict) -> dict:
643+
return {'available': False,
644+
'reason': 'cost depends on the number of rotatable bonds and MMFF convergence'}
645+
646+
647+
def _raise_physics_failure(exc: Exception) -> None:
648+
"""Translate producer-native refusals once, at the science boundary."""
649+
from physics.mep_surface import PhysicsBudgetExceeded
650+
if isinstance(exc, failures.DiracFailure):
651+
raise exc
652+
message = str(exc)
653+
lower = message.lower()
654+
if isinstance(exc, PhysicsBudgetExceeded) or 'budget' in lower or 'predicted' in lower:
655+
raise failures.DiracBudgetExceeded(message) from exc
656+
if 'did not converge' in lower:
657+
raise failures.DiracUnconverged(message) from exc
658+
if 'parse' in lower or 'molfile' in lower and 'no 3d' not in lower:
659+
raise failures.DiracParseFailure(message) from exc
660+
if 'exceeds' in lower and 'atoms' in lower:
661+
raise failures.DiracTooLarge(message) from exc
662+
if 'type' in lower or 'force field' in lower:
663+
raise failures.DiracUnparameterized(message) from exc
664+
raise failures.DiracUnsupported(message) from exc

‎backend/invocation.py‎

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from __future__ import annotations
4242

4343
import sys
44+
import threading
4445
import time
4546
from dataclasses import dataclass, field
4647
from typing import Any, Callable, Protocol
@@ -140,6 +141,8 @@ def __init__(self, catalog: C.MethodCatalog, *,
140141
self.cache = cache
141142
self.executor = executor or E.InlineExecutor()
142143
self.toolkit_versions = toolkit_versions or {}
144+
self._futures: dict[str, Any] = {}
145+
self._futures_lock = threading.Lock()
143146
self.counters = {'invoked': 0, 'cache_hit': 0, 'refused': 0, 'failed': 0,
144147
'artifacts_registered': 0}
145148

@@ -211,18 +214,96 @@ def get_job(self, job_id: str) -> dict:
211214
return row
212215

213216
def cancel_job(self, job_id: str) -> dict:
217+
with self._futures_lock:
218+
future = self._futures.get(job_id)
219+
if future is not None and not future.running() and not future.done():
220+
future.cancel()
214221
row = self.ledger.request_cancel(job_id) if self.ledger is not None and \
215222
hasattr(self.ledger, 'request_cancel') else None
216223
if row is None:
217224
raise failures.DiracNotFound(
218225
f'no job {job_id!r}', details={'job_id': job_id})
219226
return row
220227

228+
def wait_job(self, job_id: str, *, timeout: float = 300.0,
229+
poll: float = 0.1) -> dict:
230+
"""Wait for a handle without coupling the caller to executor internals."""
231+
deadline = time.time() + max(0.0, float(timeout))
232+
while True:
233+
row = self.get_job(job_id)
234+
if row.get('state') in ('done', 'failed', 'cancelled'):
235+
return row
236+
if time.time() >= deadline:
237+
row['wait'] = {'timed_out': True, 'timeout_seconds': float(timeout)}
238+
return row
239+
time.sleep(max(0.01, float(poll)))
240+
241+
def submit(self, method_id: str, payload: dict, *,
242+
inline_max: int | None = None,
243+
budget_seconds: float | None = None,
244+
request_id: str | None = None) -> dict:
245+
"""Create a reconnectable queued Job and execute it on the injected executor."""
246+
spec = self.catalog.get(method_id)
247+
self.catalog.validate(method_id, payload)
248+
spec.handler() # fail before minting a handle that can never run
249+
if 'job' not in spec.execution.get('supported_modes', []):
250+
raise failures.DiracUnsupported(
251+
f'{method_id} does not declare job execution',
252+
details={'method_id': method_id,
253+
'supported_modes': spec.execution.get('supported_modes', [])})
254+
if self.ledger is None:
255+
raise failures.DiracInternal(
256+
'job submission requires a JobStore; this kernel has none')
257+
if not getattr(self.executor, 'supports_submission', False):
258+
raise failures.DiracInternal(
259+
'job submission requires an executor with submit(); '
260+
f'got {getattr(self.executor, "kind", type(self.executor).__name__)}')
261+
budget = budget_seconds
262+
if budget is None:
263+
budget = float((payload.get('parameters') or {}).get('max_seconds')
264+
or spec.execution.get('default_budget_seconds') or 0.0) or None
265+
job_id, conflicted = self._open_job(spec, payload, budget, queued=True)
266+
if job_id is None:
267+
raise failures.DiracInternal(
268+
'the JobStore could not create or resolve a durable job handle')
269+
if not conflicted:
270+
future = self.executor.submit(
271+
self._run_submitted, job_id, method_id, payload,
272+
inline_max, budget, request_id)
273+
with self._futures_lock:
274+
self._futures[job_id] = future
275+
future.add_done_callback(lambda _f, jid=job_id: self._forget_future(jid))
276+
return {
277+
'ok': True,
278+
'data': {'job': self.get_job(job_id)},
279+
'artifacts': [], 'warnings': [],
280+
'meta': {'envelope': 2, 'method_id': method_id,
281+
'version': spec.version, 'job_id': job_id,
282+
'execution_mode': 'job', 'deduplicated': conflicted,
283+
'request_id': request_id},
284+
}
285+
286+
def _run_submitted(self, job_id: str, method_id: str, payload: dict,
287+
inline_max: int | None, budget_seconds: float | None,
288+
request_id: str | None) -> None:
289+
row = self.ledger.get(job_id)
290+
if row is None or row.get('state') == 'cancelled':
291+
return
292+
self.ledger.start(job_id)
293+
self.invoke(method_id, payload, inline_max=inline_max,
294+
budget_seconds=budget_seconds, request_id=request_id,
295+
_preopened_job_id=job_id)
296+
297+
def _forget_future(self, job_id: str) -> None:
298+
with self._futures_lock:
299+
self._futures.pop(job_id, None)
300+
221301
# ── the write side ────────────────────────────────────────────────────────
222302
def invoke(self, method_id: str, payload: dict, *,
223303
inline_max: int | None = None,
224304
budget_seconds: float | None = None,
225-
request_id: str | None = None) -> dict:
305+
request_id: str | None = None,
306+
_preopened_job_id: str | None = None) -> dict:
226307
"""Run a method and return a v2 envelope. Never raises for a REFUSAL.
227308
228309
A refusal is a RESULT — the molecule is too large, the basis does not cover
@@ -237,7 +318,7 @@ def invoke(self, method_id: str, payload: dict, *,
237318
"""
238319
t0 = time.time()
239320
self.counters['invoked'] += 1
240-
job_id = None
321+
job_id = _preopened_job_id
241322
spec = None
242323
try:
243324
spec = self.catalog.get(method_id)
@@ -265,12 +346,16 @@ def invoke(self, method_id: str, payload: dict, *,
265346
# the COMPUTED path while cache hits are the majority of responses. A rule
266347
# that applies to the minority of answers is not a rule.
267348
self.catalog.validate_output(method_id, hit.result)
268-
return self._envelope(spec, hit, t0, cache='db',
269-
inline_max=inline_max, request_id=request_id,
270-
job_id=None)
349+
env = self._envelope(spec, hit, t0, cache='db',
350+
inline_max=inline_max, request_id=request_id,
351+
job_id=job_id)
352+
if job_id is not None and self.ledger is not None:
353+
self.ledger.done(job_id, seconds=round(time.time() - t0, 3),
354+
result_summary={'ok': True, 'cache': 'db'})
355+
return env
271356

272-
if self.ledger is not None:
273-
job_id = self._open_job(spec, payload, budget)
357+
if self.ledger is not None and job_id is None:
358+
job_id, _ = self._open_job(spec, payload, budget)
274359

275360
ctx = InvocationContext(
276361
method_id=method_id, version=spec.version, budget_seconds=budget,
@@ -346,7 +431,7 @@ def invoke(self, method_id: str, payload: dict, *,
346431

347432
# ── internals ─────────────────────────────────────────────────────────────
348433
def _open_job(self, spec: C.MethodSpec, payload: dict,
349-
budget: float | None) -> str | None:
434+
budget: float | None, *, queued: bool = False) -> tuple[str | None, bool]:
350435
"""Open a ledger row, and never let the ledger break the invocation.
351436
352437
A job row is OBSERVABILITY. If the database is down, the science must still
@@ -357,18 +442,18 @@ def _open_job(self, spec: C.MethodSpec, payload: dict,
357442
import hashlib
358443
import json as _json
359444
canonical = _json.dumps(payload, sort_keys=True, separators=(',', ':'))
360-
job_id, _conflict = self.ledger.open(
445+
job_id, conflict = self.ledger.open(
361446
method_row_id=getattr(self.ledger, 'method_row_for',
362447
lambda _m: None)(spec.method_id),
363448
input_sha256=hashlib.sha256(canonical.encode()).digest(),
364449
params=dict(payload.get('parameters') or {}),
365-
budget_seconds=budget, queued=False)
366-
return job_id
450+
budget_seconds=budget, queued=queued)
451+
return job_id, conflict
367452
except Exception as e: # noqa: BLE001
368453
print(f'[invoke] ledger unavailable ({type(e).__name__}: {e}) — running '
369454
f'anyway, and provenance will say job_id: null', file=sys.stderr,
370455
flush=True)
371-
return None
456+
return None, False
372457

373458
def _require_declared_artifacts(self, spec: C.MethodSpec,
374459
out: HandlerResult) -> None:

‎backend/jobs.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ def done(self, job_id: str | None, *, seconds: float,
240240
_bump('write_failed', f'{type(e).__name__}: {e}')
241241

242242
def failed(self, job_id: str | None, *, code: str, detail: str,
243-
seconds: float | None = None) -> None:
243+
seconds: float | None = None, retryable: bool | None = None) -> None:
244244
"""Terminal-failed. `code` is mapped onto app.job_error, never raising.
245245
246246
A CANCELLED code is stored as state='cancelled', because the schema's
@@ -533,7 +533,7 @@ def done(self, job_id: str | None, *, seconds: float,
533533
finished_at=_now(), result_summary=result_summary)
534534

535535
def failed(self, job_id: str | None, *, code: str, detail: str,
536-
seconds: float | None = None) -> None:
536+
seconds: float | None = None, retryable: bool | None = None) -> None:
537537
with self._lock:
538538
row = self._rows.get(job_id or '')
539539
if row and row['state'] in ('queued', 'running'):

0 commit comments

Comments
 (0)