false,'errors'=>[$field=>$message]]; } public static function all(string $resolution): array { $schema=MapCatalog::schema();if(!$schema||!isset($schema['grids'][$resolution]))return self::error('resolution','Unsupported map resolution.'); $q=LabDb::connection()->prepare('SELECT id,field,resolution,name,rows,cols,digest,created_at FROM map_versions WHERE owner=? AND resolution=? ORDER BY created_at DESC LIMIT 200');$q->execute([lab_config()['workspace'],$resolution]); return ['ok'=>true,'data'=>['maps'=>$q->fetchAll(),'fields'=>$schema['fields'],'grid'=>$schema['grids'][$resolution]]]; } public static function save(string $field,string $resolution,string $name,string $bytes): array { $schema=MapCatalog::schema();$contract=$schema['fields'][$field]??null;$shape=$schema['grids'][$resolution]??null; if(!$contract||!$shape)return self::error('field','Unsupported map field or resolution.'); if(strlen($name)>120||trim($name)==='')return self::error('name','A map name is required (maximum 120 bytes).'); $rows=(int)$shape['nlat'];$cols=(int)$shape['nlon'];$count=$rows*$cols;if(strlen($bytes)!==$count*4)return self::error('map','Map byte count does not match its grid.'); $values=unpack('g*',$bytes);if(!$values||count($values)!==$count)return self::error('map','Map data is invalid.'); foreach($values as $value)if(!is_finite($value)||$value<$contract['min']||$value>$contract['max'])return self::error('map','Map values must be finite and within the catalog range.'); if(!empty($contract['categorical']))foreach($values as $value)if($value!==0.0&&$value!==1.0)return self::error('map','Categorical map values must be 0 or 1.'); $id=bin2hex(random_bytes(16));$digest=hash('sha256',$bytes);$db=LabDb::connection();$q=$db->prepare('INSERT INTO map_versions(id,owner,field,resolution,name,rows,cols,digest,data,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)'); $q->bindValue(1,$id);$q->bindValue(2,lab_config()['workspace']);$q->bindValue(3,$field);$q->bindValue(4,$resolution);$q->bindValue(5,trim($name));$q->bindValue(6,$rows,PDO::PARAM_INT);$q->bindValue(7,$cols,PDO::PARAM_INT);$q->bindValue(8,$digest);$q->bindValue(9,$bytes,PDO::PARAM_LOB);$q->bindValue(10,gmdate('c'));$q->execute(); return ['ok'=>true,'data'=>['id'=>$id,'field'=>$field,'resolution'=>$resolution,'name'=>trim($name),'shape'=>[$rows,$cols],'digest'=>$digest]]; } public static function get(string $id): ?array { if(!preg_match('/^[a-f0-9]{32}$/',$id))return null;$q=LabDb::connection()->prepare('SELECT id,field,resolution,name,rows,cols,digest,data,created_at FROM map_versions WHERE id=? AND owner=?');$q->execute([$id,lab_config()['workspace']]);return $q->fetch()?:null; } public static function validate(array $spec): array { $maps=$spec['maps']??[];if(!is_array($maps)||(array_is_list($maps)&&$maps!==[]))return self::error('maps','Expected a map version object.');$schema=MapCatalog::schema(); foreach(array_diff(array_keys($maps),array_keys($schema['fields']??[])) as $key)return self::error('maps.'.$key,'Unsupported map field.');$resolution=$spec['model']['resolution']??''; foreach($maps as $field=>$id){if(!is_string($id)||!($map=self::get($id)))return self::error('maps.'.$field,'Map version not found.');if($map['field']!==$field||$map['resolution']!==$resolution)return self::error('maps.'.$field,'Map field or resolution does not match the job.');if(!hash_equals($map['digest'],hash('sha256',$map['data'])))return self::error('maps.'.$field,'Stored map digest does not match.');} return ['ok'=>true,'data'=>true]; } public static function captures(array $spec): array { $valid=self::validate($spec);if(empty($valid['ok']))return $valid;$captures=[]; foreach($spec['maps']??[] as $field=>$id){$map=self::get($id);$captures[$field]=['id'=>$id,'digest'=>$map['digest'],'data'=>$map['data']];} return ['ok'=>true,'data'=>$captures]; } public static function writeSnapshots(array $captures,string $jobDir): array { if(!$captures)return ['ok'=>true,'data'=>[]];$target=$jobDir.'/inputs/maps';if(!is_dir($target)&&!mkdir($target,0750,true)&&!is_dir($target))return self::error('maps','Could not create job map snapshot.');$paths=[]; foreach($captures as $field=>$capture){$bytes=$capture['data']??'';if(!is_string($bytes)||!hash_equals((string)($capture['digest']??''),hash('sha256',$bytes)))return self::error('maps.'.$field,'Captured map digest does not match.');$path=$target.'/'.$field.'.f32';if(file_put_contents($path,$bytes,LOCK_EX)!==strlen($bytes))return self::error('maps.'.$field,'Could not write map snapshot.');$paths[$field]=$path;} return ['ok'=>true,'data'=>$paths]; } }