Skip to content

Commit 211b61a

Browse files
committed
Link project backbones and sequences to shared result workspaces
1 parent a60de35 commit 211b61a

25 files changed

Lines changed: 673 additions & 226 deletions

‎platform/api/routers/designs.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3556,6 +3556,7 @@ async def get_confidence_artifacts(design_id: str, session: AsyncSession = Depen
35563556
raise HTTPException(status_code=404, detail='Design not found')
35573557
job = await session.get(Job, design.job_id)
35583558
entries, producer = [], None
3559+
native_snapshots = None
35593560
try:
35603561
if job and revision_for_job(job) == 1 and job.model_id in ('boltz', 'boltz2'):
35613562
selected = await verified_boltz_design(design, session)
@@ -3566,14 +3567,23 @@ async def get_confidence_artifacts(design_id: str, session: AsyncSession = Depen
35663567
# for PAE and never inferred from a launch/save flag.
35673568
structure_key = Path(keys['structure'])
35683569
keys['pde'] = (structure_key.parent / f'pde_{structure_key.stem}.npz').as_posix()
3570+
elif job and revision_for_job(job) == 1 and job.model_id == 'protenix':
3571+
from services.protenix_scientific_consumer import verified_native_design
3572+
selected = await verified_native_design(design, session)
3573+
native_snapshots = {role: (selected['artifacts'][role], selected['snapshots'][role])
3574+
for role in ('structure', 'metrics', 'pae')}
3575+
producer = 'protenix'
3576+
root = selected['publication_root']
3577+
keys = {role: Path(selected['artifacts'][role]['path']).relative_to(root).as_posix()
3578+
for role in ('structure', 'metrics', 'pae')}
35693579
else:
35703580
found = retained_cp_paths(design, job)
35713581
if found is None:
35723582
return dict(design_id=design_id, status='unavailable', reason='native_inventory_unavailable', artifacts=[])
35733583
root, keys, producer = found
35743584
for role, key in keys.items():
35753585
try:
3576-
artifact, content = _snapshot(root, key)
3586+
artifact, content = native_snapshots[role] if native_snapshots is not None else _snapshot(root, key)
35773587
relative = _safe_allowed_relative(artifact['path'])
35783588
entries.append(dict(role=role, filename=Path(key).name, retained=True,
35793589
artifact_sha256=artifact['sha256'], size_bytes=len(content),

‎platform/api/services/protenix_scientific_consumer.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ def verify_publication(job, design, root, *, confidence=True, scalar_only=False)
126126
matches.append(binding)
127127
if len(matches) != 1:
128128
raise ValueError('missing selected native publication')
129-
selected = dict(design_id=design.id, snapshots=snapshots, artifacts=dict(artifacts),
129+
selected = dict(design_id=design.id, publication_root=root, snapshots=snapshots, artifacts=dict(artifacts),
130130
block=dict(candidate_id=native['producer_output_key'], document_id=native['producer_output_key']),
131131
source_kind='mmcif')
132132
if not confidence:
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Exact original-confidence inventory through the existing download owner."""
2+
import hashlib
3+
import json
4+
import os
5+
from pathlib import Path
6+
7+
from fastapi import FastAPI
8+
from httpx import ASGITransport, AsyncClient
9+
import pytest
10+
from sqlalchemy import create_engine, select
11+
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
12+
13+
from database import Base, Design, Job, get_session
14+
from routers import designs, files
15+
from services import protenix_scientific_consumer as consumer
16+
from test_protenix_native_confidence import make_publication, store
17+
18+
19+
def app_for(session):
20+
app = FastAPI()
21+
app.include_router(designs.router, prefix='/api/designs')
22+
app.include_router(files.router, prefix='/api/files')
23+
async def dependency():
24+
yield session
25+
app.dependency_overrides[get_session] = dependency
26+
app.dependency_overrides[files.get_governed_ngs_result_roots] = lambda: ()
27+
return app
28+
29+
30+
async def check_inventory(session, design_id):
31+
row = await session.get(Design, design_id)
32+
selected = await consumer.verified_native_design(row, session)
33+
async with AsyncClient(transport=ASGITransport(app=app_for(session)), base_url='http://test') as client:
34+
response = await client.get(f'/api/designs/{design_id}/confidence-artifacts')
35+
assert response.status_code == 200, response.text
36+
payload = response.json()
37+
assert payload['status'] == 'ok', payload
38+
assert payload['producer'] == 'protenix'
39+
assert {a['role'] for a in payload['artifacts']} == {'structure', 'metrics', 'pae'}
40+
for artifact in payload['artifacts']:
41+
role = artifact['role']
42+
expected = selected['artifacts'][role]
43+
assert artifact['retained'] and artifact['filename'] == Path(expected['path']).name
44+
download = await client.get(artifact['download_url'])
45+
assert download.status_code == 200, download.text
46+
assert download.content == selected['snapshots'][role]
47+
assert hashlib.sha256(download.content).hexdigest() == artifact['artifact_sha256'] == expected['sha256']
48+
assert len(download.content) == artifact['size_bytes']
49+
assert not session.dirty and not session.new
50+
return payload
51+
52+
53+
@pytest.mark.asyncio
54+
@pytest.mark.parametrize('custody', ['local', 'remote', 'retained_remote'])
55+
@pytest.mark.parametrize('fault', [None, 'missing', 'changed', 'foreign'])
56+
async def test_exact_confidence_inventory(tmp_path, monkeypatch, custody, fault):
57+
import paths
58+
job, design, root = make_publication(tmp_path, custody=custody)
59+
monkeypatch.setattr(paths, 'get_allowed_roots', lambda: {'bms_results': root})
60+
engine, factory = await store(tmp_path, job, design)
61+
try:
62+
async with factory() as session:
63+
row = await session.get(Design, design.id)
64+
selected = await consumer.verified_native_design(row, session)
65+
full = Path(selected['artifacts']['pae']['path'])
66+
# A different input with the same sample suffix is never a fallback.
67+
full.with_name('foreign_full_data_sample_0.json').write_bytes(full.read_bytes())
68+
if fault == 'missing':
69+
full.unlink()
70+
elif fault == 'changed':
71+
full.write_bytes(b'{}')
72+
elif fault == 'foreign':
73+
row.json_path = str(full.with_name('foreign_summary_confidence_sample_0.json'))
74+
await session.commit()
75+
if fault is None:
76+
await check_inventory(session, design.id)
77+
else:
78+
async with AsyncClient(transport=ASGITransport(app=app_for(session)), base_url='http://test') as client:
79+
response = await client.get(f'/api/designs/{design.id}/confidence-artifacts')
80+
assert response.status_code == 200
81+
assert response.json()['status'] == 'unavailable'
82+
assert response.json()['artifacts'] == []
83+
# Optional inventory failure does not remove the structure.
84+
structure = await client.get(f'/api/designs/{design.id}/pdb')
85+
assert structure.status_code == 200
86+
finally:
87+
await engine.dispose()
88+
89+
90+
@pytest.mark.asyncio
91+
async def test_retained_inventory_readonly(tmp_path, monkeypatch):
92+
"""Opt-in real rows copied with Core; retained native files only read."""
93+
database = os.environ.get('BMS_PROTENIX_INVENTORY_DATABASE')
94+
if not database:
95+
pytest.skip('read-only retained Protenix database not selected')
96+
import paths
97+
ids = json.loads(os.environ['BMS_PROTENIX_INVENTORY_DESIGN_IDS'])
98+
assert isinstance(ids, list) and ids and all(isinstance(value, str) for value in ids)
99+
source = create_engine(f'sqlite:///file:{database}?mode=ro&uri=true')
100+
# Copy only exact referenced Parquet files before typed JSON resolution.
101+
import shutil
102+
from sqlalchemy import text
103+
copied_root = tmp_path / 'scientific_artifacts'
104+
monkeypatch.setenv('BMS_SCIENTIFIC_ARTIFACT_ROOT', str(copied_root))
105+
def copy_references(value):
106+
if isinstance(value, str):
107+
try:
108+
value = json.loads(value)
109+
except (ValueError, TypeError):
110+
return
111+
if isinstance(value, dict):
112+
if value.get('schema') == 'bms.scientific-artifact-row-reference.v1':
113+
from services.scientific_artifacts.contracts import require_row_reference
114+
relative = Path(require_row_reference(value)['relative_path'])
115+
assert not relative.is_absolute() and '..' not in relative.parts
116+
destination = copied_root / relative
117+
destination.parent.mkdir(parents=True, exist_ok=True)
118+
shutil.copyfile(Path(database).parent / 'scientific_artifacts' / relative, destination)
119+
for item in value.values():
120+
copy_references(item)
121+
elif isinstance(value, list):
122+
for item in value:
123+
copy_references(item)
124+
with source.connect() as conn:
125+
for design_id in ids:
126+
raw = conn.execute(text('SELECT * FROM designs WHERE id = :id'), {'id': design_id}).mappings().one()
127+
for value in raw.values():
128+
copy_references(value)
129+
design_rows = [dict(r) for r in conn.execute(select(Design.__table__).where(Design.id.in_(ids))).mappings()]
130+
jobs = {row['job_id'] for row in design_rows}
131+
job_rows = [dict(r) for r in conn.execute(select(Job.__table__).where(Job.id.in_(jobs))).mappings()]
132+
source.dispose()
133+
assert len(job_rows) == len(jobs) and len(design_rows) == len(ids)
134+
monkeypatch.setattr(paths, 'get_allowed_roots', lambda: {'bms_results': Path(database).parent / 'bms_results'})
135+
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "retained-copy.sqlite"}')
136+
async with engine.begin() as conn:
137+
await conn.run_sync(Base.metadata.create_all)
138+
# No ORM flush/publication hooks and no live-store writes.
139+
await conn.execute(Job.__table__.insert(), job_rows)
140+
await conn.execute(Design.__table__.insert(), design_rows)
141+
factory = async_sessionmaker(engine, expire_on_commit=False)
142+
try:
143+
async with factory() as session:
144+
reports = [await check_inventory(session, design_id) for design_id in ids]
145+
output = os.environ.get('BMS_PROTENIX_INVENTORY_REPORT')
146+
if output:
147+
Path(output).write_text(json.dumps(reports, indent=2))
148+
finally:
149+
await engine.dispose()

‎platform/frontend/src/components/BindCraft2JobResults.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { useState } from 'react';
2+
import { BinderResultFamily } from './BinderResultFamily';
23
import { NativeBinderGenerationResults, type Props as WorkbenchProps } from './NativeBinderGenerationResults';
34
import { BindCraft2Trajectory, BindCraft2OutcomeSummary } from './BindCraft2Trajectory';
4-
import { bc2Page, bc2Label, fetchBC2Page } from '../lib/bindcraft2Results';
5+
import { bc2Page, bc2Label, bc2MetricLabel, bc2PreferredColumns, bc2PreferredDocument, fetchBC2Page, fetchBC2TrajectoryNames } from '../lib/bindcraft2Results';
56
import { BinderPredictionEvidence } from './BinderPredictionEvidence';
67
import { isAxiosError } from 'axios';
78
import { ExecutionTargetPicker } from './ExecutionTargetPicker';
@@ -128,6 +129,9 @@ export function BindCraft2JobResults({ jobId, resultsAvailable = true, launchCon
128129
queryFn: ({ signal }) => fetchBC2Page(jobId, query.arm, query.stage, 0, signal),
129130
retry: false, refetchOnWindowFocus: false,
130131
});
132+
const names = useQuery({ queryKey: ['bindcraft2-design-names', jobId, query.arm],
133+
enabled: resultsAvailable, retry: false, refetchOnWindowFocus: false,
134+
queryFn: ({ signal }) => fetchBC2TrajectoryNames(jobId, query.arm, signal) });
131135
return <div className="space-y-2 min-w-0">
132136
{data && <div className="flex flex-wrap items-center justify-between gap-3 text-sm">
133137
<div className="flex flex-wrap items-center gap-3">
@@ -151,10 +155,11 @@ export function BindCraft2JobResults({ jobId, resultsAvailable = true, launchCon
151155
const screen = keys.find(key => /^screen · .+\.iptm · last recorded$/.test(key));
152156
const refine = (screen && keys.includes(screen.replace(/^screen/, 'refine')) ? screen.replace(/^screen/, 'refine') : undefined)
153157
?? keys.find(key => /^refine · .+\.iptm · last recorded$/.test(key));
154-
return { x: screen, y: refine ?? screen, distribution: refine ?? screen, headline: [refine ?? screen, 'duration_seconds'].filter((key): key is string => !!key) };
158+
return screen ? { x: screen, y: refine ?? screen, distribution: refine ?? screen, headline: [refine ?? screen, 'duration_seconds'].filter((key): key is string => !!key) }
159+
: { x: keys.includes('pLDDT') ? 'pLDDT' : undefined, y: keys.includes('i_pTM') ? 'i_pTM' : undefined, distribution: keys.includes('i_pTM') ? 'i_pTM' : undefined, headline: ['i_pTM', 'pLDDT'].filter(key => keys.includes(key)) };
155160
},
156161
fetchPage: async (offset, signal) => bc2Page(offset === 0 ? data : await fetchBC2Page(jobId, data.arm, query.stage, offset, signal)),
157-
label: bc2Label, preferredColumns: ['seq_length', 'duration_seconds', 'terminated', ...Object.keys(bc2Page(data).records[0]?.metrics ?? {}).filter(key => /^(?:refine|screen) · .+\.iptm · last recorded$/.test(key)).sort((a, b) => Number(b.startsWith('refine · ')) - Number(a.startsWith('refine · '))).slice(0, 1), ...(query.stage === 'trajectory' ? [] : ['outcome', 'rank'])],
162+
label: row => bc2Label(row, names.data), related: row => <BinderResultFamily jobId={jobId} candidate={row} candidateLabel={bc2Label(row, names.data)} />, preferredColumns: bc2PreferredColumns(data), metricLabel: bc2MetricLabel, preferredDocument: bc2PreferredDocument,
158163
inspect: row => <BindCraft2Trajectory key={row.candidate_key} jobId={jobId} row={row} arm={data.arm} />,
159164
summary: rows => <><BindCraft2OutcomeSummary rows={rows} />{data.analytics?.complete === false && <p role="status">Trajectory analytics are incomplete. Plots cover available recorded observations only.</p>}</>,
160165
}} />}

0 commit comments

Comments
 (0)