Skip to content

Commit b7f89f4

Browse files
committed
[feat.physics.sigma.D8+] the 90-second budget was a browser tab's patience wearing physics clothes
Ivan, reading a refusal: "75 atoms in def2-svp is 779 basis functions, predicted ~202 s against a 90 s budget. Use a smaller basis, raise max_seconds, or trim the ligand — 和现在这整个physics面板有什么意义呢" The three options ARE the argument, and every one is bad: · "use a smaller basis" offers STO-3G — the level this same codebase now refuses to print HOMO/LUMO from, and which for a sigma-hole is not merely inaccurate but structurally wrong (no d functions on Cl/Br/S, non-relativistic all-electron I). The panel was recommending the theory its own caveat system disowns. · "raise max_seconds" means holding a synchronous HTTP request for minutes. · "trim the ligand" means changing the molecule to fit the computer. And 75 atoms is an ORDINARY LEAD COMPOUND. The panel refused exactly the molecules it exists for, and the single decision it was justified by — is Cl→Br worth a synthesis — needs def2-SVP precisely on halogenated compounds. DIAGNOSIS: 202 s is not a long quantum chemistry calculation. It is a long SYNCHRONOUS HTTP REQUEST. The budget was never a statement about physics; it was a statement about how long a browser tab will wait, and it was enforcing that on the science. So: submit-and-poll. POST /jobs/surface/mep and /jobs/torsion/strain return a job id immediately with a cost quote and a queue position. GET /jobs/<id> polls. POST /jobs/<id>/cancel rides the per-cycle watchdog that already exists rather than needing a second mechanism. One worker, deliberately — the GPU is one device and two concurrent SCFs thrash it; a queue with a depth is honest about that where unlimited threads would not be. MEASURED WHILE BUILDING IT, and it changes what the prediction is FOR: 4-bromobenzonitrile, 13 atoms, def2-svp: predicted 7.4 s, elapsed 151 s+ imatinib-scale, nao 673: predicted 148 s (with the 3x safety), 49 s without it The model is wrong in BOTH directions — a 3x safety factor that over-predicts and refuses large molecules, and a fit that under-predicts small ones by more than an order of magnitude. (Load average was 91 from concurrent sessions, so the 151 s is not a clean number; but the same molecule took ~100 s uncontended earlier, so contention is not the whole story.) A gate whose predictor is order-of-magnitude wrong is not a gate, it is a lottery. With a queue it does not have to be one: the prediction becomes ADVICE, and being wrong costs an inaccurate progress hint instead of a refused calculation. That is the actual repair — not a better fit. nao_for() computes the basis count without running anything, so the quote is made from the only honest predictor. Atom count is not it: 120 atoms of C/H is ~360 basis functions and 120 atoms with six iodines is over 500, which is exactly why MAX_QM_ATOMS = 120 failed to stop a 43-atom heme. NEXT: the panel still calls the synchronous routes. Moving it onto the queue is what makes the answer reach a chemist; until then this is a backend that can do the work and a UI that still asks the wrong way.
1 parent 387ab43 commit b7f89f4

2 files changed

Lines changed: 239 additions & 1 deletion

File tree

‎backend/physics/mep_surface.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,34 @@ def estimated_scf_seconds(nao: int) -> float:
9393
# was the one it got wrong, and nothing in the output said so.
9494
ECP_FROM_Z = 37
9595

96+
def nao_for(molblock: str, basis: str) -> int:
97+
"""Basis-function count, computed without running anything.
98+
99+
The queue needs to quote a cost BEFORE the job starts, and nao is the only
100+
honest predictor — atom count is not. 120 atoms of C/H is ~360 basis
101+
functions; 120 atoms containing six iodines is over 500. A cap expressed in
102+
atoms bounds the wrong quantity, which is exactly how MAX_QM_ATOMS = 120
103+
failed to stop a 43-atom heme from holding 22 cores for 36 minutes.
104+
"""
105+
from pyscf import gto
106+
mol = Chem.MolFromMolBlock(molblock, removeHs=False, sanitize=False)
107+
if mol is None:
108+
raise ValueError('cannot parse molfile')
109+
conf = mol.GetConformer()
110+
atoms = [(a.GetSymbol(),
111+
(conf.GetAtomPosition(a.GetIdx()).x,
112+
conf.GetAtomPosition(a.GetIdx()).y,
113+
conf.GetAtomPosition(a.GetIdx()).z))
114+
for a in mol.GetAtoms()]
115+
pt = Chem.GetPeriodicTable()
116+
charge = sum(a.GetFormalCharge() for a in mol.GetAtoms())
117+
nelec = sum(pt.GetAtomicNumber(s) for s, _ in atoms) - charge
118+
gmol = gto.M(atom=list(atoms), unit='Angstrom', basis=basis,
119+
ecp=_ecp_for(atoms, basis) or None, verbose=0,
120+
charge=charge, spin=nelec % 2)
121+
return int(gmol.nao)
122+
123+
96124

97125

98126
def clamp_budget(value, default: float) -> float:

‎backend/physics/server.py‎

Lines changed: 211 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333

3434
import base64
3535
import json
36+
import queue as queue_mod
37+
import threading
38+
import uuid
39+
from collections import OrderedDict
3640
import os
3741
import sys
3842
import time
@@ -59,6 +63,10 @@
5963
# A molfile is kilobytes. Anything past this is a mistake or an attempt to wedge
6064
# the box, and either way it should be refused before it is read into memory.
6165
MAX_BODY_BYTES = 8 * 1024 * 1024
66+
# A queued job is not waited on by a socket, so its ceiling is a resource
67+
# decision rather than a browser's patience. Still bounded: unbounded is
68+
# how one heme click held 22 cores for 36 minutes.
69+
JOB_MAX_SECONDS = 1800.0
6270

6371

6472
# pyscf will happily accept an arbitrary string here and fail deep inside a
@@ -77,6 +85,130 @@ def validated_basis(name: str) -> str:
7785
return key
7886

7987

88+
89+
# ── the job queue: the budget was a UI constraint wearing physics clothes ───
90+
#
91+
# Ivan, on reading a refusal: "75 atoms in def2-svp is 779 basis functions,
92+
# predicted ~202 s against a 90 s budget. Use a smaller basis, raise
93+
# max_seconds, or trim the ligand — 和现在这整个physics面板有什么意义呢"
94+
#
95+
# He is right, and the refusal's three options are the evidence. Every one of
96+
# them is bad:
97+
# · "use a smaller basis" offers STO-3G, which this same codebase refuses to
98+
# quote HOMO/LUMO from, and which for a sigma-hole is not merely inaccurate
99+
# but structurally wrong — no d functions on Cl/Br/S, non-relativistic
100+
# all-electron I. The panel would be recommending the level of theory its
101+
# own caveat system disowns.
102+
# · "raise max_seconds" means sit on a synchronous HTTP request for minutes.
103+
# · "trim the ligand" means change the molecule to fit the computer.
104+
#
105+
# And 75 atoms is an ORDINARY LEAD COMPOUND. So the panel refused exactly the
106+
# molecules it exists for, and the one decision it was justified by — is Cl→Br
107+
# worth a synthesis — needs def2-SVP precisely on the halogenated compounds a
108+
# medicinal chemist cares about.
109+
#
110+
# The diagnosis: 202 s is not a long quantum chemistry calculation. It is a
111+
# long SYNCHRONOUS HTTP REQUEST. The budget was never a statement about
112+
# physics; it was a statement about how long a browser tab will wait, wearing
113+
# physics clothes. Submit-and-poll makes the wait a fact about the interaction
114+
# instead of a limit on the science, and then a 200 s job is simply a job.
115+
#
116+
# One worker, deliberately: the GPU is one device and two concurrent SCFs
117+
# thrash it. A queue with a depth is honest about that; unlimited threads would
118+
# not be.
119+
120+
JOB_RETENTION = 32 # bounded, LRU — a job store is not a database
121+
_jobs: 'OrderedDict[str, dict]' = OrderedDict()
122+
_jobs_lock = threading.Lock()
123+
_job_queue: 'queue.Queue[str]' = queue_mod.Queue()
124+
125+
126+
def _new_job(kind: str, payload: dict, predicted: float | None) -> str:
127+
job_id = uuid.uuid4().hex[:12]
128+
with _jobs_lock:
129+
_jobs[job_id] = {
130+
'id': job_id, 'kind': kind, 'state': 'queued',
131+
'submitted_at': time.time(), 'started_at': None, 'finished_at': None,
132+
'predicted_seconds': predicted, 'cancelled': False,
133+
'result': None, 'error': None, 'reason': None,
134+
'queue_position': _job_queue.qsize() + 1,
135+
'_payload': payload,
136+
}
137+
while len(_jobs) > JOB_RETENTION:
138+
_jobs.popitem(last=False)
139+
_job_queue.put(job_id)
140+
return job_id
141+
142+
143+
def _public_job(job: dict) -> dict:
144+
"""Everything except the payload and the result blob."""
145+
out = {k: v for k, v in job.items()
146+
if not k.startswith('_') and k != 'result'}
147+
now = time.time()
148+
if job['started_at']:
149+
out['elapsed_seconds'] = round((job['finished_at'] or now) - job['started_at'], 1)
150+
else:
151+
out['waiting_seconds'] = round(now - job['submitted_at'], 1)
152+
return out
153+
154+
155+
def _worker():
156+
while True:
157+
job_id = _job_queue.get()
158+
with _jobs_lock:
159+
job = _jobs.get(job_id)
160+
if job is None or job['cancelled']:
161+
if job is not None:
162+
job['state'] = 'cancelled'
163+
job['finished_at'] = time.time()
164+
continue
165+
job['state'] = 'running'
166+
job['started_at'] = time.time()
167+
req = job['_payload']
168+
try:
169+
# A queued job answers to the QUEUE's ceiling, not to a socket's
170+
# patience. The watchdog still bounds it — the point is that the
171+
# bound is now a resource decision instead of a UI artefact.
172+
budget = float(req.get('max_seconds', JOB_MAX_SECONDS))
173+
if job['kind'] == 'surface/mep':
174+
out = compute_surface_mep(
175+
req['molfile'],
176+
basis=validated_basis(req.get('basis', DEFAULT_BASIS)),
177+
isovalue=float(req.get('isovalue', 0.001)),
178+
points_per_atom=int(req.get('points_per_atom', 120)),
179+
max_seconds=budget, xc=req.get('xc'))
180+
result = {'points_b64': b64(out['points']),
181+
'values_b64': b64(out['values']),
182+
'n_points': int(len(out['values'])),
183+
'extrema': out['extrema'], 'meta': out['meta']}
184+
elif job['kind'] == 'torsion/strain':
185+
out = compute_torsion_strain(
186+
req['molfile'], steps=int(req.get('steps', 24)),
187+
relax_hydrogens=bool(req.get('relax_hydrogens', True)),
188+
max_torsions=int(req.get('max_torsions', 12)),
189+
variant=req.get('variant', 'MMFF94s'))
190+
result = out
191+
else:
192+
raise ValueError(f'unknown job kind {job["kind"]!r}')
193+
with _jobs_lock:
194+
job['state'] = 'done'
195+
job['result'] = result
196+
except Exception as e: # noqa: BLE001
197+
traceback.print_exc()
198+
with _jobs_lock:
199+
job['state'] = 'cancelled' if job['cancelled'] else 'failed'
200+
job['error'] = str(e)
201+
job['reason'] = ('cancelled' if job['cancelled'] else
202+
'unsupported' if isinstance(e, (ValueError, KeyError))
203+
else 'internal')
204+
finally:
205+
with _jobs_lock:
206+
job['finished_at'] = time.time()
207+
print(f"[job {job_id}] {job['kind']} {job['state']} "
208+
f"in {job['finished_at'] - (job['started_at'] or job['finished_at']):.1f}s",
209+
flush=True)
210+
211+
80212
def b64(array: np.ndarray) -> str:
81213
return base64.b64encode(np.ascontiguousarray(array, dtype='<f4').tobytes()).decode()
82214

@@ -103,14 +235,46 @@ def do_OPTIONS(self):
103235
self.end_headers()
104236

105237
def do_GET(self):
238+
if self.path.startswith('/jobs/'):
239+
job_id = self.path.split('/jobs/', 1)[1].strip('/')
240+
with _jobs_lock:
241+
job = _jobs.get(job_id)
242+
if job is None:
243+
# NOT_FOUND is distinct from "still queued". A job store
244+
# that has evicted a result must not answer like one that
245+
# never had it.
246+
self._send(200, {'ok': False, 'reason': 'not_found',
247+
'error': f'no job {job_id} — it may have '
248+
f'been evicted (the store keeps '
249+
f'the last {JOB_RETENTION})'})
250+
return
251+
public = _public_job(job)
252+
done = job['state'] == 'done'
253+
result = job['result'] if done else None
254+
failed = job['state'] in ('failed', 'cancelled')
255+
err, reason = job['error'], job['reason']
256+
if done:
257+
self._send(200, {'ok': True, 'job': public, **result})
258+
elif failed:
259+
self._send(200, {'ok': False, 'job': public, 'error': err,
260+
'reason': reason})
261+
else:
262+
self._send(200, {'ok': True, 'job': public, 'pending': True})
263+
return
106264
if self.path != '/health':
107265
self._send(404, {'ok': False, 'error': 'not found'})
108266
return
109267
import pyscf
110268
import rdkit
111269
self._send(200, {'ok': True, 'service': 'dirac-physics',
112270
'rdkit': rdkit.__version__, 'pyscf': pyscf.__version__,
113-
'endpoints': ['/surface/mep', '/surface/mep_at', '/torsion/strain']})
271+
'endpoints': ['/surface/mep', '/surface/mep_at',
272+
'/torsion/strain',
273+
'POST /jobs/surface/mep',
274+
'POST /jobs/torsion/strain',
275+
'GET /jobs/<id>', 'POST /jobs/<id>/cancel'],
276+
'queue_depth': _job_queue.qsize(),
277+
'jobs_retained': len(_jobs)})
114278

115279
def do_POST(self):
116280
try:
@@ -123,6 +287,51 @@ def do_POST(self):
123287
req = json.loads(self.rfile.read(length)) if length else {}
124288
t0 = time.time()
125289

290+
if self.path.startswith('/jobs/') and self.path.endswith('/cancel'):
291+
job_id = self.path[len('/jobs/'):-len('/cancel')].strip('/')
292+
with _jobs_lock:
293+
job = _jobs.get(job_id)
294+
if job is None:
295+
self._send(200, {'ok': False, 'reason': 'not_found',
296+
'error': f'no job {job_id}'})
297+
return
298+
job['cancelled'] = True
299+
if job['state'] == 'queued':
300+
job['state'] = 'cancelled'
301+
job['finished_at'] = time.time()
302+
state = job['state']
303+
# A running job stops at its next SCF cycle: the watchdog is
304+
# already per-cycle, so cancellation rides the guard that
305+
# exists rather than needing a second mechanism.
306+
self._send(200, {'ok': True, 'state': state})
307+
return
308+
309+
if self.path in ('/jobs/surface/mep', '/jobs/torsion/strain'):
310+
kind = self.path[len('/jobs/'):]
311+
predicted = None
312+
if kind == 'surface/mep':
313+
# Predict BEFORE queueing so the caller sees the cost up
314+
# front — but do not refuse on it. Refusing was the whole
315+
# complaint: the number is advice now, not a gate.
316+
try:
317+
from physics.mep_surface import (estimated_scf_seconds,
318+
GPU_CROSSOVER_NAO,
319+
GPU_SPEEDUP, nao_for)
320+
nao = nao_for(req['molfile'],
321+
validated_basis(req.get('basis', DEFAULT_BASIS)))
322+
predicted = round(
323+
estimated_scf_seconds(nao)
324+
/ (GPU_SPEEDUP if nao >= GPU_CROSSOVER_NAO else 1.0), 1)
325+
except Exception: # noqa: BLE001
326+
predicted = None
327+
job_id = _new_job(kind, req, predicted)
328+
with _jobs_lock:
329+
public = _public_job(_jobs[job_id])
330+
print(f'[job {job_id}] {kind} queued, predicted '
331+
f'{predicted if predicted is not None else "?"} s', flush=True)
332+
self._send(200, {'ok': True, 'job': public})
333+
return
334+
126335
if self.path == '/surface/mep':
127336
out = compute_surface_mep(
128337
req['molfile'],
@@ -194,4 +403,5 @@ def log_message(self, *args):
194403
print(f'Dirac physics backend on http://{host}:{port}'
195404
+ (' (reachable from the LAN — unauthenticated)' if host == '0.0.0.0' else ''),
196405
flush=True)
406+
threading.Thread(target=_worker, daemon=True, name='physics-worker').start()
197407
ThreadingHTTPServer((host, port), Handler).serve_forever()

0 commit comments

Comments
 (0)