Skip to content

Commit 2c2dcf3

Browse files
committed
[act.dirac.ρ.D8+] collapse campaign setup into resolvable scientific inputs
A novice should not need to translate names, database IDs, assay tables, or a parent structure into private backend vocabulary before using the workbench. Browser acceptance exposed title-only target false positives: a binder could outrank the named protein. The shipped route therefore resolves external identities, hydrates assay context, generates and audits analogue series, and compares receptor constructs using source metadata rather than labels alone.
1 parent 06b2926 commit 2c2dcf3

9 files changed

Lines changed: 6135 additions & 3735 deletions

File tree

‎docs/architecture/dirac-digital-twin.html‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎docs/architecture/dirac-digital-twin.json‎

Lines changed: 5902 additions & 3722 deletions
Large diffs are not rendered by default.

‎src/app.frontend.facets.molstar-rdkit.editable/fep-workbench/fep-workbench.css‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/app.frontend.facets.molstar-rdkit.editable/fep-workbench/fep-workbench.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { CAMPAIGN_CACHE_KEYS, createCampaignState, draftFromCampaignEnvelope } f
1717
import { createStoredPreparationReceipt, exactPreparationResultFrom, preparationElapsedSeconds, preparationReceiptMatchesOpenCampaign, preparationResultMatchesOpenCampaign, preparationSubmissionLockName, preparedSystemFromPreparationResult, submitPreparationExactlyOnce } from './workbench-preparation';
1818
import { applyGuidedExample,builderReadinessCopy,campaignEstimate,clearGuidedCampaignForm,ligandIdentityCardHtml,renderBuilderGuide,renderBuilderGuideProgress,renderCampaignEstimate,renderDecisionValidation,renderLigandAuditRows,renderParentCompoundOptions,restoreParentCompoundSelection,T4L_EIGHT_LIGAND_EXAMPLE,validateDecisionInputs,type BuilderGuideStep,type BuilderUxMode } from './workbench-guided-build';
1919
import { bindTargetStructureControls,fetchPdbExperimentalRecord,inspectBoundLigands,type BoundLigandCandidate } from './workbench-target-search';
20+
import { bindCompoundWorkflow,morganSimilarity,type CompoundCandidate } from './workbench-compound-tools';
2021
import type { AtomInfo, Bond, BuilderStage, CampaignDraftV2, Compound, DepictionContract, Edge, ExecutionContract, PreparedSystemOption, RunJob, Network } from './workbench-types';
2122
const query = new URLSearchParams(location.search);
2223
const resultShowcase=query.get('showcase')==='results';
@@ -756,7 +757,7 @@ async function validateBuilderLigands():Promise<{rows:Array<{id:string;smiles:st
756757
if (stereoState.unavailable)rowError='Browser could not produce a CIP/E-Z witness';
757758
else if (stereoState.unknown&&policy.stereo==='preserve_block_unknown')rowError='Unknown stereochemistry is blocked by PRESERVE SPECIFIED · BLOCK UNKNOWN';
758759
else { valid++; outcome=stereoState.unknown?'VALID · UNKNOWN STEREO WILL BE SERVER-ENUMERATED':'VALID · READY FOR SERVER MICROSTATE PREPARATION'; }
759-
} catch (error) { const raw=error instanceof Error?error.message:String(error); rowError=/could not parse/i.test(raw)?`“${row.smiles}” is not valid SMILES. Chemical names are not accepted here; use a SMILES string, Draw, or Upload SDF/MOL.`:raw; }
760+
} catch (error) { const raw=error instanceof Error?error.message:String(error); rowError=/could not parse/i.test(raw)?`“${row.smiles}” is not valid SMILES. Search, draw, or upload it instead.`:raw; }
760761
}
761762
if (rowError) { errors++; outcome=`ERROR · ${rowError}`; }
762763
rendered.push(ligandIdentityCardHtml({ id: row.id,sourceLine: row.sourceLine,error: rowError,depiction,input: row.smiles||row.raw,canonical,charge,stereo,protonation: policy.protonation,tautomer: policy.tautomer,outcome }));
@@ -766,9 +767,21 @@ async function validateBuilderLigands():Promise<{rows:Array<{id:string;smiles:st
766767
const priorParent=(document.getElementById('parent-compound-select') as HTMLSelectElement|null)?.value||''; renderParentCompoundOptions(document,errors?[]:inputRows.map(row=>row.id),priorParent);
767768
validatedLigandSignature=capturedSignature; validatedLigandCount=valid; validatedLigandRowCount=inputRows.length; validatedLigandErrorCount=errors; text('ligand-valid-count',String(valid)); text('ligand-charge-count',String(charged)); text('ligand-count-label',errors?`${errors} ERROR${errors===1?'':'S'}`:`${valid} VALID`); heavy.sort((a,b)=>a-b); text('ligand-heavy-median',heavy.length?String(heavy[Math.floor(heavy.length/2)]):'—');
768769
updateBuilderReadiness(valid);
769-
showBuilderNotice(errors===0&&valid===inputRows.length&&valid>=2?`${valid} complete ligand identities are browser-audited. Server protonation/tautomer work remains explicitly unverified until preparation.`:`${errors} explicit ligand row/policy error${errors===1?'':'s'} · review is blocked until every non-empty row is VALID.`);
770+
showBuilderNotice(errors===0&&valid===inputRows.length&&valid>=2?`${valid} ligand identities validated; preparation will resolve microstates.`:`${errors} ligand error${errors===1?'':'s'} · fix every row before review.`);
770771
return { rows: inputRows.map(({ id,smiles })=>({ id,smiles })),valid,charged };
771772
}
773+
async function appendResolvedCompound(candidate:CompoundCandidate):Promise<void> {
774+
if (!invalidateScientificState('ligands',`${candidate.sourceId} added`)) throw new Error('campaign locked');
775+
const textarea=document.getElementById('campaign-ligands') as HTMLTextAreaElement|null; if (!textarea) throw new Error('editor unavailable');
776+
const used=new Set(textLigandRows().map(row=>row.id)),base=candidate.id; let id=base,suffix=2; while (used.has(id))id=`${base}-${suffix++}`;
777+
textarea.value=[textarea.value.trim(),`${id} ${candidate.smiles}`].filter(Boolean).join('\n'); invalidateLigandValidation(); await validateBuilderLigands(); showBuilderNotice(`${id} added from ${candidate.sourceId}; confirm its identity below.`);
778+
}
779+
async function replaceImportedSeries(rows:Array<{id:string;smiles:string}>,context:{parentId?:string}):Promise<void> {
780+
if (!rows.length) throw new Error('empty'); if (!invalidateScientificState('ligands','series import')) throw new Error('locked');
781+
const textarea=document.getElementById('campaign-ligands') as HTMLTextAreaElement|null; if (!textarea) throw new Error('ligand editor is unavailable'); textarea.value=rows.map(row=>`${row.id} ${row.smiles}`).join('\n');
782+
invalidateLigandValidation(); const checked=await validateBuilderLigands(); if (checked.valid!==rows.length) throw new Error(`${rows.length-checked.valid} structures need correction`);
783+
if (context.parentId) { restoreParentCompoundSelection(document,context.parentId); document.getElementById('parent-compound-select')?.dispatchEvent(new Event('change')); } updateBuilderReadiness(checked.valid);
784+
}
772785
function clearCampaign():void {
773786
if (physicalRunActive()) { showBuilderNotice(`Campaign clear refused while RunSet ${activeRunId||'creation receipt'} may be creating or physically active. No browser state or durable receipt was removed.`); return; }
774787
const planner=readPlannerReceipt(); if (planner) { showBuilderNotice(`Campaign clear refused while planner receipt ${planner.job_id||planner.owner_token} is unresolved. Reconcile it, or use the explicit archive control; no state was removed.`); syncPlannerRecoveryControl(); return; }
@@ -810,7 +823,8 @@ async function inspectPdb(pdb:string,preferredReference=''):Promise<boolean> {
810823
const reference=selectedReferenceLigand(); text('pose-reference-state',reference?.label||'NO REFERENCE · ALIGNMENT UNAVAILABLE'); text('receptor-preview-title',`${pdb} · ${title}`); text('receptor-preview-detail',`${method}${Number.isFinite(resolution)?` · ${Number(resolution).toFixed(2)} Å`:''} · ${boundLigands.length} bound organic candidate${boundLigands.length===1?'':'s'} found`); updateBuilderReadiness(); showBuilderNotice(reference?`${pdb} loaded · ${reference.resname} selected as the drug-like bound reference · verify identity before preparation.`:`${pdb} coordinates loaded. Choose the bound ligand that corresponds to your reference compound.`); return true;
811824
} catch { if (requestId!==pdbFetchGeneration||epoch!==draftEpoch) return false; text('receptor-preview-title',`${pdb} · STRUCTURE NOT FOUND`); text('receptor-preview-detail',receptorPdbText?'Existing receptor was preserved; the failed response was not applied.':'No structure was loaded. Check the PDB accession at RCSB, or upload a PDB file.'); updateBuilderReadiness(); showBuilderNotice(`RCSB could not load ${pdb}. Check the accession or upload the structure file.`); return false; } finally { if (requestId===pdbFetchGeneration) { button.disabled=false; button.textContent='FETCH & INSPECT'; } }
812825
}
813-
bindTargetStructureControls(document,{ loadStructure: pdb=>inspectPdb(pdb),locked: runContextLocked,notify: showBuilderNotice });
826+
bindTargetStructureControls(document,{ loadStructure: pdb=>inspectPdb(pdb),locked: runContextLocked,notify: showBuilderNotice,referenceSmiles: ()=>{ const parent=(document.getElementById('parent-compound-select') as HTMLSelectElement|null)?.value||''; return textLigandRows().find(row=>row.id===parent)?.smiles||''; },similarity: morganSimilarity });
827+
bindCompoundWorkflow(document,{ locked: runContextLocked,notify: showBuilderNotice,getRows: ()=>textLigandRows().map(({ id,smiles })=>({ id,smiles })),getParent: ()=>(document.getElementById('parent-compound-select') as HTMLSelectElement|null)?.value||'',appendLigand: appendResolvedCompound,replaceSeries: replaceImportedSeries });
814828
document.getElementById('inspect-pdb')?.addEventListener('click',async()=>{
815829
const pdb=(document.getElementById('campaign-pdb') as HTMLInputElement|null)?.value.trim().toUpperCase()||'';
816830
await inspectPdb(pdb);
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe,expect,it,jest } from '@jest/globals';
2+
import { chemblCandidate,parseAssayCsv,pubChemCandidates,resolveAssayRows,resolveCompoundQuery,transformationRisk } from './workbench-compound-tools';
3+
4+
describe('novice compound workflow',()=>{
5+
it('turns PubChem identity records into exact visible structures',()=>{
6+
expect(pubChemCandidates({ PropertyTable: { Properties: [{ CID: 2244,Title: 'Aspirin',MolecularFormula: 'C9H8O4',SMILES: 'CC(=O)Oc1ccccc1C(=O)O',Charge: 0 }] } })).toEqual([expect.objectContaining({ id: 'ASPIRIN',sourceId: 'CID 2244',smiles: 'CC(=O)Oc1ccccc1C(=O)O',charge: 0 })]);
7+
expect(chemblCandidate({ molecule_chembl_id: 'CHEMBL25',pref_name: 'ASPIRIN',molecule_structures: { canonical_smiles: 'CC(=O)Oc1ccccc1C(=O)O' },molecule_properties: { full_molformula: 'C9H8O4',full_molcharge: 0 } })).toMatchObject({ id: 'CHEMBL25',source: 'CHEMBL' });
8+
});
9+
10+
it('resolves a name and a CID through the official PubChem property shape',async()=>{
11+
const mock=jest.fn(async()=>new Response(JSON.stringify({ PropertyTable: { Properties: [{ CID: 2244,Title: 'Aspirin',SMILES: 'CCO',Charge: 0 }] } }),{ status: 200 })),fetcher=mock as unknown as typeof fetch;
12+
await expect(resolveCompoundQuery('aspirin',fetcher)).resolves.toMatchObject([{ sourceId: 'CID 2244' }]);
13+
await expect(resolveCompoundQuery('CID 2244',fetcher)).resolves.toMatchObject([{ sourceId: 'CID 2244' }]);
14+
const calls=mock.mock.calls as unknown as Array<[string]>; expect(String(calls[0][0])).toContain('/name/aspirin/property/'); expect(String(calls[1][0])).toContain('/cid/2244/property/');
15+
});
16+
17+
it('parses quoted assay CSV and preserves endpoint, reference and priority',()=>{
18+
const rows=parseAssayCsv('compound_id,smiles,endpoint,value,unit,is_reference,priority\nPARENT,"CC(=O)O",IC50,12,nM,yes,HIGH\nA2,CCO,IC50,35,nM,no,MEDIUM');
19+
expect(rows).toEqual([
20+
{ id: 'PARENT',smiles: 'CC(=O)O',query: '',endpoint: 'IC50',activity: '12',unit: 'nM',reference: true,priority: 'HIGH' },
21+
{ id: 'A2',smiles: 'CCO',query: '',endpoint: 'IC50',activity: '35',unit: 'nM',reference: false,priority: 'MEDIUM' },
22+
]);
23+
});
24+
25+
it('resolves identifier-only assay rows while preserving explicit IDs',async()=>{
26+
const fetcher=jest.fn(async()=>new Response(JSON.stringify({ PropertyTable: { Properties: [{ CID: 2244,Title: 'Aspirin',SMILES: 'CCO',Charge: 0 }] } }),{ status: 200 })) as unknown as typeof fetch;
27+
await expect(resolveAssayRows(parseAssayCsv('id,cid,reference\nLead,2244,true'),fetcher)).resolves.toMatchObject([{ id: 'LEAD',smiles: 'CCO',reference: true }]);
28+
});
29+
30+
it('makes low similarity, charge, and stereo changes visibly risky',()=>{
31+
expect(transformationRisk('CCO',{ id: 'A',name: 'A',smiles: 'CCCO',formula: '',charge: 0,source: 'PUBCHEM',sourceId: 'CID 1',sourceUrl: '',similarity: .9 })).toBe('LOW');
32+
expect(transformationRisk('CCO',{ id: 'A',name: 'A',smiles: 'CC[C@H](O)C',formula: '',charge: 0,source: 'PUBCHEM',sourceId: 'CID 1',sourceUrl: '',similarity: .9 })).toBe('HIGH');
33+
expect(transformationRisk('CCO',{ id: 'A',name: 'A',smiles: 'CC[NH3+]',formula: '',charge: 1,source: 'PUBCHEM',sourceId: 'CID 1',sourceUrl: '',similarity: .9 })).toBe('HIGH');
34+
});
35+
36+
});

0 commit comments

Comments
 (0)