&1', $output, $exitCode); if($exitCode!==0 || !is_file($tmp.'/catalog/parameters.json')) throw new RuntimeException('Could not generate parameter catalog: '.implode("\n",$output)); $owner=$config['workspace']; function check_backend($value,$message){if(!$value)throw new RuntimeException($message);} file_put_contents($tmp.'/config.json',json_encode($config)); putenv('EXOPLASIM_CONFIG='.$tmp.'/config.json'); try { $saved=Experiment::save($owner,['name'=>'test','spec'=>['model'=>['resolution'=>'T21','layers'=>10,'ncpus'=>1],'parameters'=>[],'run'=>['cycles'=>1,'steps'=>1440]]]); if(empty($saved['ok'])) throw new RuntimeException('Experiment save failed: '.json_encode($saved)); check_backend(is_object($saved['data']['spec']['parameters']),'Saved experiment API must encode empty parameters as an object.'); $query=LabDb::connection()->prepare('SELECT spec FROM experiments WHERE id=?');$query->execute([$saved['data']['id']]);$raw=$query->fetchColumn(); check_backend(is_object(json_decode($raw)->parameters),'Saved experiment JSON must contain an empty object for parameters.'); $job=Job::enqueue($owner,$saved['data']); check_backend($job['ok'],'Model queue failed.'); check_backend(is_object($job['data']['spec']['parameters']),'Job API must encode empty parameters as an object.'); check_backend(Job::get($job['data']['id'],'other-workspace')===null,'Job ownership must be scoped.'); check_backend(Job::cancel($job['data']['id'],$owner)['ok'],'Model cancellation failed.'); $modelRetry=Job::retry($job['data']['id'],$owner); check_backend($modelRetry['ok']&&$modelRetry['data']['kind']==='model','Model retry must preserve kind.'); $mapBytes=pack('g*',...array_fill(0,2048,100.0)); $height=Maps::save('height','T21','height',$mapBytes);check_backend($height['ok'],'Height map save failed.'); $mapSpec=['model'=>['resolution'=>'T21'],'maps'=>['height'=>$height['data']['id']]]; $capture=Maps::captures($mapSpec);check_backend($capture['ok']&&hash('sha256',$capture['data']['height']['data'])===$height['data']['digest'],'Map capture must preserve immutable bytes.'); $land=Maps::save('landmask','T21','land',pack('g*',...array_fill(0,2048,1.0)));check_backend($land['ok'],'Land map save failed.'); $mapped=Experiment::save($owner,['name'=>'mapped','spec'=>['model'=>['resolution'=>'T21'],'parameters'=>[],'run'=>['cycles'=>1,'steps'=>1440],'maps'=>['landmask'=>$land['data']['id'],'height'=>$height['data']['id']]]]); $mappedJob=Job::enqueue($owner,$mapped['data']);check_backend($mappedJob['ok'],'Mapped job enqueue failed.'); $captured=Job::mapCaptures($mappedJob['data']['id']);check_backend($captured['height']['data']===$mapBytes,'Job must capture exact map bytes.');check_backend(Job::validatedMapCaptures($mappedJob['data'])['ok'],'Map capture validation must not depend on JSON key order.'); $badCapture=$mappedJob['data'];$badCapture['spec']['maps']=[];check_backend(empty(Job::validatedMapCaptures($badCapture)['ok']),'Missing map capture rows must fail validation.'); $changed=Maps::save('height','T21','later edit',pack('g*',...array_fill(0,2048,200.0)));check_backend($changed['ok']&&Job::mapCaptures($mappedJob['data']['id'])['height']['data']===$mapBytes,'Later map edits must not change queued job inputs.'); check_backend(Job::cancel($mappedJob['data']['id'],$owner)['ok'],'Mapped cancellation failed.');$mappedRetry=Job::retry($mappedJob['data']['id'],$owner);check_backend($mappedRetry['ok']&&Job::mapCaptures($mappedRetry['data']['id'])['height']['data']===$mapBytes,'Retry must copy the original immutable map capture.'); $invalid=Maps::save('landmask','T21','bad',pack('g*',...array_fill(0,2048,0.5)));check_backend(empty($invalid['ok']),'Non-binary land mask must fail.'); check_backend(empty(Maps::save('height','T21','wrong size',pack('g*',1.0))['ok']),'Wrong map dimensions must fail.'); $nonfinite=Maps::save('height','T21','nonfinite',pack('g*',...array_merge([NAN],array_fill(0,2047,0.0))));check_backend(empty($nonfinite['ok']),'Nonfinite map values must fail.'); $otherResolution=Maps::save('height','T42','other grid',pack('g*',...array_fill(0,8192,10.0)));check_backend($otherResolution['ok'],'T42 map save failed.'); $cross=Experiment::save($owner,['name'=>'cross','spec'=>['model'=>['resolution'=>'T21'],'maps'=>['height'=>$otherResolution['data']['id']]]]);check_backend(empty(Job::enqueue($owner,$cross['data'])['ok']),'Cross-resolution map enqueue must fail.'); $planet=Job::enqueueKind($owner,'planet','generated',['model'=>[],'parameters'=>[],'run'=>['cycles'=>5],'surface'=>['seed'=>12]]); check_backend($planet['ok'],'Planet enqueue failed.'); check_backend($planet['ok']&&$planet['data']['kind']==='planet'&&(int)$planet['data']['total_cycles']===1,'Planet jobs must have one generation cycle.'); check_backend(is_object($planet['data']['spec']['parameters']),'Planet job API must encode empty parameters as an object.'); check_backend(Job::cancel($planet['data']['id'],$owner)['ok'],'Planet cancellation failed.'); $planetRetry=Job::retry($planet['data']['id'],$owner); check_backend($planetRetry['ok']&&$planetRetry['data']['kind']==='planet'&&(int)$planetRetry['data']['total_cycles']===1,'Planet retry must preserve kind and one generation cycle.'); check_backend(empty(Job::enqueueKind($owner,'arbitrary','bad',[])['ok']),'Unsupported task kind must fail.'); echo "backend PHP tests passed\n"; } finally { putenv('EXOPLASIM_CONFIG'); foreach (glob($tmp.'/jobs/*')?:[] as $f) { if(is_dir($f)) rmdir($f); else unlink($f); } if (is_dir($tmp.'/jobs')) rmdir($tmp.'/jobs'); foreach (glob($tmp.'/data/*')?:[] as $f) if(is_file($f)) unlink($f); if(is_dir($tmp.'/data')) rmdir($tmp.'/data'); foreach (glob($tmp.'/catalog/*')?:[] as $f) if(is_file($f)) unlink($f); if(is_dir($tmp.'/catalog')) rmdir($tmp.'/catalog'); foreach (glob($tmp.'/*')?:[] as $f) if(is_file($f)) unlink($f); rmdir($tmp); }