File size: 24,961 Bytes
04a3ade
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
"""Pinned CombiSolv-QM/COSMO preparation and frozen, full-precision MACE cache."""
from pathlib import Path
import sys
sys.path.insert(0,str(Path(__file__).resolve().parent))
import eval_common as common
from eval_common import WORK,write,sha
import concurrent.futures,fcntl,gc,hashlib,json,multiprocessing as mp,os,shutil,time,traceback
import numpy as np
import pandas as pd
import torch
from rdkit import Chem,RDLogger
from rdkit.Chem import AllChem
from huggingface_hub import HfApi,hf_hub_download,snapshot_download,CommitOperationAdd

OUT=WORK/'multitask_combisolv'
DATA=OUT/'data'
MODEL_REPO='Queimo/rem3di-cosmo10-combisolv-multitask'
DATA_REPO='Queimo/combisolv-qm-rem3di-multitask'
RAW_REV='eacfee378eaafa49c98b4e12e9d76e2c26de1626'
COSMO_REPO='Queimo/pubchemqc-cosmo-pm6'
COSMO_REV='6aeb084c14fd757677d00665efac8d130611273c'
MONROE_REV='de075eabe98e4adbbedbc86ca9e877f4bfd9b629'
INITIAL_HASHES={
 'encoder.pth':'97e616a16e8317717784929495ba3a030bfcd821038be6e05324624b17c409c0',
 'atomic_preprocessor.pth':'9683883267a902773b4f6777eedb366befd016f4f7083bed62a1bd35ae113e3d',
 'geometric_preprocessor.pth':'4da0843646d06fcd2ff5b13e1fd4fb4b22c8c0456d0507c59df01e665c4d6d7b',
 'cosmo_head.pth':'1a43270e60f00c9428099e0e37ab62f77a2c219ea611d4f05194bc1abf69b0a7'}
DECODER_REPO='Queimo/rem3di-pcqm500k-corrected'
DECODER_REV='2f5c3b40b36259a13adc8412689254054bb93288'
DECODER_PATH='runs/corrected_20261027/evaluation/paper_audit/author_pcqm500k_5ep/epoch_02/decoder.pth'


def status(phase,**kw):
    row=dict(phase=phase,pid=os.getpid(),updated_unix=time.time(),**kw)
    write(OUT/'status.json',row);print('MULTITASK_STATUS',json.dumps(row),flush=True)


def connectivity(smiles):
    mol=Chem.MolFromSmiles(str(smiles))
    assert mol is not None,smiles
    key=Chem.MolToInchiKey(mol)
    assert key,smiles
    return key.split('-')[0]


def geometry_job(item):
    """One reproducible explicit-H 3D conformer, never substitute a 2D layout."""
    index,smiles=item
    RDLogger.DisableLog('rdApp.warning')
    mol=Chem.AddHs(Chem.MolFromSmiles(smiles))
    methods=[]
    for retry in range(3):
        params=AllChem.ETKDGv3();params.randomSeed=42026+index+100000*retry
        params.numThreads=1;params.useRandomCoords=retry>0;params.maxIterations=2000
        result=AllChem.EmbedMolecule(mol,params)
        if result==0:break
    if result!=0:
        # Knowledge-based torsions can be inconsistent for strained macrocycles
        # or uncommon N=S=N valence. Generic 3D distance geometry avoids those
        # preferences; subsequent force-field optimization still applies.
        params=AllChem.EmbedParameters();params.randomSeed=342026+index
        params.useRandomCoords=True;params.ignoreSmoothingFailures=True
        params.useBasicKnowledge=False;params.useExpTorsionAnglePrefs=False
        params.maxIterations=5000;params.numThreads=1
        result=AllChem.EmbedMolecule(mol,params)
        methods.append('generic-3D-distance-geometry')
    if result!=0:return dict(smiles=smiles,error='ETKDG and generic 3D distance geometry failed')
    charge=Chem.GetFormalCharge(mol);radicals=sum(a.GetNumRadicalElectrons() for a in mol.GetAtoms())
    # MMFF can mutate formal-charge/aromaticity annotations (e.g. N#N=O). Keep
    # the source molecular graph intact; accept optimized coordinates only.
    optimized=Chem.Mol(mol)
    if AllChem.MMFFHasAllMoleculeParams(mol):
        code=AllChem.MMFFOptimizeMolecule(optimized,mmffVariant='MMFF94s',maxIters=500)
        methods.append('MMFF94s:'+str(code))
    elif AllChem.UFFHasAllMoleculeParams(mol):
        code=AllChem.UFFOptimizeMolecule(optimized,maxIters=500);methods.append('UFF:'+str(code))
    else:methods.append('ETKDG-only: no complete force-field parameters')
    # Some MMFF parameter checks themselves mutate the checked molecule.
    if Chem.GetFormalCharge(optimized)!=charge:
        methods=['ETKDG-only: rejected MMFF charge mutation'];optimized=None
    pos=np.asarray((optimized if optimized is not None else mol).GetConformer().GetPositions(),dtype=np.float32)
    nums=[a.GetAtomicNum() for a in mol.GetAtoms()]
    assert np.isfinite(pos).all() and pos.shape==(len(nums),3)
    assert radicals==0 and charge==0 and sum(nums)%2==0,(smiles,charge,radicals)
    return dict(smiles=smiles,molecule_group=connectivity(smiles),atomic_numbers=nums,
                positions=pos.tolist(),charge=charge,geometry_method=';'.join(methods))


def initialize():
    OUT.mkdir(exist_ok=True);DATA.mkdir(exist_ok=True)
    initial=WORK/'checkpoints/epoch_10'
    bigsol=json.loads((WORK/'bigsoldb_evaluation/protocol.json').read_text())
    for n,digest in INITIAL_HASHES.items():
        assert sha(initial/n)==digest,(n,'Not the BigSolDB-evaluated PCQMep2COSMOep10 checkpoint')
        if n!='cosmo_head.pth':assert bigsol['checkpoint_sha256'][n]==digest
    decoder=Path(hf_hub_download(DECODER_REPO,DECODER_PATH,revision=DECODER_REV))
    foundation=Path('/marimo/rem3di_training/MACE-POLAR-1-M.model')
    foundation_sha=sha(foundation)
    assert foundation_sha=='fab8b8713c832f31a2a853aaa22fd638be8a369cbf5095e6b3e982a18d10e93a'
    write(OUT/'initialization.json',dict(initial_checkpoint_repository='Queimo/rem3di-pcqm-epoch2-cosmo',
        initial_checkpoint_path='epoch_10',initial_checkpoint_revision='9266a41fbe3b36afe0615e0c3ba232ca74bdeef7',initial_hashes=INITIAL_HASHES,
        frozen_foundation_sha256=foundation_sha,
        verified_against_bigsoldb_protocol=True,bigsoldb_protocol_sha256=sha(WORK/'bigsoldb_evaluation/protocol.json'),
        denoising_decoder=dict(repository=DECODER_REPO,revision=DECODER_REV,path=DECODER_PATH,sha256=sha(decoder)),
        explanation='All encoder, atomic/geometric preprocessor and COSMO head weights come from the exact evaluated COSMO10 checkpoint. Only its omitted denoising decoder is restored from PCQM epoch2.'))
    return initial,decoder


def prepare_tables():
    if (DATA/'manifest.json').exists():
        previous=json.loads((DATA/'manifest.json').read_text())
        if previous.get('preparation_version')==2:return previous
    initialize();status('preparing_joint_splits')
    root=Path(snapshot_download(COSMO_REPO,repo_type='dataset',revision=COSMO_REV,
        allow_patterns=['data/*','manifest.json','label_transform.json']))
    cosmo=pd.concat([pd.read_parquet(p).assign(original_split=split)
        for split in ['train','validation'] for p in sorted((root/'data').glob(split+'-*.parquet'))],ignore_index=True)
    assert len(cosmo)==360259
    overlap=pd.concat([pd.read_parquet(p,columns=['molecule_group']) for p in sorted((root/'data').glob('benchmark_overlap-*.parquet'))],ignore_index=True)
    blocked=set(overlap.molecule_group.astype(str))
    bigsol=pd.read_csv(WORK/'bigsoldb_evaluation/data/test.csv')
    bigsol_keys={connectivity(s) for s in bigsol.SMILES_Solute.unique()}
    assert len(bigsol_keys)<=126
    raw_path=Path(hf_hub_download(DATA_REPO,'raw/combisolv_qm.parquet',repo_type='dataset',revision=RAW_REV))
    raw_manifest=json.loads(Path(hf_hub_download(DATA_REPO,'raw/source_manifest.json',repo_type='dataset',revision=RAW_REV)).read_text())
    assert sha(raw_path)==raw_manifest['parquet_sha256']
    raw=pd.read_parquet(raw_path);assert len(raw)==1000000
    # The supplied InChI avoids RDKit 2026's interpretation of the legacy
    # hypervalent N#N=O SMILES as a charged HN2O species. Preserve source strings.
    structures=pd.concat([raw[['smiles solute','inchi solute']].rename(columns={'smiles solute':'source_smiles','inchi solute':'source_inchi'}),
        raw[['smiles solvent','inchi solvent']].rename(columns={'smiles solvent':'source_smiles','inchi solvent':'source_inchi'})]).drop_duplicates()
    converted=[]
    for source_smiles,source_inchi in structures.itertuples(index=False,name=None):
        mol=Chem.MolFromInchi(source_inchi,removeHs=False)
        assert mol is not None,source_inchi
        converted.append(dict(source_smiles=source_smiles,source_inchi=source_inchi,smiles=Chem.MolToSmiles(mol,isomericSmiles=True)))
    structures=pd.DataFrame(converted)
    mapping=dict(zip(structures.source_smiles,structures.smiles))
    molecules=[str(s) for s in pd.unique(pd.concat([raw['smiles solute'].map(mapping),raw['smiles solvent'].map(mapping)]))]
    lookup={s:i for i,s in enumerate(molecules)}
    keys=np.asarray([connectivity(s) for s in molecules]);key_by_smiles=dict(zip(molecules,keys))
    solvent=np.asarray([lookup[mapping[s]] for s in raw['smiles solvent']],dtype=np.int32)
    solute=np.asarray([lookup[mapping[s]] for s in raw['smiles solute']],dtype=np.int32)
    solute_key=keys[solute];solvent_key=keys[solvent]
    heldout=bigsol_keys
    cosine_val=set(cosmo.loc[cosmo.original_split=='validation','molecule_group'].astype(str))
    eligible=sorted(set(solute_key)-heldout-cosine_val)
    rng=np.random.default_rng(42026)
    valkeys=set(np.asarray(eligible)[rng.permutation(len(eligible))[:max(1,round(.02*len(eligible)))]])
    # Validation solutes cannot occur in either input role in new supervised training.
    benchmark=np.isin(solute_key,list(heldout))|np.isin(solvent_key,list(heldout))
    is_val=np.isin(solute_key,list(valkeys))&~benchmark
    auxiliary_holdout=np.isin(solute_key,list(cosine_val))|np.isin(solvent_key,list(cosine_val))|np.isin(solvent_key,list(valkeys))
    split=np.full(len(raw),'train',dtype=object)
    split[auxiliary_holdout]='auxiliary_holdout';split[is_val]='validation';split[benchmark]='benchmark_overlap'
    train=split=='train'
    assert not set(solute_key[train])&valkeys and not set(solvent_key[train])&valkeys
    assert not set(solute_key[train])&heldout and not set(solvent_key[train])&heldout
    target=raw['dGsolv (298K) [kcal/mol]'].to_numpy(dtype=np.float64)
    assert np.isfinite(target).all()
    mean=float(target[train].mean());scale=float(target[train].std())
    assert scale>0
    pairs=pd.DataFrame(dict(source_row=np.arange(len(raw)),solute_index=solute,solvent_index=solvent,
        temperature_K=298.,dGsolv_kcal_mol=target,split=split))
    pairs.to_parquet(DATA/'combisolv_pairs.parquet',index=False,compression='zstd')
    pd.DataFrame(dict(smiles=molecules,molecule_group=keys)).to_parquet(DATA/'combisolv_molecules.parquet',index=False,compression='zstd')
    structures.to_parquet(DATA/'source_structure_mapping.parquet',index=False,compression='zstd')
    cosmo['joint_split']=cosmo.original_split
    mask=np.isin(cosmo.molecule_group.astype(str),list(heldout|valkeys))
    cosmo.loc[mask,'joint_split']='new_holdout'
    assert not set(cosmo.loc[cosmo.joint_split=='train','molecule_group'].astype(str))&(heldout|valkeys)
    # Retain every original row with explicit membership, rather than silently dropping it.
    for start in range(0,len(cosmo),50000):
        cosmo.iloc[start:start+50000].to_parquet(DATA/f'cosmo-{start//50000:03d}.parquet',index=False,compression='zstd')
    shutil.copyfile(root/'label_transform.json',DATA/'cosmo_label_transform.json')
    write(DATA/'combisolv_label_transform.json',dict(transform='z=(dGsolv-mean)/scale; no logarithm for signed free energies',
        target='dGsolv_kcal_mol',temperature_K=298.,mean=mean,scale=scale,training_rows=int(train.sum()),
        fit_split='CombiSolv-QM training only',temperature_feature='Temperature_K / 298; constant 1 for this source'))
    write(DATA/'heldout_groups.json',dict(bigsoldb_test_solute_connectivity=sorted(bigsol_keys),
        existing_esol_freesolv_quarantine=sorted(blocked),combisolv_validation_solutes=sorted(valkeys),
        cosmo_validation=sorted(cosine_val)))
    manifest=dict(preparation_version=2,raw_revision=RAW_REV,cosmo_revision=COSMO_REV,raw_combisolv_rows=len(raw),
        unique_qm_molecules=len(molecules),combisolv_splits={str(k):int(v) for k,v in pairs.split.value_counts().items()},
        cosmo_splits={str(k):int(v) for k,v in cosmo.joint_split.value_counts().items()},
        bigsoldb_test_solute_groups=len(bigsol_keys),new_training_benchmark_overlap=0,
        exclusion='Connectivity InChIKey, in both solute and solvent roles; validation solutes excluded across objectives',
        geometry_identity='Canonical isomeric SMILES from supplied source InChI; original SMILES/InChI mapping preserved. This avoids RDKit 2026 legacy hypervalent nitrogen reinterpretation.',
        esol_freesolv='Original COSMO quarantine preserved. CombiSolv training is protected against requested BigSolDB holdout; no claim of ESOL/FreeSolv quarantine for CombiSolv.',
        initial_checkpoint_exposure='New exclusions cannot remove any BigSolDB or validation molecule exposure already present in the starting COSMO10/PCQM2 checkpoint.',
        source_manifest=raw_manifest,files={p.name:sha(p) for p in DATA.iterdir() if p.is_file() and p.name!='manifest.json'})
    write(DATA/'manifest.json',manifest);status('joint_splits_ready',**{k:v for k,v in manifest.items() if k not in ['files','source_manifest']})
    return manifest


def prepare_geometries():
    path=DATA/'combisolv_geometries.parquet'
    if path.exists():
        record_geometry_manifest(path);return
    molecules=pd.read_parquet(DATA/'combisolv_molecules.parquet')
    status('generating_combisolv_3d',total=len(molecules))
    result=[]
    with concurrent.futures.ProcessPoolExecutor(max_workers=8,mp_context=mp.get_context('spawn')) as pool:
        for i,row in enumerate(pool.map(geometry_job,enumerate(molecules.smiles),chunksize=4),1):
            result.append(row)
            if i%256==0:status('generating_combisolv_3d',completed=i,total=len(molecules))
    failures=[r for r in result if 'error' in r];write(DATA/'geometry_failures.json',failures)
    assert not failures,failures[:10]
    assert [r['smiles'] for r in result]==molecules.smiles.tolist()
    pd.DataFrame(result).to_parquet(path,index=False,compression='zstd')
    record_geometry_manifest(path)


def record_geometry_manifest(path):
    from rdkit import rdBase
    frame=pd.read_parquet(path,columns=['smiles','geometry_method'])
    molecules=pd.read_parquet(DATA/'combisolv_molecules.parquet',columns=['smiles'])
    assert frame.smiles.tolist()==molecules.smiles.tolist()
    write(DATA/'geometry_manifest.json',dict(rows=len(frame),sha256=sha(path),seed=42026,
        rdkit_version=rdBase.rdkitVersion,
        algorithm='RDKit ETKDGv3 explicit H, deterministic random-coordinate retries, generic 3D distance geometry fallback; MMFF94s or UFF where parameterized',
        methods={str(k):int(v) for k,v in frame.geometry_method.value_counts().items()},
        no_2d_fallback=True,missing=0))


def cosmo_frame():
    return pd.concat([pd.read_parquet(p) for p in sorted(DATA.glob('cosmo-[0-9]*.parquet'))],ignore_index=True)


class RawCache:
    """Flat float32 arrays, atom offsets and positions; learned preprocessors stay trainable."""
    def __init__(self,name):
        self.root=OUT/'cache'/name
        self.ptr=np.load(self.root/'offsets.npy');self.lengths=np.diff(self.ptr)
        self.positions=np.load(self.root/'positions.npy',mmap_mode='r')
        self.features=np.load(self.root/'features.npy',mmap_mode='r')
    def sample(self,ids):
        from torch.nn.utils.rnn import pad_sequence
        from threedscriptors.data_handling.sample import Sample
        spans=[slice(int(self.ptr[i]),int(self.ptr[i+1])) for i in ids]
        f=pad_sequence([torch.from_numpy(np.array(self.features[s],copy=True)) for s in spans],batch_first=True).cuda()
        p=pad_sequence([torch.from_numpy(np.array(self.positions[s],copy=True)) for s in spans],batch_first=True).cuda()
        lens=torch.tensor(self.lengths[ids],device='cuda')
        mask=torch.arange(f.shape[1],device='cuda')[None,:]>=lens[:,None]
        return Sample(embeddings=f,atomic_positions=p,padding_mask=mask)


def cache_mace(name,frame):
    root=OUT/'cache'/name;root.mkdir(parents=True,exist_ok=True)
    if (root/'complete.json').exists():return
    from remedi.data_handling.sample import Sample,yield_molecules_collate_fn
    from build_author_mace_runtime import build_author_mace_runtime
    from train_cosmo import batches
    numbers=[torch.tensor(np.asarray(z),dtype=torch.long) for z in frame.atomic_numbers]
    positions=[torch.tensor(np.stack(p),dtype=torch.float32) for p in frame.positions]
    lengths=np.asarray([len(z) for z in numbers]);ptr=np.r_[0,np.cumsum(lengths)]
    np.save(root/'offsets.npy',ptr)
    pos=np.lib.format.open_memmap(root/'positions.npy',mode='w+',dtype=np.float32,shape=(int(ptr[-1]),3))
    for i,p in enumerate(positions):pos[ptr[i]:ptr[i+1]]=p.numpy()
    pos.flush();del pos
    mode='r+' if (root/'features.npy').exists() else 'w+'
    features=np.lib.format.open_memmap(root/'features.npy',mode=mode,dtype=np.float32,shape=(int(ptr[-1]),4096))
    progress=json.loads((root/'progress.json').read_text()) if (root/'progress.json').exists() else {'batches':0}
    mace=build_author_mace_runtime().cuda().eval().requires_grad_(False)
    captures=[]
    hook=mace.preprocessor.atomic_preprocessor.register_forward_pre_hook(lambda m,a:captures.append(a[0].detach()))
    started=time.time()
    if name=='bigsoldb':
        plan=list(common.batch_indices(np.arange(len(frame)),lengths,max_size=24,atom_cap=2400))
    else:plan=list(batches(np.arange(len(frame)),lengths,max_size=64,atom_cap=3600))
    status('caching_frozen_mace',corpus=name,total=len(frame),atoms=int(ptr[-1]),bytes=int(ptr[-1])*4096*4,batches=len(plan))
    with torch.inference_mode():
        for bi,ids in enumerate(plan,1):
            if bi<=progress['batches']:continue
            batch=yield_molecules_collate_fn([Sample(atomic_positions=positions[i],atomic_numbers=numbers[i],total_charge=float(frame.charge.iloc[i]) if 'charge' in frame else 0.,multiplicity=1.) for i in ids]).to_('cuda')
            captures.clear();mace.preprocessor(batch);assert len(captures)==1
            raw=captures.pop().float().cpu().numpy()
            for j,i in enumerate(ids):
                v=raw[j,:lengths[i]];assert v.shape==(lengths[i],4096) and np.isfinite(v).all()
                features[ptr[i]:ptr[i+1]]=v
            if bi%25==0 or bi==len(plan):
                features.flush();write(root/'progress.json',dict(batches=bi,total_batches=len(plan)))
                status('caching_frozen_mace',corpus=name,batch=bi,batches=len(plan),seconds=time.time()-started)
    features.flush();del features
    hook.remove();del mace,numbers,positions;gc.collect();torch.cuda.empty_cache()
    write(root/'complete.json',dict(rows=len(frame),atoms=int(ptr[-1]),width=4096,dtype='float32',
        mace_sha256='fab8b8713c832f31a2a853aaa22fd638be8a369cbf5095e6b3e982a18d10e93a',
        description='Frozen MACE only. Full-precision inputs to learned atomic/geometric preprocessors; no trainable activations cached.'))


def publish_data():
    marker=OUT/'prepared_data_hub.json'
    if marker.exists():return
    pairs=pd.read_parquet(DATA/'combisolv_pairs.parquet')
    for split,frame in pairs.groupby('split',sort=False):
        frame.to_parquet(DATA/f'combisolv_{split}.parquet',index=False,compression='zstd')
    manifest=json.loads((DATA/'manifest.json').read_text())
    manifest['files']={p.name:sha(p) for p in DATA.iterdir() if p.is_file() and p.name!='manifest.json'}
    write(DATA/'manifest.json',manifest)
    header='''---
license: cc-by-4.0
task_categories:
- tabular-regression
configs:
- config_name: raw_combisolv_qm
  default: true
  data_files:
  - split: records
    path: raw/combisolv_qm.parquet
- config_name: prepared_pairs
  data_files:
  - split: train
    path: prepared/combisolv_train.parquet
  - split: validation
    path: prepared/combisolv_validation.parquet
  - split: benchmark_overlap
    path: prepared/combisolv_benchmark_overlap.parquet
  - split: auxiliary_holdout
    path: prepared/combisolv_auxiliary_holdout.parquet
- config_name: combisolv_geometries
  data_files:
  - split: molecules
    path: prepared/combisolv_geometries.parquet
- config_name: cosmo_pool
  data_files:
  - split: records
    path: prepared/cosmo-*.parquet
---
'''
    card=header+'\n# CombiSolv-QM and COSMO multi-task continuation data\n\n'
    card+='The unchanged original one-million-row CombiSolv-QM table is preserved under `raw/`, together with source archive/CSV/Parquet checksums. The target is signed solvation Gibbs free energy at 298 K, in kcal/mol, using 1 mol/L reference concentrations. It is distinct from experimental solubility.\n\n'
    card+=f"Prepared CombiSolv memberships: `{json.dumps(manifest['combisolv_splits'],sort_keys=True)}`. All {manifest['unique_qm_molecules']:,} unique structures have explicit-H coordinates; supplied InChI defines identity, and original SMILES/InChI mappings are retained. Solute/solvent indices refer to row order in the geometry and molecule tables.\n\n"
    card+='Geometries use seeded RDKit ETKDGv3, random-coordinate retries and generic 3D distance geometry for strained/unusual structures, followed by MMFF94s or UFF when parameterized. There is no 2D fallback. Geometry methods, software version, coverage and checksums are recorded.\n\n'
    card+=f"The COSMO pool retains the source training/validation records and adds an explicit `joint_split` membership: `{json.dumps(manifest['cosmo_splits'],sort_keys=True)}`. Filter that column for training. The complete original COSMO dataset and its existing ESOL/FreeSolv quarantine remain available in [Queimo/pubchemqc-cosmo-pm6](https://huggingface.co/datasets/{COSMO_REPO}) at revision `{COSMO_REV}`.\n\n"
    card+='BigSolDB2.0 held-out solute connectivity groups are excluded from all new objectives and both CombiSolv input roles. CombiSolv validation solutes are excluded across objectives. Existing COSMO validation groups are excluded from CombiSolv training. Raw rows remain available with explicit quarantine membership; earlier exposure in the starting checkpoint cannot be undone. ESOL/FreeSolv quarantine is preserved for COSMO, but is not claimed for CombiSolv.\n\n'
    card+='CombiSolv target mean/std is fitted on its retained training rows only; signed energies are not log transformed. The existing COSMO log1p/ln transform and normalization are preserved so the initialized output head retains its meaning.\n\n'
    card+='[SolProp v1.2 source](https://zenodo.org/records/5970538) · [Vermeire and Green (2021)](https://doi.org/10.1016/j.cej.2021.129307) · [Vermeire, Chung and Green (2022)](https://doi.org/10.1021/jacs.2c01768). Please cite the original data authors.\n'
    card_path=OUT/'dataset_README.md';card_path.write_text(card)
    files=[p for p in DATA.iterdir() if p.is_file()]
    commit=HfApi().create_commit(repo_id=DATA_REPO,repo_type='dataset',
        operations=[CommitOperationAdd(path_in_repo='prepared/'+p.name,path_or_fileobj=str(p)) for p in files]+[
            CommitOperationAdd(path_in_repo='README.md',path_or_fileobj=str(card_path))],
        commit_message='Add reproducible joint splits, all geometries, label transforms and benchmark exclusions')
    remote={p.rfilename:p for p in HfApi().dataset_info(DATA_REPO,revision=commit.oid,files_metadata=True).siblings}
    for p in files:
        entry=remote['prepared/'+p.name]
        if entry.lfs:assert entry.lfs.sha256==sha(p)
    write(marker,dict(repository=DATA_REPO,revision=commit.oid,verified=True,files=len(files)))


def main():
    OUT.mkdir(exist_ok=True)
    lock=(OUT/'prepare.lock').open('a');fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
    prepare_tables();prepare_geometries()
    cache_mace('combisolv',pd.read_parquet(DATA/'combisolv_geometries.parquet'))
    cache_mace('cosmo',cosmo_frame())
    # Evaluation geometries must be byte-identical to those used for the published baseline.
    import openadmet_evaluate as runtime
    runtime.restore_monroe()
    from monroe.model.constants import NODE_FEAT_LIST_ONE_HOT
    graphs=pd.read_parquet(WORK/'bigsoldb_evaluation/features/shared_graphs.parquet')
    vocab=NODE_FEAT_LIST_ONE_HOT['atomic_number'];col=list(NODE_FEAT_LIST_ONE_HOT).index('atomic_number')
    ptable=Chem.GetPeriodicTable()
    graph_atoms=pd.DataFrame(dict(smiles=graphs.smiles,positions=[np.stack(p).astype(np.float32).tolist() for p in graphs.pos_rdkit],
        atomic_numbers=[[ptable.GetAtomicNumber(vocab[int(i)]) for i in np.stack(codes)[:,col]] for codes in graphs.node_codes],
        charge=[int(round(float(np.stack(v)[:,5].sum()))) for v in graphs.node_float]))
    graph_atoms.to_parquet(DATA/'bigsoldb_fixed_geometries.parquet',index=False,compression='zstd')
    cache_mace('bigsoldb',graph_atoms)
    publish_data()
    write(OUT/'preparation_complete.json',dict(completed=True,time=time.time(),initialization=json.loads((OUT/'initialization.json').read_text()),data=json.loads((OUT/'prepared_data_hub.json').read_text())))
    status('preparation_complete')


if __name__=='__main__':
    try:main()
    except Exception:status('failed_preparation',error=traceback.format_exc());raise