Skip to content

Commit c62187d

Browse files
committed
[fix.fields.rho.D8+] a warm is not an interaction, so it must not inherit an interaction's budget
Ivan: qm potential 还是没有缓存,把所有的都缓存好. The warm ran and lapatinib — the interview molecule, the only halogenated one — cached nothing. Two causes, one of them mine: ① The warm clicked the BUTTONS, so it inherited the panel's 60 s client budget. Lapatinib came back "SCF exceeded its 60 s budget after 10 cycles (234 basis functions)". A warm has nobody waiting on it; the only correct budget is a generous one. It now POSTs directly with max_seconds=900. It still needs the app's EXACT molfile, because the durable cache is keyed on sha256(molfile) and the app's is reconstructed by mol* from scene loci. window.diracFields exposes the molfile the panel would send, so the warm writes keys the app will actually ask for. ② I RESTARTED THE FIELDS DAEMON REPEATEDLY WHILE THE WARM WAS IN FLIGHT. Four of lapatinib's six fields read "Backend unreachable". Those rows never had a chance and the failure was mine, not the molecule's — worth writing down because the screenshot blames the chemistry and the log blames the operator. WATER, and this one is a refusal that was RIGHT for the wrong-looking reason. The pocket field refused with "no AMBER template charge for HOH:O". AMBER does carry water — WAT:OW -0.834 plus two WAT:HW +0.417, summing to zero as it must. But a crystallographic water is ONE OXYGEN: the hydrogens were never resolved. Mapping HOH:O onto OW would contribute a fictitious -0.834 MONOPOLE per water, and an electrostatic field is dominated by its monopoles — six pocket waters would be five charge units of pure invention sitting on top of the answer. Placing the hydrogens is not a repair either: a water's entire electrostatic identity is its dipole DIRECTION, so inventing an orientation points it confidently the wrong way. Waters are excluded, counted, and the reason rides on the response. CURRENCY GUARD on both physics SCF paths, from a peer's frontend state audit. runSurface/runTorsion committed a multi-second result with no check that the ligand was still the one asked about: click A, click B while A is solving, and A's number renders into B's scene with nothing about it looking wrong. field-wells has had this guard since this morning; this facet, written hours later, did not. Third instance today of a fix landing on one path of two. isCurrentLigandGeneration is exported deliberately so gate 8 can mechanise ONE invariant — every SCF-reaching path is bounded AND its result is checked before it lands — instead of two with only one enforced.
1 parent 68c48a3 commit c62187d

4 files changed

Lines changed: 109 additions & 17 deletions

File tree

‎backend/field_server.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,6 +1257,8 @@ def field_quantum(mol: Chem.Mol, kind: str, basis: str,
12571257
_charge_table: dict[tuple[str, str], float] | None = None
12581258
_charge_table_lock = threading.Lock()
12591259
CHARGE_FORCEFIELD = 'AMBER'
1260+
# Excluded from a pocket source unless the caller supplies hydrogens.
1261+
WATER_RESNAMES = {'HOH', 'WAT', 'DOD', 'H2O', 'TIP', 'TP3', 'SOL'}
12601262

12611263

12621264
def charge_table() -> dict[tuple[str, str], float]:
@@ -1293,6 +1295,20 @@ def resolve_charges(sources: list[dict]) -> tuple[list[float], list[str]]:
12931295
charges: list[float] = []
12941296
missing: list[str] = []
12951297
for a in sources:
1298+
# WATER IS EXCLUDED, and mapping it would be worse than refusing it.
1299+
# AMBER's water is WAT:OW -0.834 plus two WAT:HW +0.417 — neutral, as
1300+
# it must be. A crystallographic water is ONE OXYGEN: the hydrogens
1301+
# were never resolved. Mapping HOH:O onto OW therefore contributes a
1302+
# fictitious -0.834 MONOPOLE per water, and an electrostatic field is
1303+
# dominated by its monopoles — six pocket waters would be five charge
1304+
# units of invention sitting on top of the answer.
1305+
#
1306+
# Placing the hydrogens is not a repair either: a water's entire
1307+
# electrostatic identity is the direction of its dipole, so inventing
1308+
# an orientation points it confidently the wrong way.
1309+
if str(a.get('resname', '')).upper() in WATER_RESNAMES:
1310+
charges.append(0.0)
1311+
continue
12961312
if a.get('charge') is not None:
12971313
charges.append(float(a['charge']))
12981314
continue
@@ -1358,6 +1374,11 @@ def field_region(sources, lo, hi, spacing: float, kind: str):
13581374

13591375
# Charges come from the residue template when the caller sent residue
13601376
# identity instead of a number. Unresolved atoms are named, not zeroed.
1377+
n_water = sum(1 for a in sources
1378+
if str(a.get('resname', '')).upper() in WATER_RESNAMES)
1379+
if kind == 'mep' and n_water:
1380+
sources = [a for a in sources
1381+
if str(a.get('resname', '')).upper() not in WATER_RESNAMES]
13611382
resolved, missing = (resolve_charges(sources) if kind == 'mep'
13621383
else ([None] * len(sources), []))
13631384
if missing:
@@ -1433,6 +1454,12 @@ def field_region(sources, lo, hi, spacing: float, kind: str):
14331454
'net_charge': round(float(weights.sum()), 3),
14341455
'charge_model': (f'{CHARGE_FORCEFIELD} residue templates (pdb2pqr)'
14351456
if kind == 'mep' else 'caller-supplied'),
1457+
'waters_excluded': n_water,
1458+
'waters_note': (f'{n_water} crystallographic water(s) left OUT: their '
1459+
f'hydrogens were never resolved, and a bare oxygen would '
1460+
f'contribute a fictitious monopole while an invented '
1461+
f'orientation would point the dipole confidently wrong.'
1462+
if n_water else None),
14361463
'dims': dims.tolist(), **grid_spacing_meta(lo, hi, dims, spacing),
14371464
'vmin': float(v.min()), 'vmax': float(v.max()),
14381465
'iso_fixed': iso,

‎scripts/warm-fields.sh‎

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -64,22 +64,32 @@ cat > "$STAGE/index.html.driver" <<EOF
6464
if (/no ligand/i.test(summary)) {
6565
log.push('NO LIGAND — nothing to precompute');
6666
} else {
67-
for (var f = 0; f < FIELDS.length; f++) {
68-
var btn = document.querySelector('.field-btn[data-field="' + FIELDS[f] + '"]');
69-
if (!btn) { log.push(FIELDS[f] + ':no-button'); continue; }
70-
for (var w = 0; w < 30 && btn.disabled; w++) await wait(1000);
71-
if (btn.disabled) { log.push(FIELDS[f] + ':still-disabled'); continue; }
72-
btn.click();
73-
var done = false;
74-
for (var k = 0; k < 240; k++) {
75-
await wait(1000);
76-
var st = txt('#field-status');
77-
if (/rendered/i.test(st)) { log.push(FIELDS[f] + ':OK'); done = true; break; }
78-
if (/refus|unreach|cannot|exceed|budget|not quotable/i.test(st)) {
79-
log.push(FIELDS[f] + ':REFUSED(' + st.slice(0, 60) + ')'); done = true; break;
80-
}
67+
// POST DIRECTLY, with the app's own molfile and a budget no interactive
68+
// click should ever have. Clicking the buttons warms the same rows but
69+
// inherits the panel's 60 s client budget — which is why lapatinib came
70+
// back "SCF exceeded its 60 s budget after 10 cycles" and cached nothing.
71+
// A warm is not an interaction: nobody is waiting, so the only correct
72+
// budget is a generous one.
73+
var hook = window.diracFields;
74+
if (!hook || !hook.molfile) {
75+
log.push('NO MOLFILE HOOK');
76+
} else {
77+
for (var f = 0; f < FIELDS.length; f++) {
78+
var kind = FIELDS[f];
79+
try {
80+
var r = await fetch(hook.backend + '/field', {
81+
method: 'POST', headers: { 'Content-Type': 'application/json' },
82+
body: JSON.stringify({ molfile: hook.molfile, kind: kind,
83+
basis: 'sto-3g', store: true,
84+
max_seconds: 900 }),
85+
});
86+
var j = await r.json();
87+
log.push(kind + (j.ok ? ':OK' : ':REFUSED(' + String(j.error).slice(0, 50) + ')'));
88+
} catch (e) { log.push(kind + ':ERR(' + e + ')'); }
8189
}
82-
if (!done) log.push(FIELDS[f] + ':TIMEOUT');
90+
// and render one so the screenshot shows a real field
91+
var b0 = document.querySelector('.field-btn[data-field="mep"]');
92+
if (b0 && !b0.disabled) { b0.click(); await wait(6000); }
8393
}
8494
// The physics panel too — same molecule, same session, both daemons warm.
8595
var pt = document.querySelector('[data-jump="physics"]');

‎src/app.frontend.facets.molstar-rdkit.editable/facets/field-wells/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,12 @@ export function updateFieldWellsLigand(nextMolfile: string | null, label: string
857857
const changed = nextMolfile !== molfile;
858858
molfile = nextMolfile;
859859
ligandLabel = label;
860+
// Debug/warm hook: exposes the EXACT molfile the panel would send. The
861+
// durable cache is keyed on sha256(molfile), so a warmer that generates its
862+
// own molfile writes rows the app can never hit. Reading this one makes the
863+
// warm exact, and lets it use a budget no interactive click should have.
864+
(window as unknown as { diracFields: unknown }).diracFields =
865+
{ molfile, label: ligandLabel, backend: BACKEND };
860866
const summary = byId('fields-summary');
861867
if (summary) summary.textContent = molfile ? (label ?? 'Ligand') : 'No ligand loaded';
862868
if (changed) {

‎src/app.frontend.facets.molstar-rdkit.editable/facets/ligand-physics/index.ts‎

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,24 @@ let molfile: string | null = null;
7676
let ligandLabel: string | null = null;
7777
let inFlight: AbortController | null = null;
7878
let busy = false;
79+
/**
80+
* Monotonic ligand generation. A multi-second SCF that lands after the user
81+
* has moved on must be DISCARDED, not rendered: it would put a confident
82+
* number from molecule A into molecule B's scene, which is the worst kind of
83+
* stale result because nothing about it looks wrong.
84+
*
85+
* field-wells has carried this guard since this morning; this facet, written
86+
* hours later, did not — the same "fix lands on one path of two" that bit the
87+
* iodine ECP and the SCF deadline today. Found by a peer's state audit.
88+
*
89+
* Exported so the physics-contract gate can mechanise ONE invariant — every
90+
* SCF-reaching path is bounded AND its result is checked before it lands —
91+
* instead of two, only one of which is currently enforced.
92+
*/
93+
let ligandGeneration = 0;
94+
export function isCurrentLigandGeneration(g: number): boolean {
95+
return g === ligandGeneration;
96+
}
7997
/**
8098
* The extrema markers currently in the scene, so a re-run replaces them
8199
* instead of stacking. Typed loosely on purpose: the concrete
@@ -295,14 +313,37 @@ async function markExtrema(extrema: Extremum[]) {
295313
lastMarkers = repr;
296314
}
297315

316+
/**
317+
* The basis the refusal already prescribes.
318+
*
319+
* The cost gate declines a large ligand with a specific, correct remedy — "use a smaller
320+
* basis (sto-3g is ~31 s here)" — and until this control existed the panel offered no way
321+
* to take it. An honest refusal that names an action the interface cannot perform is a
322+
* dead end wearing the manners of a good error message; measured on lapatinib, which is
323+
* 66 atoms and 698 basis functions against a 90 s budget, that dead end was the only
324+
* outcome available.
325+
*/
326+
function selectedBasis(): string {
327+
const el = document.getElementById('phys-basis') as HTMLSelectElement | null;
328+
return el?.value || 'def2-svp';
329+
}
330+
298331
async function runSurface() {
299332
if (!molfile || busy) return;
300333
setBusy(true);
334+
const basis = selectedBasis();
301335
setStatus('phys-surface-status',
302-
`Solving surface electrostatics on ${ligandLabel ?? 'ligand'}… `
336+
`Solving surface electrostatics on ${ligandLabel ?? 'ligand'} in ${basis}… `
303337
+ `(gives up at ${BUDGET_SECONDS} s · Cancel to stop now)`, 'busy');
338+
const generation = ligandGeneration;
304339
try {
305-
const out = await post('/surface/mep', { molfile }, BUDGET_SECONDS);
340+
const out = await post('/surface/mep', { molfile, basis }, BUDGET_SECONDS);
341+
if (!isCurrentLigandGeneration(generation)) {
342+
setStatus('phys-surface-status',
343+
'Ligand changed while solving — result discarded rather than '
344+
+ 'rendered into the wrong molecule.', 'idle');
345+
return;
346+
}
306347
if (!out.ok) {
307348
setStatus('phys-surface-status', out.error, 'error');
308349
return;
@@ -334,8 +375,15 @@ async function runTorsion() {
334375
if (!molfile || busy) return;
335376
setBusy(true);
336377
setStatus('phys-torsion-status', `Scanning rotatable bonds on ${ligandLabel ?? 'ligand'}…`, 'busy');
378+
const generation = ligandGeneration;
337379
try {
338380
const out = await post('/torsion/strain', { molfile }, BUDGET_SECONDS);
381+
if (!isCurrentLigandGeneration(generation)) {
382+
setStatus('phys-torsion-status',
383+
'Ligand changed while scanning — result discarded rather than '
384+
+ 'rendered into the wrong molecule.', 'idle');
385+
return;
386+
}
339387
if (!out.ok) {
340388
setStatus('phys-torsion-status', out.error, 'error');
341389
return;
@@ -404,6 +452,7 @@ export function updateLigandPhysics(nextMolfile: string | null, label: string |
404452
const summary = byId('phys-summary');
405453
if (summary) summary.textContent = molfile ? (label ?? 'Ligand') : 'No ligand loaded';
406454
if (changed) {
455+
ligandGeneration++; // invalidates every in-flight result
407456
inFlight?.abort();
408457
inFlight = null;
409458
for (const id of ['phys-surface-body', 'phys-torsion-body']) {

0 commit comments

Comments
 (0)