diff --git a/geom/explicit_h/README.md b/geom/explicit_h/README.md new file mode 100644 index 0000000000000000000000000000000000000000..fa99efd8659c0602a5f37a4f393e4a34737008b5 --- /dev/null +++ b/geom/explicit_h/README.md @@ -0,0 +1,12 @@ +# GEOM-Drugs with explicit hydrogens — epoch 50 + +Continued from the best checkpoint after 48 completed epochs. The original Lightning checkpoint includes current weights, EMA weights and optimizer state. The inference loader constructs only the selected network (EMA by default). + +```python +from morph.inference import load_pretrained + +model = load_pretrained("geom-explicit-h", epoch=50, device="cuda") +trajectories, validity, vocab, coordinate_std = model.generate(n_mols=16) +``` + +The training subset contains at most 72 atoms including hydrogen. Coordinates returned by generation are in Angstrom. Config, vocabularies and training distributions are included; no dataset is needed for inference. diff --git a/geom/explicit_h/atom_tokens.txt b/geom/explicit_h/atom_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..d26c72a1280cb2fc09f0caa954cd4a5cc9e74b36 --- /dev/null +++ b/geom/explicit_h/atom_tokens.txt @@ -0,0 +1,10 @@ +Br +C +Cl +F +H +I +N +O +P +S diff --git a/geom/explicit_h/charge_tokens.txt b/geom/explicit_h/charge_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..3ca179801a8bfa91f0e7d8861b931916836a370b --- /dev/null +++ b/geom/explicit_h/charge_tokens.txt @@ -0,0 +1,5 @@ +-2 +-1 +0 +1 +2 diff --git a/geom/explicit_h/config.yaml b/geom/explicit_h/config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..362538636d71ea5a49a2c2f7d7ad6ab9bc9fbf03 --- /dev/null +++ b/geom/explicit_h/config.yaml @@ -0,0 +1,345 @@ +representation: molecule +seed: 42 +heads: + heads: + _target_: morph.model.layers.heads.MultiHeadModule + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + heads_configs: + graph_aggregation: mean + node_heads: + atom_type_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.atom_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + charge_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.charge_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + ins_rate_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_rate: ${model.head_initialization.ins_rate} + do_del_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_del_probability} + do_sub_a_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_sub_a_probability} + edge_heads: + edge_type_head: + input_dim: ${model.head_edge_dim} + output_dim: ${len:${data.vocab.edge_tokens}} + adapter_dim: ${model.head_edge_adapter_dim} + group: state + do_sub_e_head: + input_dim: ${model.head_edge_dim} + output_dim: 1 + adapter_dim: ${model.head_edge_adapter_dim} + group: state + initial_probability: ${model.head_initialization.do_sub_e_probability} + ins_edge_head: + _target_: morph.model.layers.heads.InsertionEdgeHead + _recursive_: false + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_edge_dim} + output_init_std: ${model.head_initialization.output_std} + n_edge_types: ${len:${data.vocab.edge_tokens}} + n_atom_types: ${len:${data.vocab.atom_tokens}} + n_charge_types: ${len:${data.vocab.charge_tokens}} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false +insertion_head: + mode: unimodal + dimension: 3 + num_components: 1 + model: + _target_: morph.model.layers.heads.UnimodalInsertionHead + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_adapter_dim} + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + N_a: ${len:${data.vocab.atom_tokens}} + N_c: ${len:${data.vocab.charge_tokens}} +model: + module: + optimizer_config: + implementation: torch + optimizer: + execution: ${oc.env:MORPH_MUON_EXECUTION,native} + lr: 0.01 + momentum: 0.95 + weight_decay: 0.0 + nesterov: true + ns_steps: 5 + eps: 1.0e-07 + adjust_lr_fn: original + aux_optimizer: + lr: 0.0003 + betas: + - 0.9 + - 0.95 + eps: 1.0e-10 + weight_decay: 0.0 + gradient_clip_val: 1.0 + gradient_clip_algorithm: norm + scheduler: + _target_: morph.utils.lr_schedulers.MultiGroupCosineWarmupLR + warmup_steps: 2000 + total_steps: 200000 + min_lr_fraction: 0.05 + last_epoch: -1 + interval: step + monitor: null + loss_weights: + do_sub_a: 0.2 + sub_a_class: 0.03 + do_sub_e: 0.25 + sub_e_class: 0.5 + do_del: 0.4 + ins_rate: 0.15 + ins_state: 0.01 + ins_e: 0.4 + x: 2.0 + c: 0.5 + ins_e_ii: 0.03 + _target_: morph.model.lightning.base.LightningModuleRates + insertion_head_mode: ${insertion_head.mode} + ins_noise_scale: ${data.ins_noise_scale} + ema_decay: 0.999 + ema_warmup_steps: 5000 + use_ema_for_eval: true + time_dist: ${if:${eq:${model.sample_time_strategy}, log}, ${model.beta_sched}, + ${model.linear_sched}} + integrator: ${model.integrator} + vocab: ${data.vocab} + use_learnable_loss_weights: false + use_time_weights: false + model: + _target_: morph.model.backbones.dit.DiTBackboneWithHeads + _recursive_: false + atom_type_embedding_args: ${embeddings.atom_type_embedding} + edge_type_embedding_args: ${embeddings.edge_type_embedding} + time_embedding_args: ${embeddings.time_embedding} + node_count_embedding_args: ${embeddings.node_count_embedding} + incident_bond_embedding: false + backbone_model_args: ${model.backbone} + heads_args: ${heads.heads} + insertion_head_args: ${insertion_head.model} + ins_edge_head_args: ${heads.ins_edge_head} + rotate: true + n_augmentations: 1 + integrator: ${integrator.integrator} + beta_sched: + _target_: torch.distributions.Beta + concentration1: 2.0 + concentration0: 1.0 + linear_sched: + _target_: torch.distributions.Uniform + low: 0.0 + high: 1.0 + sample_time_strategy: linear + head_node_dim: ${model.backbone.out_node_nf} + head_edge_dim: ${model.backbone.out_edge_nf} + head_node_adapter_dim: 256 + head_edge_adapter_dim: 256 + head_insertion_adapter_dim: 256 + head_insertion_edge_dim: 256 + head_independent_adapters: true + head_initialization: + output_std: 0.001 + coordinate_output_std: 0.0 + do_del_probability: 0.5 + do_sub_a_probability: 0.5 + do_sub_e_probability: 0.5 + ins_rate: 0.6931471805599453 + token_weighting: + type_loss_token_weights: uniform + weight_alpha: 0.4 + lora: + enabled: false + rank: 8 + alpha: 8.0 + dropout: 0.0 + targets: + - qkv + - proj + - mlp.gate_value + - mlp.out_proj + freeze_base: true + backbone: + _target_: morph.model.backbones.dit.DiTBackbone + _recursive_: false + d_input: ${embeddings.atom_type_embedding.out_dim} + d_cond: ${oc.eval:"${embeddings.time_embedding.out_dim} + ${embeddings.node_count_embedding.out_dim}"} + d_model: 512 + out_node_nf: 256 + out_edge_nf: 256 + num_layers: 8 + nhead: 16 + mlp_ratio: 4.0 + ffn_hidden_dim: null + ffn_multiple_of: 64 + qk_norm: true + coordinate_output_init_std: ${model.head_initialization.coordinate_output_std} + center_coordinate_output: true + in_edge_nf: ${embeddings.edge_type_embedding.out_dim} + edge_pair_dim: 256 + pair_bias_hidden_dim: 32 + num_atom_types: ${len:${data.vocab.atom_tokens}} + lora: ${model.lora} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false +schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 0.8 +trainer: + trainer: + deterministic: warn + devices: 1 + accelerator: auto + strategy: auto + precision: 32-true + max_epochs: -1 + accumulate_grad_batches: 1 + num_sanity_val_steps: 0 + gradient_clip_val: null + gradient_clip_algorithm: norm + check_val_every_n_epoch: 1 + num_val_samples: 4096 + limit_val_batches: null + limit_predict_batches: 4 + max_time: null + do_train: false + do_validate: false + do_predict: true + checkpoint_path: null + final_checkpoint_path: null +callbacks: {} +logging: + enabled: false + project: morph-${data.dataset_name} + name: geom-explicit-h + save_dir: . + offline: false +integrator: + integrator: + _target_: morph.flow_matching.integration.RateIntegrator + vocab: ${data.vocab} + distributions: None + insertion_head_mode: ${insertion_head.mode} + insertion_dimension: ${insertion_head.dimension} + insertion_num_components: ${insertion_head.num_components} + time_strategy: log + time_beta_alpha: 2.0 + time_beta_beta: 1.0 + num_integration_steps: 100 + ins_noise_scale: ${data.ins_noise_scale} + renoise_insertions: true + exact_schedule_integration: true + del_schedule: ${data.interpolator.del_schedule} + ins_schedule: ${data.interpolator.ins_schedule} + sub_schedule: ${data.interpolator.sub_schedule} + sub_e_schedule: ${data.interpolator.sub_e_schedule} +data: + root_path: . + preprocess_workers: 0 + preprocess_chunk_size: 128 + statistics_workers: ${data.preprocess_workers} + hydrogen_mode: explicit + cache_name: ${data.dataset_name}${if:${eq:${data.hydrogen_mode},implicit},_implicit_h,''} + source_dir: null + max_atoms: 72 + allowed_atom_symbols: + - H + - C + - 'N' + - O + - S + - F + - Cl + - Br + - P + - I + n_atoms_strategy: uniform + uniform_n_atoms_padding: 5 + optimal_transport: equivariant + ins_noise_scale: 0.65 + move_noise_scale: 0.5 + allow_charged: true + preprocessing: + _target_: morph.dataset.core.preprocessing.Preprocessing + _recursive_: false + root: . + num_workers: 0 + train_dataset: null + atom_tokens_path: ${data.preprocessing.root}/atom_tokens.txt + edge_tokens_path: ${data.preprocessing.root}/edge_tokens.txt + charge_tokens_path: ${data.preprocessing.root}/charge_tokens.txt + distributions_path: ${data.preprocessing.root}/distributions.pt + vocab: null + interpolator: + _target_: morph.flow_matching.interpolation.Interpolator + distributions: null + optimal_transport: ${data.optimal_transport} + ins_noise_scale: ${data.ins_noise_scale} + move_noise_scale: ${data.move_noise_scale} + atom_type_noise_prob: 0.0 + pre_align_iters: 2 + c_move: 1.0 + c_sub: 0.0 + c_ins: 6.125 + c_del: 6.125 + del_schedule: ${schedule} + ins_schedule: ${schedule} + sub_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + sub_e_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + datamodule: null + dataset_name: geom + dataset_target: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset +embeddings: + atom_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.atom_tokens}} + embedding_dim: 64 + out_dim: 128 + edge_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.edge_tokens}} + embedding_dim: 64 + out_dim: 128 + time_embedding: + _target_: morph.model.layers.embedding.TimeEmbedding + embedding_dim: 128 + out_dim: 128 + node_count_embedding: + _target_: morph.model.layers.embedding.ExtrapolatableCountEmbedding + embedding_dim: 64 + out_dim: 128 + min_period: 128.0 + max_period: 1024.0 + max_count: 96.0 diff --git a/geom/explicit_h/distributions.json b/geom/explicit_h/distributions.json new file mode 100644 index 0000000000000000000000000000000000000000..5257e64dcd17b88651740050603ac130f0124f37 --- /dev/null +++ b/geom/explicit_h/distributions.json @@ -0,0 +1,15406 @@ +{ + "atom_type_distribution": [ + 0.0009399419068358839, + 0.40246495604515076, + 0.003820041660219431, + 0.004622052423655987, + 0.4455429017543793, + 1.746328780427575e-05, + 0.06322308629751205, + 0.06675907224416733, + 7.518968777731061e-05, + 0.012535374611616135 + ], + "edge_type_distribution": [ + 0.9558339715003967, + 0.036910656839609146, + 0.007218532729893923, + 3.680539157357998e-05 + ], + "charge_type_distribution": [ + 1.2930099728691857e-06, + 0.0020575423259288073, + 0.9950409531593323, + 0.00290004163980484, + 1.6449536133222864e-07 + ], + "n_atoms_distribution": [ + 0.0, + 0.0, + 0.0, + 1.7712740429942642e-07, + 0.0, + 3.5425480859885283e-07, + 3.5425480859885283e-07, + 3.5425480859885283e-07, + 1.239891730620002e-06, + 2.8340384687908227e-06, + 8.502114724251442e-06, + 4.782439646078274e-06, + 1.558721123728901e-05, + 3.648824349511415e-05, + 4.9772799684433267e-05, + 6.0046186263207346e-05, + 7.350787200266495e-05, + 0.00011017324140993878, + 0.00019696567323990166, + 0.000267993746092543, + 0.0003774584911298007, + 0.0005579512799158692, + 0.0009449746576137841, + 0.0012839965056627989, + 0.002041747560724616, + 0.002648585941642523, + 0.0038034566678106785, + 0.004636486526578665, + 0.006434684153646231, + 0.008230401203036308, + 0.01023300364613533, + 0.012141905725002289, + 0.014930599369108677, + 0.017782174050807953, + 0.020237158983945847, + 0.023916803300380707, + 0.026766251772642136, + 0.028156703338027, + 0.031546566635370255, + 0.03363773226737976, + 0.034495383501052856, + 0.037021927535533905, + 0.03860349953174591, + 0.03938250616192818, + 0.040041595697402954, + 0.03986889868974686, + 0.04040771722793579, + 0.039737291634082794, + 0.03794635832309723, + 0.0365116260945797, + 0.03570268303155899, + 0.033752866089344025, + 0.03189922869205475, + 0.030490001663565636, + 0.02852034568786621, + 0.02656574361026287, + 0.02402130886912346, + 0.022764058783650398, + 0.020507987588644028, + 0.01766757294535637, + 0.01609361730515957, + 0.014043014496564865, + 0.012413442134857178, + 0.010036392137408257, + 0.009312295354902744, + 0.007565110921859741, + 0.005838650278747082, + 0.005072042811661959, + 0.004129016771912575, + 0.00307723437435925, + 0.0029760946054011583, + 0.002396710915490985 + ], + "coordinate_std": 2.4406259059906006, + "pairwise_distance_histogram": [ + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 906.0, + 140.0, + 75.0, + 105.0, + 303.0, + 184.0, + 22.0, + 45.0, + 41.0, + 53.0, + 57.0, + 296.0, + 335.0, + 44.0, + 41.0, + 86.0, + 42.0, + 39.0, + 28.0, + 36.0, + 36.0, + 28.0, + 21.0, + 42.0, + 139.0, + 3804.0, + 353.0, + 47.0, + 36.0, + 54.0, + 45.0, + 31.0, + 47.0, + 73.0, + 334.0, + 38.0, + 41.0, + 23.0, + 37.0, + 31.0, + 18.0, + 46.0, + 59.0, + 47.0, + 30.0, + 42.0, + 54.0, + 41.0, + 35.0, + 29.0, + 33.0, + 33.0, + 36.0, + 34.0, + 44.0, + 219.0, + 42.0, + 35.0, + 27.0, + 34.0, + 45.0, + 43.0, + 31.0, + 53.0, + 47.0, + 20.0, + 28.0, + 27.0, + 23.0, + 21.0, + 18.0, + 35.0, + 28.0, + 30.0, + 42.0, + 45.0, + 30.0, + 42.0, + 30.0, + 29.0, + 29.0, + 27.0, + 16.0, + 12.0, + 19.0, + 14.0, + 24.0, + 26.0, + 26.0, + 33.0, + 32.0, + 26.0, + 21.0, + 21.0, + 26.0, + 22.0, + 69.0, + 69.0, + 37.0, + 17.0, + 31.0, + 23.0, + 13.0, + 15.0, + 33.0, + 74.0, + 12.0, + 5.0, + 12.0, + 5.0, + 18.0, + 7.0, + 6.0, + 7.0, + 8.0, + 4.0, + 8.0, + 4.0, + 91.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 12.0, + 110843.0, + 134616.0, + 235.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2767.0, + 422354.0, + 40592.0, + 1068.0, + 18057.0, + 15937.0, + 7693.0, + 15022.0, + 14893.0, + 17421.0, + 19252.0, + 23093.0, + 38199.0, + 40716.0, + 432083.0, + 46430.0, + 31398.0, + 25887.0, + 34241.0, + 223385.0, + 32154.0, + 29838.0, + 29400.0, + 29183.0, + 37249.0, + 46730.0, + 97936.0, + 44001.0, + 34330.0, + 38614.0, + 32939.0, + 31224.0, + 33046.0, + 35343.0, + 72459.0, + 38345.0, + 38358.0, + 47895.0, + 44644.0, + 39619.0, + 46167.0, + 65960.0, + 68309.0, + 66383.0, + 44820.0, + 38541.0, + 36679.0, + 37605.0, + 39037.0, + 40929.0, + 41339.0, + 34860.0, + 32172.0, + 32587.0, + 37171.0, + 44442.0, + 47044.0, + 38892.0, + 29566.0, + 32095.0, + 36072.0, + 35539.0, + 32840.0, + 31700.0, + 30267.0, + 29798.0, + 27471.0, + 27302.0, + 25136.0, + 22399.0, + 21465.0, + 21025.0, + 21592.0, + 21798.0, + 21971.0, + 21168.0, + 21686.0, + 20324.0, + 20433.0, + 17161.0, + 16920.0, + 15543.0, + 16413.0, + 15408.0, + 13291.0, + 12757.0, + 11594.0, + 11825.0, + 10764.0, + 10095.0, + 9327.0, + 8980.0, + 8035.0, + 7749.0, + 7495.0, + 7305.0, + 7162.0, + 7120.0, + 6467.0, + 6501.0, + 5796.0, + 5467.0, + 4996.0, + 4785.0, + 4325.0, + 4163.0, + 3993.0, + 3664.0, + 3218.0, + 3025.0, + 2943.0, + 2798.0, + 2664.0, + 2416.0, + 2270.0, + 2063.0, + 2010.0, + 1951.0, + 1814.0, + 1858.0, + 1711.0, + 1485.0, + 16933.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 76.0, + 1.0, + 644.0, + 56.0, + 29.0, + 54.0, + 101.0, + 114.0, + 114.0, + 108.0, + 87.0, + 92.0, + 90.0, + 94.0, + 102.0, + 101.0, + 115.0, + 121.0, + 122.0, + 134.0, + 174.0, + 205.0, + 164.0, + 121.0, + 640.0, + 3859.0, + 132.0, + 105.0, + 135.0, + 145.0, + 132.0, + 156.0, + 145.0, + 1310.0, + 132.0, + 143.0, + 141.0, + 133.0, + 152.0, + 147.0, + 152.0, + 167.0, + 158.0, + 182.0, + 128.0, + 141.0, + 115.0, + 136.0, + 161.0, + 126.0, + 127.0, + 174.0, + 151.0, + 177.0, + 158.0, + 166.0, + 147.0, + 166.0, + 173.0, + 163.0, + 148.0, + 133.0, + 125.0, + 136.0, + 117.0, + 142.0, + 153.0, + 106.0, + 118.0, + 121.0, + 117.0, + 111.0, + 111.0, + 140.0, + 145.0, + 114.0, + 116.0, + 108.0, + 107.0, + 129.0, + 134.0, + 97.0, + 106.0, + 95.0, + 79.0, + 74.0, + 115.0, + 129.0, + 105.0, + 105.0, + 81.0, + 82.0, + 65.0, + 65.0, + 65.0, + 51.0, + 81.0, + 67.0, + 55.0, + 58.0, + 67.0, + 57.0, + 44.0, + 32.0, + 72.0, + 37.0, + 41.0, + 35.0, + 32.0, + 18.0, + 27.0, + 19.0, + 22.0, + 30.0, + 15.0, + 25.0, + 76.0, + 14.0, + 13.0, + 186.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 60.0, + 0.0, + 4.0, + 29.0, + 295.0, + 255.0, + 44.0, + 108.0, + 185.0, + 226.0, + 135.0, + 140.0, + 154.0, + 160.0, + 163.0, + 170.0, + 188.0, + 179.0, + 179.0, + 291.0, + 185.0, + 193.0, + 328.0, + 166.0, + 199.0, + 3509.0, + 1139.0, + 271.0, + 211.0, + 227.0, + 276.0, + 204.0, + 197.0, + 684.0, + 249.0, + 225.0, + 253.0, + 298.0, + 269.0, + 218.0, + 371.0, + 738.0, + 278.0, + 318.0, + 323.0, + 270.0, + 295.0, + 178.0, + 216.0, + 542.0, + 214.0, + 208.0, + 197.0, + 203.0, + 258.0, + 225.0, + 218.0, + 260.0, + 354.0, + 225.0, + 194.0, + 194.0, + 196.0, + 228.0, + 327.0, + 287.0, + 213.0, + 197.0, + 229.0, + 217.0, + 202.0, + 174.0, + 144.0, + 212.0, + 155.0, + 172.0, + 237.0, + 278.0, + 225.0, + 176.0, + 196.0, + 183.0, + 135.0, + 150.0, + 162.0, + 140.0, + 141.0, + 133.0, + 141.0, + 168.0, + 148.0, + 129.0, + 145.0, + 122.0, + 125.0, + 110.0, + 153.0, + 159.0, + 93.0, + 117.0, + 87.0, + 77.0, + 85.0, + 79.0, + 72.0, + 76.0, + 81.0, + 66.0, + 73.0, + 79.0, + 59.0, + 76.0, + 66.0, + 44.0, + 46.0, + 55.0, + 41.0, + 49.0, + 39.0, + 49.0, + 33.0, + 35.0, + 32.0, + 445.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 285.0, + 8368.0, + 4096.0, + 6171.0, + 8991.0, + 19174.0, + 348632.0, + 34277.0, + 25774.0, + 32381.0, + 17040.0, + 14123.0, + 12788.0, + 12432.0, + 12884.0, + 18310.0, + 17212.0, + 16420.0, + 19532.0, + 30253.0, + 25385.0, + 24911.0, + 36162.0, + 32457.0, + 33026.0, + 49470.0, + 285564.0, + 82393.0, + 37689.0, + 46938.0, + 42277.0, + 37158.0, + 37573.0, + 50782.0, + 110008.0, + 40411.0, + 42324.0, + 49106.0, + 45644.0, + 52703.0, + 41894.0, + 54446.0, + 72117.0, + 76840.0, + 61863.0, + 65561.0, + 57610.0, + 51125.0, + 54868.0, + 48614.0, + 51135.0, + 50439.0, + 48490.0, + 47283.0, + 48153.0, + 66180.0, + 50298.0, + 47104.0, + 44890.0, + 45587.0, + 44184.0, + 47169.0, + 49142.0, + 49230.0, + 46402.0, + 44520.0, + 41832.0, + 42451.0, + 41745.0, + 35725.0, + 35191.0, + 33284.0, + 32677.0, + 31636.0, + 30965.0, + 31755.0, + 30618.0, + 30703.0, + 30336.0, + 29304.0, + 27350.0, + 25770.0, + 25317.0, + 25797.0, + 26079.0, + 25602.0, + 23779.0, + 21830.0, + 21247.0, + 20075.0, + 19600.0, + 18032.0, + 16648.0, + 15618.0, + 14495.0, + 13822.0, + 14264.0, + 14208.0, + 12882.0, + 11868.0, + 11421.0, + 11028.0, + 10280.0, + 9812.0, + 9542.0, + 9013.0, + 8767.0, + 8309.0, + 7774.0, + 7274.0, + 6706.0, + 6641.0, + 6308.0, + 5835.0, + 5373.0, + 4902.0, + 4802.0, + 4458.0, + 4473.0, + 4047.0, + 3590.0, + 3618.0, + 3293.0, + 3088.0, + 2915.0, + 2868.0, + 36004.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 3.0, + 10.0, + 8.0, + 6.0, + 3.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 5.0, + 1408.0, + 3297.0, + 1432.0, + 8238.0, + 6157.0, + 4237.0, + 3728.0, + 5128.0, + 3148.0, + 2624.0, + 2641.0, + 2701.0, + 3244.0, + 23567.0, + 15565.0, + 4315.0, + 3636.0, + 4151.0, + 4762.0, + 4568.0, + 4798.0, + 4977.0, + 5224.0, + 5327.0, + 7099.0, + 12805.0, + 12997.0, + 12562.0, + 11978.0, + 8666.0, + 7410.0, + 7655.0, + 14927.0, + 39738.0, + 9040.0, + 12946.0, + 12438.0, + 17329.0, + 12460.0, + 10077.0, + 11788.0, + 23598.0, + 16279.0, + 9980.0, + 6790.0, + 6412.0, + 6332.0, + 6425.0, + 8331.0, + 7334.0, + 6134.0, + 6774.0, + 5967.0, + 7015.0, + 11089.0, + 8620.0, + 6348.0, + 5036.0, + 5704.0, + 5512.0, + 6173.0, + 6225.0, + 5863.0, + 5293.0, + 5027.0, + 4266.0, + 4514.0, + 4296.0, + 3881.0, + 3405.0, + 3267.0, + 3276.0, + 3031.0, + 3079.0, + 3090.0, + 2951.0, + 2362.0, + 2503.0, + 2391.0, + 2294.0, + 1742.0, + 1632.0, + 1648.0, + 1578.0, + 1496.0, + 1371.0, + 1264.0, + 1185.0, + 1215.0, + 1134.0, + 1079.0, + 1012.0, + 818.0, + 830.0, + 899.0, + 832.0, + 899.0, + 849.0, + 759.0, + 705.0, + 571.0, + 417.0, + 533.0, + 434.0, + 423.0, + 332.0, + 321.0, + 275.0, + 248.0, + 278.0, + 259.0, + 255.0, + 218.0, + 202.0, + 203.0, + 155.0, + 160.0, + 139.0, + 150.0, + 170.0, + 199.0, + 153.0, + 1451.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 12013.0, + 9751.0, + 13631.0, + 9078.0, + 4461.0, + 2968.0, + 2745.0, + 2895.0, + 2554.0, + 2646.0, + 2558.0, + 2882.0, + 3023.0, + 4986.0, + 4120.0, + 4589.0, + 4414.0, + 4756.0, + 5196.0, + 7821.0, + 5389.0, + 5319.0, + 5252.0, + 6191.0, + 14961.0, + 15417.0, + 13134.0, + 12029.0, + 9966.0, + 7021.0, + 7062.0, + 8456.0, + 10481.0, + 37080.0, + 9449.0, + 17460.0, + 12396.0, + 14730.0, + 10140.0, + 10269.0, + 13365.0, + 27763.0, + 17353.0, + 35353.0, + 8386.0, + 7315.0, + 8224.0, + 6667.0, + 6976.0, + 8224.0, + 6763.0, + 6565.0, + 7025.0, + 8410.0, + 9459.0, + 8081.0, + 8197.0, + 6862.0, + 7009.0, + 5961.0, + 5840.0, + 6311.0, + 5553.0, + 5312.0, + 5122.0, + 5091.0, + 4614.0, + 4203.0, + 3749.0, + 3938.0, + 4622.0, + 4145.0, + 3274.0, + 3805.0, + 3722.0, + 3505.0, + 3301.0, + 2849.0, + 2655.0, + 2691.0, + 2515.0, + 2517.0, + 2253.0, + 2263.0, + 2279.0, + 2242.0, + 2101.0, + 1926.0, + 1992.0, + 1764.0, + 1657.0, + 1577.0, + 1528.0, + 1400.0, + 1432.0, + 1384.0, + 1412.0, + 1433.0, + 1140.0, + 1123.0, + 1112.0, + 1123.0, + 932.0, + 974.0, + 821.0, + 776.0, + 707.0, + 746.0, + 664.0, + 593.0, + 503.0, + 506.0, + 425.0, + 401.0, + 388.0, + 363.0, + 359.0, + 357.0, + 433.0, + 415.0, + 319.0, + 316.0, + 3537.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 5.0, + 2.0, + 5.0, + 24.0, + 10.0, + 11.0, + 11.0, + 12.0, + 26.0, + 10.0, + 9.0, + 6.0, + 2.0, + 13.0, + 18.0, + 37.0, + 43.0, + 2.0, + 2.0, + 1.0, + 1.0, + 3.0, + 3.0, + 6.0, + 23.0, + 15.0, + 67.0, + 11.0, + 2.0, + 2.0, + 4.0, + 6.0, + 1.0, + 115.0, + 12.0, + 2.0, + 6.0, + 49.0, + 26.0, + 36.0, + 32.0, + 62.0, + 10.0, + 6.0, + 3.0, + 4.0, + 3.0, + 4.0, + 5.0, + 5.0, + 3.0, + 0.0, + 3.0, + 5.0, + 2.0, + 2.0, + 20.0, + 21.0, + 2.0, + 2.0, + 2.0, + 2.0, + 4.0, + 3.0, + 0.0, + 4.0, + 6.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 3.0, + 9.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 6.0, + 0.0, + 8.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 5.0, + 5.0, + 0.0, + 2.0, + 6.0, + 3.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 33.0, + 9296.0, + 1568.0, + 548.0, + 654.0, + 407.0, + 322.0, + 393.0, + 424.0, + 553.0, + 472.0, + 542.0, + 565.0, + 1534.0, + 980.0, + 861.0, + 793.0, + 682.0, + 668.0, + 752.0, + 877.0, + 750.0, + 885.0, + 821.0, + 1646.0, + 6646.0, + 2257.0, + 1019.0, + 956.0, + 5710.0, + 792.0, + 724.0, + 765.0, + 2438.0, + 15126.0, + 1005.0, + 1091.0, + 980.0, + 1110.0, + 1792.0, + 1820.0, + 1418.0, + 1176.0, + 1167.0, + 904.0, + 886.0, + 739.0, + 962.0, + 979.0, + 1548.0, + 767.0, + 785.0, + 939.0, + 1050.0, + 1654.0, + 1985.0, + 1902.0, + 1155.0, + 1276.0, + 1253.0, + 1246.0, + 1161.0, + 689.0, + 537.0, + 631.0, + 888.0, + 623.0, + 691.0, + 471.0, + 405.0, + 380.0, + 432.0, + 414.0, + 438.0, + 478.0, + 583.0, + 420.0, + 359.0, + 361.0, + 471.0, + 334.0, + 239.0, + 264.0, + 359.0, + 247.0, + 194.0, + 190.0, + 174.0, + 132.0, + 123.0, + 213.0, + 188.0, + 118.0, + 82.0, + 73.0, + 120.0, + 120.0, + 119.0, + 91.0, + 42.0, + 68.0, + 62.0, + 76.0, + 55.0, + 28.0, + 40.0, + 32.0, + 31.0, + 35.0, + 25.0, + 29.0, + 17.0, + 25.0, + 24.0, + 17.0, + 40.0, + 19.0, + 35.0, + 107.0, + 170.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 9691.0, + 10414.0, + 16777216.0, + 16733855.0, + 16777216.0, + 1492.0, + 10.0, + 0.0, + 30.0, + 111929.0, + 1708659.0, + 4551423.0, + 16777216.0, + 16777216.0, + 16777216.0, + 2834846.0, + 16777216.0, + 9644045.0, + 6890359.0, + 7441590.0, + 7029367.0, + 6485800.0, + 6991918.0, + 8213395.0, + 11772377.0, + 16777216.0, + 16777216.0, + 12764673.0, + 8414636.0, + 9084040.0, + 12098492.0, + 16777216.0, + 16777216.0, + 15406039.0, + 15145456.0, + 14706841.0, + 16777216.0, + 16777216.0, + 16525131.0, + 16777216.0, + 15487702.0, + 12803484.0, + 12377947.0, + 11888164.0, + 11801750.0, + 11921907.0, + 12396884.0, + 13106420.0, + 13002931.0, + 13289458.0, + 11759340.0, + 11296732.0, + 12004121.0, + 13045113.0, + 12319747.0, + 10589549.0, + 10031468.0, + 9638072.0, + 9422025.0, + 9384838.0, + 9452510.0, + 9214291.0, + 8321873.0, + 7657919.0, + 7411377.0, + 7106317.0, + 6805594.0, + 6633282.0, + 6437449.0, + 6175092.0, + 5956855.0, + 5779942.0, + 5758829.0, + 5552579.0, + 5468595.0, + 5270037.0, + 4942259.0, + 4608753.0, + 4269413.0, + 4030622.0, + 3829832.0, + 3594723.0, + 3379917.0, + 3256991.0, + 3091995.0, + 2919693.0, + 2747090.0, + 2624261.0, + 2521294.0, + 2434546.0, + 2258778.0, + 2107473.0, + 2008607.0, + 1912623.0, + 1861684.0, + 1787000.0, + 1686830.0, + 1586326.0, + 1486593.0, + 1385786.0, + 1294029.0, + 1214072.0, + 1126996.0, + 1040460.0, + 976024.0, + 907814.0, + 854011.0, + 807667.0, + 758972.0, + 714886.0, + 674929.0, + 632501.0, + 593628.0, + 551603.0, + 514926.0, + 488257.0, + 460680.0, + 436028.0, + 404396.0, + 374381.0, + 344612.0, + 318620.0, + 297477.0, + 275407.0, + 253815.0, + 233380.0, + 215112.0, + 198971.0, + 183839.0, + 170497.0, + 159640.0, + 148281.0, + 137656.0, + 127670.0, + 117886.0, + 108878.0, + 103101.0, + 94854.0, + 997182.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 11216.0, + 985980.0, + 1298.0, + 0.0, + 0.0, + 4.0, + 0.0, + 0.0, + 0.0, + 41.0, + 1287545.0, + 599712.0, + 21907.0, + 21501.0, + 183050.0, + 107053.0, + 74288.0, + 77134.0, + 82066.0, + 87437.0, + 92448.0, + 103928.0, + 135483.0, + 1748837.0, + 256008.0, + 145282.0, + 194476.0, + 144027.0, + 765530.0, + 403661.0, + 131406.0, + 140383.0, + 152892.0, + 154724.0, + 154705.0, + 240597.0, + 194802.0, + 169386.0, + 173072.0, + 171898.0, + 146805.0, + 139983.0, + 146190.0, + 261497.0, + 244223.0, + 173075.0, + 181389.0, + 209432.0, + 178687.0, + 177987.0, + 210584.0, + 286784.0, + 268588.0, + 199337.0, + 179991.0, + 165877.0, + 162359.0, + 175713.0, + 163730.0, + 158489.0, + 147260.0, + 144036.0, + 147080.0, + 160093.0, + 173223.0, + 189434.0, + 155542.0, + 128736.0, + 125093.0, + 133449.0, + 139459.0, + 132576.0, + 124011.0, + 123273.0, + 115842.0, + 116117.0, + 110401.0, + 97580.0, + 89714.0, + 84919.0, + 81950.0, + 80647.0, + 80277.0, + 74760.0, + 76866.0, + 79073.0, + 75944.0, + 75823.0, + 66038.0, + 61072.0, + 58246.0, + 57924.0, + 56997.0, + 52944.0, + 47638.0, + 44790.0, + 43031.0, + 41919.0, + 39968.0, + 37287.0, + 35161.0, + 33124.0, + 30473.0, + 28551.0, + 28910.0, + 27472.0, + 26158.0, + 24821.0, + 23686.0, + 22607.0, + 21106.0, + 20019.0, + 19025.0, + 17667.0, + 16081.0, + 15417.0, + 14724.0, + 13053.0, + 12261.0, + 11438.0, + 10766.0, + 10235.0, + 9628.0, + 9063.0, + 8329.0, + 7761.0, + 7318.0, + 6769.0, + 6452.0, + 5816.0, + 5332.0, + 5119.0, + 4980.0, + 53738.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1207837.0, + 337.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 83.0, + 1710373.0, + 52231.0, + 669.0, + 14928.0, + 189246.0, + 257192.0, + 187993.0, + 133319.0, + 126793.0, + 127691.0, + 124010.0, + 172136.0, + 252790.0, + 1339270.0, + 129973.0, + 106898.0, + 133406.0, + 479361.0, + 668503.0, + 302768.0, + 244572.0, + 237626.0, + 220627.0, + 261010.0, + 323620.0, + 288751.0, + 462819.0, + 222574.0, + 266234.0, + 224847.0, + 201305.0, + 203612.0, + 270431.0, + 299022.0, + 218865.0, + 230248.0, + 223584.0, + 244435.0, + 238574.0, + 267067.0, + 342354.0, + 372697.0, + 272471.0, + 246240.0, + 246457.0, + 240403.0, + 253306.0, + 255434.0, + 252356.0, + 245087.0, + 227186.0, + 222968.0, + 222530.0, + 234434.0, + 242766.0, + 224007.0, + 190087.0, + 188180.0, + 186765.0, + 195269.0, + 191990.0, + 190984.0, + 183856.0, + 176210.0, + 164498.0, + 160730.0, + 155587.0, + 147940.0, + 135670.0, + 129032.0, + 126547.0, + 124479.0, + 118675.0, + 119231.0, + 118757.0, + 115623.0, + 114371.0, + 102235.0, + 97611.0, + 93860.0, + 91046.0, + 89622.0, + 84349.0, + 80347.0, + 75656.0, + 73772.0, + 71286.0, + 68914.0, + 64262.0, + 60327.0, + 57768.0, + 53475.0, + 50364.0, + 47789.0, + 46961.0, + 45575.0, + 42967.0, + 40322.0, + 38187.0, + 37349.0, + 35021.0, + 33183.0, + 31174.0, + 29150.0, + 27707.0, + 26161.0, + 24550.0, + 22651.0, + 21712.0, + 20541.0, + 19526.0, + 18354.0, + 16773.0, + 15852.0, + 14688.0, + 13966.0, + 13780.0, + 12984.0, + 12104.0, + 11298.0, + 10266.0, + 9463.0, + 9156.0, + 8550.0, + 8067.0, + 7301.0, + 83074.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 16777216.0, + 3237923.0, + 0.0, + 0.0, + 0.0, + 2.0, + 22.0, + 343.0, + 179137.0, + 1411717.0, + 9522256.0, + 16777216.0, + 4832728.0, + 1121233.0, + 7611579.0, + 15619468.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16274952.0, + 12956712.0, + 15197662.0, + 16777216.0, + 16777216.0, + 16777216.0, + 11874594.0, + 13142701.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16741527.0, + 16165378.0, + 15365263.0, + 14566258.0, + 13937879.0, + 13406922.0, + 12890622.0, + 12316187.0, + 11821143.0, + 11369901.0, + 10856282.0, + 10285826.0, + 9935593.0, + 9563312.0, + 9072129.0, + 8369714.0, + 7933340.0, + 7627369.0, + 7394943.0, + 7167773.0, + 6870790.0, + 6522040.0, + 6160984.0, + 5838375.0, + 5550554.0, + 5280310.0, + 4995515.0, + 4698141.0, + 4418727.0, + 4159822.0, + 3914405.0, + 3704339.0, + 3526126.0, + 3337198.0, + 3146925.0, + 2963353.0, + 2775887.0, + 2615663.0, + 2465414.0, + 2327741.0, + 2204637.0, + 2074997.0, + 1958205.0, + 1840516.0, + 1728502.0, + 1616884.0, + 1512932.0, + 1421843.0, + 1325314.0, + 1235491.0, + 1151697.0, + 1072233.0, + 999065.0, + 937037.0, + 879671.0, + 820826.0, + 766528.0, + 713625.0, + 662949.0, + 618276.0, + 576218.0, + 539449.0, + 503835.0, + 469834.0, + 434936.0, + 403956.0, + 4685084.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 470.0, + 4057.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 575.0, + 8221.0, + 355.0, + 1008.0, + 749.0, + 191.0, + 294.0, + 495.0, + 528.0, + 430.0, + 497.0, + 707.0, + 814.0, + 918.0, + 9263.0, + 1293.0, + 1025.0, + 917.0, + 1037.0, + 4958.0, + 1350.0, + 643.0, + 630.0, + 563.0, + 598.0, + 763.0, + 2076.0, + 1155.0, + 782.0, + 700.0, + 681.0, + 645.0, + 763.0, + 592.0, + 1488.0, + 666.0, + 623.0, + 713.0, + 662.0, + 702.0, + 802.0, + 1021.0, + 952.0, + 1049.0, + 575.0, + 536.0, + 520.0, + 497.0, + 523.0, + 635.0, + 669.0, + 624.0, + 443.0, + 512.0, + 684.0, + 1001.0, + 1224.0, + 546.0, + 508.0, + 594.0, + 646.0, + 622.0, + 615.0, + 634.0, + 639.0, + 562.0, + 616.0, + 288.0, + 242.0, + 179.0, + 177.0, + 231.0, + 236.0, + 229.0, + 188.0, + 187.0, + 227.0, + 239.0, + 292.0, + 216.0, + 153.0, + 155.0, + 169.0, + 118.0, + 103.0, + 109.0, + 91.0, + 116.0, + 102.0, + 92.0, + 101.0, + 71.0, + 67.0, + 58.0, + 49.0, + 58.0, + 46.0, + 81.0, + 62.0, + 59.0, + 54.0, + 39.0, + 43.0, + 32.0, + 36.0, + 37.0, + 30.0, + 25.0, + 23.0, + 27.0, + 17.0, + 21.0, + 20.0, + 27.0, + 16.0, + 18.0, + 13.0, + 11.0, + 13.0, + 13.0, + 61.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 205213.0, + 1536002.0, + 16777216.0, + 13162792.0, + 38379.0, + 713.0, + 222.0, + 163.0, + 734.0, + 103292.0, + 1794195.0, + 3246144.0, + 8469664.0, + 16777216.0, + 2913603.0, + 1270938.0, + 3201236.0, + 3938133.0, + 3574196.0, + 3208352.0, + 2905524.0, + 2659932.0, + 2852215.0, + 3946684.0, + 7063621.0, + 13903994.0, + 7418893.0, + 3158508.0, + 3143287.0, + 4337348.0, + 8030970.0, + 7369210.0, + 6003721.0, + 5368726.0, + 5394833.0, + 5698419.0, + 6376751.0, + 5930910.0, + 5929728.0, + 5855273.0, + 4729752.0, + 4136089.0, + 3680238.0, + 3629515.0, + 3734471.0, + 4057707.0, + 3893493.0, + 4105188.0, + 4036019.0, + 3607797.0, + 3265133.0, + 3517882.0, + 3801404.0, + 3971699.0, + 3313661.0, + 2933338.0, + 2739063.0, + 2531574.0, + 2485980.0, + 2600517.0, + 2479980.0, + 2263244.0, + 2098755.0, + 1954718.0, + 1884145.0, + 1779987.0, + 1740407.0, + 1650343.0, + 1525992.0, + 1389294.0, + 1371465.0, + 1337786.0, + 1334879.0, + 1299691.0, + 1234777.0, + 1191357.0, + 1081594.0, + 973481.0, + 888408.0, + 831939.0, + 765092.0, + 724362.0, + 679616.0, + 642482.0, + 609975.0, + 561306.0, + 541095.0, + 524424.0, + 504424.0, + 466207.0, + 412682.0, + 392179.0, + 367987.0, + 355437.0, + 351236.0, + 323431.0, + 296725.0, + 277743.0, + 255284.0, + 235023.0, + 215916.0, + 196561.0, + 183656.0, + 170506.0, + 152170.0, + 138454.0, + 131473.0, + 125528.0, + 121564.0, + 115143.0, + 107032.0, + 99979.0, + 90046.0, + 83952.0, + 78794.0, + 75431.0, + 68470.0, + 62558.0, + 57420.0, + 52754.0, + 47430.0, + 44280.0, + 41662.0, + 38834.0, + 34543.0, + 32269.0, + 29485.0, + 25866.0, + 23908.0, + 21487.0, + 21457.0, + 20403.0, + 17653.0, + 15864.0, + 14932.0, + 14170.0, + 13607.0, + 12728.0, + 119104.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 284121.0, + 7419517.0, + 6517273.0, + 5707000.0, + 785.0, + 185.0, + 172.0, + 63.0, + 102.0, + 315.0, + 273726.0, + 2218632.0, + 10473215.0, + 10382370.0, + 391168.0, + 4589231.0, + 4064099.0, + 6370094.0, + 5756328.0, + 3582974.0, + 3248198.0, + 3218980.0, + 3095578.0, + 4213472.0, + 8057046.0, + 12108337.0, + 5095899.0, + 5254219.0, + 4279860.0, + 5313246.0, + 8542249.0, + 6987219.0, + 6454572.0, + 5074962.0, + 5055485.0, + 5477671.0, + 5998232.0, + 5586851.0, + 6511321.0, + 6190620.0, + 5247080.0, + 5926064.0, + 4182641.0, + 4111784.0, + 4242975.0, + 4598278.0, + 3919748.0, + 4392285.0, + 4432022.0, + 4051124.0, + 3899645.0, + 4281475.0, + 4511377.0, + 4203197.0, + 3785215.0, + 3638853.0, + 3227336.0, + 3064032.0, + 3138503.0, + 3250743.0, + 3108747.0, + 2964461.0, + 2916173.0, + 2720623.0, + 2605906.0, + 2617580.0, + 2560839.0, + 2444363.0, + 2241942.0, + 2055349.0, + 2060728.0, + 2084862.0, + 2045498.0, + 1991233.0, + 1935568.0, + 1809860.0, + 1696593.0, + 1612348.0, + 1520149.0, + 1367301.0, + 1280648.0, + 1222721.0, + 1169165.0, + 1114330.0, + 1055017.0, + 1009525.0, + 990574.0, + 954005.0, + 906327.0, + 850208.0, + 787555.0, + 740822.0, + 702866.0, + 686744.0, + 668620.0, + 615282.0, + 586775.0, + 552728.0, + 519758.0, + 487752.0, + 454242.0, + 423554.0, + 391085.0, + 362009.0, + 333750.0, + 310766.0, + 296638.0, + 280432.0, + 268138.0, + 254861.0, + 242574.0, + 229866.0, + 217388.0, + 202129.0, + 193494.0, + 178708.0, + 164162.0, + 154736.0, + 144843.0, + 135296.0, + 126243.0, + 117316.0, + 108738.0, + 100689.0, + 93146.0, + 87002.0, + 79809.0, + 74288.0, + 68414.0, + 63765.0, + 59794.0, + 55652.0, + 51932.0, + 48625.0, + 45523.0, + 42765.0, + 39544.0, + 35735.0, + 393043.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1532.0, + 19045.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 124.0, + 5318.0, + 20382.0, + 22221.0, + 9753.0, + 2563.0, + 5846.0, + 5114.0, + 7566.0, + 6059.0, + 6046.0, + 5629.0, + 4204.0, + 5387.0, + 9828.0, + 8249.0, + 17088.0, + 8340.0, + 3500.0, + 4656.0, + 8373.0, + 14549.0, + 7394.0, + 5603.0, + 5419.0, + 7039.0, + 7503.0, + 8793.0, + 9003.0, + 5703.0, + 3909.0, + 2993.0, + 2579.0, + 2423.0, + 2355.0, + 2492.0, + 2663.0, + 2543.0, + 3463.0, + 3187.0, + 2487.0, + 1976.0, + 2093.0, + 2682.0, + 2798.0, + 1735.0, + 1518.0, + 1478.0, + 1350.0, + 1261.0, + 1219.0, + 2035.0, + 1187.0, + 1113.0, + 1077.0, + 1014.0, + 746.0, + 755.0, + 657.0, + 528.0, + 398.0, + 456.0, + 454.0, + 625.0, + 398.0, + 447.0, + 518.0, + 231.0, + 282.0, + 278.0, + 269.0, + 259.0, + 242.0, + 332.0, + 232.0, + 202.0, + 184.0, + 173.0, + 119.0, + 162.0, + 122.0, + 118.0, + 100.0, + 117.0, + 154.0, + 116.0, + 127.0, + 138.0, + 85.0, + 90.0, + 60.0, + 84.0, + 68.0, + 80.0, + 42.0, + 32.0, + 29.0, + 37.0, + 40.0, + 30.0, + 29.0, + 28.0, + 11.0, + 4.0, + 11.0, + 10.0, + 8.0, + 7.0, + 17.0, + 12.0, + 13.0, + 9.0, + 13.0, + 9.0, + 4.0, + 3.0, + 7.0, + 4.0, + 3.0, + 1.0, + 4.0, + 1.0, + 0.0, + 3.0, + 2.0, + 6.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 203.0, + 324983.0, + 3421583.0, + 1396329.0, + 2920.0, + 119.0, + 311.0, + 175.0, + 27385.0, + 27152.0, + 1150358.0, + 1842361.0, + 3123012.0, + 974583.0, + 302157.0, + 513934.0, + 681633.0, + 647147.0, + 712472.0, + 548797.0, + 607424.0, + 526180.0, + 673603.0, + 1272144.0, + 1692418.0, + 3078377.0, + 804278.0, + 634750.0, + 845249.0, + 1371586.0, + 2040580.0, + 993063.0, + 964099.0, + 859726.0, + 1030160.0, + 1090462.0, + 1152569.0, + 1159777.0, + 858254.0, + 727137.0, + 688979.0, + 633244.0, + 618671.0, + 702146.0, + 637024.0, + 862300.0, + 784069.0, + 952670.0, + 723836.0, + 629671.0, + 668601.0, + 782701.0, + 817445.0, + 651539.0, + 628185.0, + 511604.0, + 498371.0, + 494539.0, + 466236.0, + 480304.0, + 428674.0, + 359324.0, + 369848.0, + 348739.0, + 345596.0, + 337808.0, + 344050.0, + 359611.0, + 278919.0, + 255113.0, + 271685.0, + 272485.0, + 266000.0, + 251577.0, + 234241.0, + 212091.0, + 192300.0, + 182410.0, + 173972.0, + 159815.0, + 139345.0, + 137643.0, + 126678.0, + 118859.0, + 109708.0, + 103647.0, + 102453.0, + 102069.0, + 93716.0, + 89999.0, + 77563.0, + 73425.0, + 70768.0, + 70091.0, + 66296.0, + 56754.0, + 52516.0, + 48562.0, + 43991.0, + 40593.0, + 36922.0, + 36437.0, + 34062.0, + 30261.0, + 26329.0, + 25629.0, + 24032.0, + 22835.0, + 22976.0, + 20720.0, + 19295.0, + 17288.0, + 17282.0, + 15377.0, + 14893.0, + 13147.0, + 12539.0, + 10651.0, + 9460.0, + 8236.0, + 7614.0, + 6818.0, + 6766.0, + 6078.0, + 5610.0, + 5083.0, + 4604.0, + 4504.0, + 4446.0, + 4228.0, + 4082.0, + 3516.0, + 2949.0, + 2657.0, + 24538.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 26678.0, + 3447.0, + 3789.0, + 30264.0, + 813.0, + 1485.0, + 695.0, + 909.0, + 606.0, + 534.0, + 529.0, + 469.0, + 452.0, + 535.0, + 1143.0, + 550.0, + 583.0, + 608.0, + 650.0, + 540.0, + 584.0, + 545.0, + 608.0, + 780.0, + 811.0, + 62020.0, + 9775.0, + 662.0, + 550.0, + 1119.0, + 605.0, + 674.0, + 753.0, + 5672.0, + 7789.0, + 633.0, + 581.0, + 554.0, + 576.0, + 546.0, + 587.0, + 620.0, + 841.0, + 823.0, + 584.0, + 679.0, + 541.0, + 514.0, + 632.0, + 479.0, + 425.0, + 673.0, + 540.0, + 557.0, + 582.0, + 570.0, + 721.0, + 588.0, + 513.0, + 527.0, + 606.0, + 616.0, + 488.0, + 429.0, + 449.0, + 420.0, + 431.0, + 521.0, + 469.0, + 529.0, + 434.0, + 389.0, + 398.0, + 400.0, + 418.0, + 331.0, + 371.0, + 376.0, + 359.0, + 319.0, + 318.0, + 326.0, + 277.0, + 311.0, + 298.0, + 251.0, + 284.0, + 245.0, + 229.0, + 229.0, + 247.0, + 177.0, + 274.0, + 173.0, + 168.0, + 162.0, + 237.0, + 304.0, + 226.0, + 164.0, + 161.0, + 148.0, + 157.0, + 121.0, + 141.0, + 106.0, + 126.0, + 114.0, + 121.0, + 129.0, + 139.0, + 84.0, + 71.0, + 68.0, + 68.0, + 84.0, + 70.0, + 68.0, + 58.0, + 48.0, + 45.0, + 754.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 612.0, + 0.0, + 10.0, + 120.0, + 7949.0, + 3676.0, + 342.0, + 394.0, + 666.0, + 726.0, + 699.0, + 723.0, + 669.0, + 676.0, + 729.0, + 735.0, + 826.0, + 802.0, + 2900.0, + 949.0, + 940.0, + 935.0, + 1037.0, + 992.0, + 1886.0, + 25464.0, + 2066.0, + 6445.0, + 4828.0, + 2733.0, + 2890.0, + 4448.0, + 3714.0, + 4022.0, + 1987.0, + 2878.0, + 2341.0, + 7796.0, + 3490.0, + 1439.0, + 5356.0, + 7321.0, + 1502.0, + 1308.0, + 1317.0, + 1308.0, + 1283.0, + 1183.0, + 1374.0, + 1443.0, + 994.0, + 1039.0, + 1080.0, + 1195.0, + 1054.0, + 1132.0, + 1219.0, + 1246.0, + 1169.0, + 1143.0, + 1145.0, + 1227.0, + 1186.0, + 1252.0, + 1295.0, + 1317.0, + 1207.0, + 1050.0, + 1060.0, + 1050.0, + 1030.0, + 1059.0, + 1135.0, + 1053.0, + 929.0, + 1014.0, + 1147.0, + 873.0, + 861.0, + 864.0, + 829.0, + 868.0, + 864.0, + 840.0, + 697.0, + 784.0, + 878.0, + 671.0, + 649.0, + 675.0, + 591.0, + 573.0, + 516.0, + 551.0, + 485.0, + 536.0, + 565.0, + 512.0, + 490.0, + 469.0, + 409.0, + 496.0, + 451.0, + 392.0, + 403.0, + 366.0, + 369.0, + 372.0, + 357.0, + 351.0, + 360.0, + 298.0, + 293.0, + 284.0, + 258.0, + 279.0, + 208.0, + 225.0, + 221.0, + 231.0, + 227.0, + 236.0, + 227.0, + 223.0, + 2792.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 24.0, + 784.0, + 2023.0, + 5992.0, + 63611.0, + 40246.0, + 64344.0, + 62179.0, + 199710.0, + 1265566.0, + 107630.0, + 67994.0, + 61316.0, + 56048.0, + 58655.0, + 61556.0, + 61781.0, + 85545.0, + 83789.0, + 89527.0, + 95611.0, + 123470.0, + 145845.0, + 106814.0, + 120220.0, + 131663.0, + 162438.0, + 161515.0, + 207940.0, + 1431739.0, + 186990.0, + 185247.0, + 193563.0, + 167033.0, + 159596.0, + 168959.0, + 563029.0, + 180348.0, + 174399.0, + 193121.0, + 225362.0, + 193479.0, + 182588.0, + 192227.0, + 263279.0, + 395729.0, + 259756.0, + 235811.0, + 242212.0, + 210840.0, + 210260.0, + 213902.0, + 209905.0, + 194051.0, + 194831.0, + 201401.0, + 201834.0, + 213909.0, + 234323.0, + 186803.0, + 180483.0, + 187901.0, + 190391.0, + 192823.0, + 200911.0, + 204339.0, + 185795.0, + 184290.0, + 180391.0, + 181273.0, + 170565.0, + 152799.0, + 151223.0, + 143720.0, + 137073.0, + 131708.0, + 126803.0, + 124575.0, + 122209.0, + 119296.0, + 118583.0, + 114746.0, + 106949.0, + 98957.0, + 97451.0, + 97408.0, + 96181.0, + 92885.0, + 88846.0, + 83396.0, + 78558.0, + 76966.0, + 71837.0, + 69172.0, + 64607.0, + 60585.0, + 56279.0, + 53890.0, + 52386.0, + 52791.0, + 50285.0, + 46232.0, + 43709.0, + 41979.0, + 40472.0, + 39243.0, + 36185.0, + 35036.0, + 32925.0, + 31664.0, + 29503.0, + 28101.0, + 25978.0, + 24986.0, + 23625.0, + 22482.0, + 21452.0, + 19421.0, + 18441.0, + 17763.0, + 17140.0, + 15509.0, + 15062.0, + 13757.0, + 12902.0, + 12146.0, + 11481.0, + 10918.0, + 10242.0, + 9670.0, + 116568.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 30.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 1.0, + 3.0, + 1.0, + 4.0, + 3.0, + 5.0, + 2.0, + 4.0, + 5.0, + 23.0, + 16.0, + 4.0, + 3.0, + 11.0, + 1.0, + 4.0, + 4.0, + 1.0, + 0.0, + 1.0, + 3.0, + 9.0, + 7.0, + 7.0, + 3.0, + 1.0, + 7.0, + 5.0, + 4.0, + 4.0, + 0.0, + 0.0, + 5.0, + 13.0, + 8.0, + 0.0, + 0.0, + 2.0, + 9.0, + 2.0, + 8.0, + 5.0, + 4.0, + 5.0, + 5.0, + 0.0, + 1.0, + 2.0, + 1.0, + 6.0, + 11.0, + 3.0, + 5.0, + 1.0, + 6.0, + 7.0, + 15.0, + 4.0, + 3.0, + 11.0, + 3.0, + 0.0, + 2.0, + 3.0, + 19.0, + 2.0, + 2.0, + 0.0, + 8.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 0.0, + 2.0, + 3.0, + 1.0, + 2.0, + 0.0, + 0.0, + 3.0, + 2.0, + 3.0, + 1.0, + 2.0, + 3.0, + 2.0, + 2.0, + 1.0, + 4.0, + 1.0, + 4.0, + 22.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 81.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 25166.0, + 9282.0, + 4337.0, + 8081.0, + 77921.0, + 68804.0, + 43989.0, + 28776.0, + 23761.0, + 20277.0, + 17079.0, + 15773.0, + 16880.0, + 41471.0, + 78518.0, + 37931.0, + 29351.0, + 25577.0, + 28600.0, + 35028.0, + 25662.0, + 27301.0, + 28266.0, + 31555.0, + 30389.0, + 90964.0, + 110013.0, + 34747.0, + 35784.0, + 32966.0, + 29393.0, + 28817.0, + 35030.0, + 97522.0, + 67080.0, + 32706.0, + 35552.0, + 41484.0, + 42315.0, + 34260.0, + 42531.0, + 89322.0, + 108992.0, + 57023.0, + 39311.0, + 30147.0, + 28504.0, + 32172.0, + 31087.0, + 28341.0, + 27314.0, + 29431.0, + 28034.0, + 28669.0, + 41944.0, + 41390.0, + 34835.0, + 21926.0, + 22431.0, + 22901.0, + 22857.0, + 25005.0, + 25107.0, + 21942.0, + 20506.0, + 19722.0, + 19471.0, + 20511.0, + 16113.0, + 13887.0, + 13516.0, + 12150.0, + 11139.0, + 10712.0, + 10641.0, + 11387.0, + 10815.0, + 10813.0, + 9738.0, + 9618.0, + 8393.0, + 7442.0, + 7267.0, + 6590.0, + 6893.0, + 6288.0, + 5843.0, + 5476.0, + 4951.0, + 4653.0, + 4451.0, + 3930.0, + 3667.0, + 3651.0, + 3423.0, + 3356.0, + 3338.0, + 2947.0, + 2770.0, + 2731.0, + 2566.0, + 2348.0, + 2071.0, + 1951.0, + 1939.0, + 1838.0, + 1615.0, + 1388.0, + 1213.0, + 1213.0, + 1011.0, + 1011.0, + 911.0, + 791.0, + 796.0, + 764.0, + 646.0, + 753.0, + 796.0, + 604.0, + 558.0, + 582.0, + 469.0, + 425.0, + 4003.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 78.0, + 237.0, + 16202.0, + 63726.0, + 30976.0, + 34862.0, + 26700.0, + 25480.0, + 19475.0, + 17541.0, + 17329.0, + 17548.0, + 15782.0, + 17354.0, + 24001.0, + 21218.0, + 25487.0, + 32275.0, + 29333.0, + 28382.0, + 41536.0, + 32229.0, + 31235.0, + 33886.0, + 46013.0, + 52537.0, + 61719.0, + 46326.0, + 38251.0, + 32326.0, + 28835.0, + 30394.0, + 31816.0, + 108119.0, + 29589.0, + 32439.0, + 39175.0, + 45434.0, + 40968.0, + 34652.0, + 56809.0, + 92400.0, + 70081.0, + 140092.0, + 60694.0, + 31773.0, + 30991.0, + 31381.0, + 32134.0, + 27387.0, + 28777.0, + 27308.0, + 27264.0, + 30524.0, + 35778.0, + 32073.0, + 25099.0, + 24780.0, + 23558.0, + 22239.0, + 21897.0, + 21652.0, + 23248.0, + 21356.0, + 20736.0, + 19804.0, + 19620.0, + 17667.0, + 17226.0, + 16897.0, + 17229.0, + 16121.0, + 14561.0, + 14676.0, + 16906.0, + 14371.0, + 13480.0, + 12593.0, + 11696.0, + 11108.0, + 10192.0, + 9944.0, + 9927.0, + 8873.0, + 8585.0, + 8307.0, + 8231.0, + 7540.0, + 7247.0, + 6958.0, + 6764.0, + 6422.0, + 6036.0, + 5637.0, + 5403.0, + 4810.0, + 5198.0, + 4875.0, + 4157.0, + 4010.0, + 3940.0, + 3710.0, + 3562.0, + 3340.0, + 3255.0, + 3204.0, + 2956.0, + 2551.0, + 2418.0, + 2266.0, + 2207.0, + 1959.0, + 1836.0, + 1727.0, + 1671.0, + 1662.0, + 1440.0, + 1490.0, + 1254.0, + 1208.0, + 1173.0, + 1173.0, + 1116.0, + 1131.0, + 11089.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 30.0, + 115.0, + 363.0, + 111.0, + 56.0, + 83.0, + 17.0, + 36.0, + 14.0, + 16.0, + 36.0, + 15.0, + 13.0, + 17.0, + 30.0, + 96.0, + 168.0, + 68.0, + 48.0, + 30.0, + 59.0, + 77.0, + 72.0, + 23.0, + 48.0, + 16.0, + 54.0, + 48.0, + 67.0, + 22.0, + 21.0, + 21.0, + 21.0, + 31.0, + 51.0, + 74.0, + 34.0, + 75.0, + 101.0, + 92.0, + 217.0, + 230.0, + 78.0, + 53.0, + 37.0, + 20.0, + 17.0, + 40.0, + 20.0, + 8.0, + 6.0, + 4.0, + 3.0, + 6.0, + 36.0, + 16.0, + 30.0, + 24.0, + 24.0, + 24.0, + 19.0, + 20.0, + 21.0, + 23.0, + 21.0, + 7.0, + 53.0, + 9.0, + 30.0, + 3.0, + 4.0, + 9.0, + 16.0, + 7.0, + 0.0, + 13.0, + 23.0, + 6.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 4.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 67.0, + 383.0, + 10087.0, + 7664.0, + 16972.0, + 10970.0, + 6445.0, + 4478.0, + 3986.0, + 3520.0, + 3005.0, + 3183.0, + 3420.0, + 4170.0, + 5195.0, + 10877.0, + 5217.0, + 5945.0, + 5525.0, + 4764.0, + 4576.0, + 4525.0, + 4802.0, + 5913.0, + 7617.0, + 6200.0, + 10960.0, + 21627.0, + 8468.0, + 5216.0, + 5851.0, + 6055.0, + 4235.0, + 4416.0, + 8419.0, + 64939.0, + 5969.0, + 5902.0, + 6827.0, + 7501.0, + 9684.0, + 12596.0, + 8934.0, + 7849.0, + 7301.0, + 6374.0, + 4950.0, + 4463.0, + 5686.0, + 6749.0, + 6206.0, + 5159.0, + 4625.0, + 4726.0, + 4379.0, + 4906.0, + 5850.0, + 9391.0, + 4693.0, + 4108.0, + 3964.0, + 4063.0, + 4028.0, + 3284.0, + 2883.0, + 2801.0, + 3146.0, + 3353.0, + 3946.0, + 3527.0, + 2262.0, + 2158.0, + 2088.0, + 1925.0, + 1912.0, + 1830.0, + 2097.0, + 1905.0, + 1942.0, + 1638.0, + 1759.0, + 1565.0, + 1267.0, + 1219.0, + 1275.0, + 1129.0, + 1066.0, + 970.0, + 944.0, + 972.0, + 752.0, + 719.0, + 742.0, + 688.0, + 543.0, + 511.0, + 502.0, + 505.0, + 526.0, + 516.0, + 423.0, + 445.0, + 313.0, + 402.0, + 334.0, + 290.0, + 265.0, + 268.0, + 213.0, + 221.0, + 219.0, + 252.0, + 260.0, + 162.0, + 157.0, + 159.0, + 139.0, + 145.0, + 96.0, + 149.0, + 75.0, + 87.0, + 832.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 567654.0, + 0.0, + 27.0, + 609.0, + 2025.0, + 12925.0, + 39732.0, + 6738.0, + 4448.0, + 1584.0, + 1295.0, + 1057.0, + 1033.0, + 2511.0, + 9407.0, + 1260.0, + 1655.0, + 2931.0, + 3050.0, + 7097.0, + 9001.0, + 3555.0, + 1762.0, + 1644.0, + 1894.0, + 17393.0, + 36144.0, + 3674.0, + 3953.0, + 3029.0, + 2658.0, + 2673.0, + 2819.0, + 16780.0, + 2731.0, + 2700.0, + 2795.0, + 2749.0, + 3008.0, + 2902.0, + 3665.0, + 4638.0, + 3786.0, + 3482.0, + 3554.0, + 3246.0, + 2865.0, + 3023.0, + 3140.0, + 3061.0, + 2074.0, + 1807.0, + 1695.0, + 1686.0, + 1807.0, + 1983.0, + 1867.0, + 1909.0, + 1696.0, + 1730.0, + 1675.0, + 1829.0, + 1825.0, + 1674.0, + 1794.0, + 1894.0, + 1849.0, + 1817.0, + 1846.0, + 1766.0, + 1816.0, + 1770.0, + 1570.0, + 1567.0, + 1414.0, + 1484.0, + 1667.0, + 1412.0, + 1309.0, + 1260.0, + 1289.0, + 1209.0, + 1272.0, + 1297.0, + 1113.0, + 1034.0, + 997.0, + 1079.0, + 774.0, + 821.0, + 848.0, + 805.0, + 766.0, + 751.0, + 684.0, + 661.0, + 624.0, + 664.0, + 632.0, + 616.0, + 583.0, + 586.0, + 561.0, + 513.0, + 505.0, + 516.0, + 439.0, + 492.0, + 423.0, + 461.0, + 426.0, + 379.0, + 378.0, + 404.0, + 326.0, + 340.0, + 322.0, + 310.0, + 265.0, + 282.0, + 296.0, + 257.0, + 239.0, + 263.0, + 240.0, + 252.0, + 235.0, + 201.0, + 3097.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 11.0, + 175.0, + 45387.0, + 21013.0, + 20571.0, + 52248.0, + 86357.0, + 200552.0, + 988591.0, + 170562.0, + 91860.0, + 75012.0, + 67953.0, + 68602.0, + 72532.0, + 98627.0, + 86425.0, + 95507.0, + 97154.0, + 127775.0, + 153715.0, + 188343.0, + 241420.0, + 136441.0, + 138408.0, + 146978.0, + 153866.0, + 1041787.0, + 282913.0, + 220914.0, + 267186.0, + 248781.0, + 215239.0, + 204045.0, + 457120.0, + 214507.0, + 213450.0, + 227571.0, + 241979.0, + 269813.0, + 268828.0, + 227449.0, + 408059.0, + 494766.0, + 354081.0, + 284462.0, + 282371.0, + 274202.0, + 275935.0, + 290720.0, + 303487.0, + 284176.0, + 270228.0, + 264100.0, + 269776.0, + 278434.0, + 293158.0, + 255791.0, + 242003.0, + 249054.0, + 257254.0, + 255500.0, + 261098.0, + 272564.0, + 260316.0, + 254553.0, + 245314.0, + 256286.0, + 246691.0, + 228622.0, + 223524.0, + 217560.0, + 210556.0, + 206103.0, + 196633.0, + 191631.0, + 182750.0, + 175021.0, + 172935.0, + 161739.0, + 158412.0, + 150868.0, + 146602.0, + 143844.0, + 144819.0, + 140496.0, + 133714.0, + 128403.0, + 122689.0, + 119151.0, + 111981.0, + 107535.0, + 102926.0, + 97544.0, + 92260.0, + 87042.0, + 84964.0, + 83507.0, + 79612.0, + 75632.0, + 72246.0, + 69492.0, + 66902.0, + 63254.0, + 59147.0, + 56908.0, + 54757.0, + 52012.0, + 48735.0, + 46247.0, + 44159.0, + 41955.0, + 39570.0, + 37765.0, + 36178.0, + 33953.0, + 32427.0, + 30541.0, + 28708.0, + 27877.0, + 26290.0, + 24433.0, + 23302.0, + 21706.0, + 20555.0, + 20171.0, + 18713.0, + 17268.0, + 16429.0, + 15270.0, + 14249.0, + 13488.0, + 175097.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 300.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 21.0, + 33.0, + 47.0, + 3.0, + 3.0, + 6.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 7.0, + 50.0, + 44.0, + 68.0, + 6.0, + 4.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 7.0, + 19.0, + 63.0, + 7.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1124.0, + 76.0, + 297.0, + 3254.0, + 40758.0, + 80092.0, + 76250.0, + 60645.0, + 27973.0, + 24529.0, + 22970.0, + 30149.0, + 28202.0, + 46533.0, + 40365.0, + 18620.0, + 23962.0, + 28121.0, + 45875.0, + 50114.0, + 38623.0, + 46393.0, + 45632.0, + 36820.0, + 44536.0, + 78140.0, + 62161.0, + 78547.0, + 57638.0, + 58251.0, + 46924.0, + 43483.0, + 66380.0, + 128866.0, + 46143.0, + 51153.0, + 52301.0, + 59631.0, + 57875.0, + 57332.0, + 120082.0, + 122695.0, + 67245.0, + 55151.0, + 45010.0, + 41963.0, + 42827.0, + 48562.0, + 47269.0, + 44026.0, + 42070.0, + 43224.0, + 40823.0, + 53173.0, + 58191.0, + 49021.0, + 33035.0, + 31779.0, + 31738.0, + 32200.0, + 35570.0, + 40109.0, + 33167.0, + 30653.0, + 30415.0, + 28105.0, + 29356.0, + 26127.0, + 21490.0, + 20885.0, + 19729.0, + 18474.0, + 17777.0, + 17463.0, + 16993.0, + 17544.0, + 16329.0, + 15356.0, + 15081.0, + 13907.0, + 12708.0, + 12775.0, + 12113.0, + 11537.0, + 10677.0, + 10386.0, + 9943.0, + 8933.0, + 8548.0, + 7820.0, + 7726.0, + 6618.0, + 6119.0, + 5737.0, + 5951.0, + 5470.0, + 5498.0, + 4960.0, + 4781.0, + 4524.0, + 4035.0, + 3824.0, + 3685.0, + 3530.0, + 3472.0, + 3019.0, + 2918.0, + 2562.0, + 2298.0, + 2269.0, + 2056.0, + 1897.0, + 1715.0, + 1605.0, + 1597.0, + 1570.0, + 1445.0, + 1359.0, + 1266.0, + 1126.0, + 1067.0, + 1003.0, + 872.0, + 828.0, + 805.0, + 730.0, + 649.0, + 7144.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 8399.0, + 46546.0, + 8.0, + 389.0, + 5425.0, + 30336.0, + 29622.0, + 28002.0, + 29611.0, + 19986.0, + 15466.0, + 15390.0, + 22745.0, + 17779.0, + 32904.0, + 17119.0, + 20212.0, + 20836.0, + 22660.0, + 28141.0, + 38271.0, + 28510.0, + 38039.0, + 31054.0, + 34592.0, + 40661.0, + 48128.0, + 43634.0, + 65803.0, + 44329.0, + 41502.0, + 36074.0, + 34559.0, + 51239.0, + 37177.0, + 38184.0, + 38390.0, + 43156.0, + 45778.0, + 43997.0, + 58864.0, + 82433.0, + 65815.0, + 116072.0, + 74537.0, + 44209.0, + 45129.0, + 48016.0, + 51903.0, + 52173.0, + 48924.0, + 45634.0, + 42362.0, + 42537.0, + 48688.0, + 45902.0, + 39754.0, + 37209.0, + 36327.0, + 35120.0, + 34539.0, + 33646.0, + 38063.0, + 34832.0, + 29942.0, + 28683.0, + 28350.0, + 26076.0, + 25350.0, + 26963.0, + 26593.0, + 23845.0, + 23348.0, + 22600.0, + 22629.0, + 23365.0, + 21647.0, + 20469.0, + 19543.0, + 19542.0, + 17657.0, + 16844.0, + 15862.0, + 14535.0, + 14108.0, + 13816.0, + 13011.0, + 12613.0, + 11553.0, + 11124.0, + 10466.0, + 9926.0, + 9422.0, + 9092.0, + 8399.0, + 8396.0, + 7928.0, + 7682.0, + 7273.0, + 7076.0, + 6746.0, + 6494.0, + 6029.0, + 5631.0, + 5307.0, + 5098.0, + 4912.0, + 4659.0, + 4339.0, + 3883.0, + 3799.0, + 3614.0, + 3339.0, + 3173.0, + 2829.0, + 2813.0, + 2766.0, + 2482.0, + 2357.0, + 2162.0, + 1895.0, + 1811.0, + 1880.0, + 1828.0, + 1665.0, + 1733.0, + 1589.0, + 1412.0, + 16275.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 32.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 158.0, + 169.0, + 56.0, + 112.0, + 28.0, + 26.0, + 23.0, + 29.0, + 63.0, + 52.0, + 31.0, + 44.0, + 69.0, + 86.0, + 78.0, + 123.0, + 137.0, + 190.0, + 87.0, + 55.0, + 37.0, + 104.0, + 44.0, + 33.0, + 34.0, + 40.0, + 64.0, + 39.0, + 32.0, + 21.0, + 39.0, + 27.0, + 27.0, + 10.0, + 18.0, + 25.0, + 75.0, + 82.0, + 177.0, + 67.0, + 183.0, + 72.0, + 58.0, + 70.0, + 85.0, + 79.0, + 79.0, + 72.0, + 65.0, + 36.0, + 23.0, + 38.0, + 29.0, + 79.0, + 23.0, + 46.0, + 14.0, + 16.0, + 29.0, + 14.0, + 41.0, + 76.0, + 74.0, + 31.0, + 21.0, + 18.0, + 8.0, + 13.0, + 6.0, + 9.0, + 9.0, + 6.0, + 2.0, + 0.0, + 3.0, + 10.0, + 19.0, + 8.0, + 11.0, + 7.0, + 1.0, + 1.0, + 2.0, + 4.0, + 4.0, + 6.0, + 3.0, + 1.0, + 2.0, + 1.0, + 0.0, + 0.0, + 2.0, + 0.0, + 2.0, + 4.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 24.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 11.0, + 224.0, + 9168.0, + 643.0, + 1169.0, + 6243.0, + 6041.0, + 2430.0, + 3420.0, + 2535.0, + 2916.0, + 2644.0, + 2793.0, + 2623.0, + 3058.0, + 5370.0, + 3951.0, + 4237.0, + 4773.0, + 5206.0, + 5414.0, + 6590.0, + 4804.0, + 5194.0, + 5873.0, + 9633.0, + 13208.0, + 12857.0, + 9901.0, + 8705.0, + 9657.0, + 9582.0, + 7994.0, + 7530.0, + 41131.0, + 8906.0, + 8619.0, + 11573.0, + 10279.0, + 13559.0, + 12716.0, + 12375.0, + 13739.0, + 9244.0, + 7586.0, + 6965.0, + 6739.0, + 6458.0, + 7075.0, + 7295.0, + 6821.0, + 6111.0, + 6441.0, + 5714.0, + 6763.0, + 7676.0, + 11415.0, + 6509.0, + 6798.0, + 6099.0, + 6381.0, + 6984.0, + 5645.0, + 5702.0, + 4869.0, + 4939.0, + 5855.0, + 4973.0, + 4963.0, + 3038.0, + 3014.0, + 2991.0, + 2901.0, + 3013.0, + 2706.0, + 3685.0, + 3024.0, + 2579.0, + 2350.0, + 2407.0, + 2238.0, + 2024.0, + 1945.0, + 1734.0, + 1710.0, + 1576.0, + 1539.0, + 1636.0, + 1435.0, + 1279.0, + 1183.0, + 1218.0, + 1193.0, + 1077.0, + 787.0, + 894.0, + 942.0, + 921.0, + 1018.0, + 648.0, + 610.0, + 517.0, + 524.0, + 505.0, + 522.0, + 455.0, + 460.0, + 511.0, + 443.0, + 334.0, + 300.0, + 323.0, + 293.0, + 297.0, + 297.0, + 278.0, + 248.0, + 199.0, + 420.0, + 228.0, + 213.0, + 193.0, + 202.0, + 164.0, + 127.0, + 1457.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 164695.0, + 16777216.0, + 463750.0, + 269926.0, + 984411.0, + 2321856.0, + 5505207.0, + 15333710.0, + 16777216.0, + 16777216.0, + 7987839.0, + 6970926.0, + 5663499.0, + 7522175.0, + 16777216.0, + 5919363.0, + 4655171.0, + 5113476.0, + 7296047.0, + 11456336.0, + 16777216.0, + 16777216.0, + 12691501.0, + 13610250.0, + 14389669.0, + 15260072.0, + 16777216.0, + 15226327.0, + 16213852.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16777216.0, + 14114315.0, + 13576543.0, + 14028729.0, + 15199088.0, + 15790142.0, + 14593446.0, + 14045283.0, + 16353889.0, + 16777216.0, + 16777216.0, + 16777216.0, + 16208916.0, + 15544209.0, + 15467945.0, + 16777216.0, + 16212860.0, + 15692384.0, + 15513897.0, + 15109527.0, + 15053017.0, + 15869787.0, + 14686720.0, + 13383169.0, + 12664302.0, + 12459571.0, + 12607629.0, + 12845323.0, + 13233097.0, + 12971842.0, + 12339075.0, + 12001768.0, + 11365966.0, + 11292596.0, + 10889530.0, + 10321392.0, + 10099526.0, + 9891067.0, + 9629487.0, + 9376259.0, + 9084947.0, + 8763645.0, + 8433682.0, + 8135221.0, + 7780167.0, + 7391938.0, + 7000561.0, + 6721633.0, + 6483304.0, + 6359207.0, + 6267304.0, + 6015309.0, + 5777366.0, + 5482417.0, + 5233453.0, + 5004797.0, + 4799408.0, + 4581743.0, + 4356210.0, + 4144982.0, + 3940128.0, + 3751227.0, + 3594079.0, + 3436460.0, + 3244240.0, + 3050931.0, + 2891077.0, + 2742594.0, + 2606788.0, + 2481904.0, + 2366222.0, + 2250384.0, + 2133243.0, + 2019743.0, + 1907011.0, + 1800499.0, + 1700667.0, + 1598045.0, + 1513472.0, + 1428515.0, + 1345334.0, + 1261031.0, + 1190093.0, + 1122136.0, + 1061314.0, + 998739.0, + 932694.0, + 875015.0, + 812623.0, + 765150.0, + 718171.0, + 681071.0, + 639460.0, + 598854.0, + 561570.0, + 525691.0, + 491029.0, + 459811.0, + 431961.0, + 402432.0, + 5126107.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 15.0, + 288.0, + 512.0, + 256.0, + 367.0, + 403.0, + 1988.0, + 3239.0, + 492.0, + 467.0, + 506.0, + 356.0, + 443.0, + 615.0, + 540.0, + 569.0, + 1068.0, + 758.0, + 767.0, + 832.0, + 997.0, + 940.0, + 1011.0, + 1045.0, + 1163.0, + 1196.0, + 1165.0, + 2598.0, + 1371.0, + 1064.0, + 1287.0, + 1423.0, + 1411.0, + 990.0, + 981.0, + 3520.0, + 1099.0, + 1201.0, + 1187.0, + 1297.0, + 1621.0, + 1202.0, + 1340.0, + 1584.0, + 1644.0, + 902.0, + 830.0, + 804.0, + 760.0, + 992.0, + 1036.0, + 903.0, + 905.0, + 870.0, + 947.0, + 950.0, + 932.0, + 913.0, + 944.0, + 865.0, + 749.0, + 821.0, + 934.0, + 1118.0, + 1500.0, + 1075.0, + 1198.0, + 1010.0, + 683.0, + 654.0, + 696.0, + 680.0, + 691.0, + 710.0, + 542.0, + 561.0, + 629.0, + 689.0, + 644.0, + 632.0, + 547.0, + 583.0, + 537.0, + 374.0, + 357.0, + 441.0, + 447.0, + 377.0, + 311.0, + 330.0, + 266.0, + 202.0, + 211.0, + 157.0, + 147.0, + 156.0, + 144.0, + 175.0, + 209.0, + 137.0, + 141.0, + 137.0, + 150.0, + 117.0, + 109.0, + 111.0, + 99.0, + 78.0, + 67.0, + 75.0, + 67.0, + 41.0, + 39.0, + 50.0, + 38.0, + 43.0, + 29.0, + 14.0, + 27.0, + 20.0, + 24.0, + 16.0, + 16.0, + 17.0, + 19.0, + 199.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 12.0, + 6316207.0, + 17854.0, + 0.0, + 39.0, + 4157.0, + 12941.0, + 20960.0, + 58625.0, + 143803.0, + 331407.0, + 16777216.0, + 4928937.0, + 498527.0, + 877091.0, + 2161806.0, + 4666206.0, + 10031375.0, + 7934825.0, + 4501448.0, + 2964288.0, + 2704275.0, + 3821821.0, + 5416151.0, + 11036569.0, + 3464111.0, + 2358763.0, + 2793141.0, + 3718034.0, + 6303283.0, + 6295909.0, + 6123465.0, + 5415848.0, + 5191240.0, + 5186973.0, + 5287839.0, + 10805260.0, + 5983254.0, + 5662125.0, + 5674549.0, + 5515592.0, + 5030018.0, + 5302668.0, + 6603380.0, + 4866633.0, + 4999856.0, + 5274731.0, + 5282280.0, + 5303203.0, + 4479897.0, + 4720169.0, + 5703319.0, + 5920716.0, + 4679480.0, + 4367448.0, + 4335037.0, + 3927628.0, + 3906314.0, + 4113206.0, + 4063221.0, + 3638004.0, + 3530701.0, + 3433395.0, + 3436778.0, + 3583493.0, + 3371855.0, + 2874752.0, + 2585772.0, + 2527891.0, + 2497016.0, + 2474751.0, + 2658557.0, + 2570384.0, + 2386182.0, + 2238568.0, + 2054482.0, + 1966518.0, + 1861307.0, + 1708633.0, + 1623968.0, + 1558288.0, + 1484360.0, + 1426186.0, + 1332893.0, + 1280981.0, + 1235780.0, + 1181167.0, + 1087899.0, + 995454.0, + 923796.0, + 865272.0, + 805294.0, + 790517.0, + 769846.0, + 716116.0, + 665866.0, + 637731.0, + 589802.0, + 551343.0, + 518888.0, + 476951.0, + 443811.0, + 411877.0, + 376820.0, + 359402.0, + 340992.0, + 324985.0, + 304392.0, + 273253.0, + 255472.0, + 236365.0, + 220843.0, + 208224.0, + 196810.0, + 184426.0, + 172017.0, + 161756.0, + 147545.0, + 136394.0, + 127072.0, + 118518.0, + 108305.0, + 101154.0, + 93188.0, + 85646.0, + 79458.0, + 74471.0, + 69785.0, + 64638.0, + 59235.0, + 54803.0, + 50793.0, + 45897.0, + 43032.0, + 40936.0, + 39957.0, + 36747.0, + 32805.0, + 29904.0, + 26777.0, + 299233.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 917743.0, + 30831.0, + 33.0, + 0.0, + 326.0, + 7476.0, + 35278.0, + 87306.0, + 175277.0, + 346112.0, + 2602607.0, + 11355709.0, + 1538783.0, + 3124228.0, + 4580896.0, + 5399762.0, + 7495010.0, + 5937816.0, + 6465830.0, + 2998078.0, + 2575869.0, + 2661114.0, + 5918245.0, + 4443072.0, + 5051868.0, + 2723929.0, + 3379163.0, + 3925961.0, + 4556521.0, + 5878930.0, + 6142001.0, + 4985084.0, + 5161494.0, + 4978967.0, + 4972712.0, + 7335126.0, + 8481313.0, + 5983247.0, + 6298232.0, + 5882001.0, + 5680965.0, + 5097013.0, + 4843327.0, + 5700897.0, + 4323869.0, + 4787907.0, + 4973023.0, + 5237023.0, + 5206890.0, + 4733937.0, + 5999164.0, + 6079368.0, + 5890081.0, + 5690517.0, + 5240201.0, + 4391380.0, + 4319901.0, + 4547537.0, + 4768660.0, + 4507687.0, + 4460715.0, + 4295132.0, + 4148067.0, + 4125078.0, + 4230935.0, + 3645690.0, + 3356538.0, + 3217726.0, + 3179740.0, + 3211669.0, + 3204661.0, + 3310373.0, + 3252372.0, + 3160206.0, + 2961953.0, + 2864973.0, + 2698092.0, + 2550186.0, + 2420557.0, + 2366560.0, + 2287596.0, + 2254506.0, + 2148306.0, + 2031856.0, + 1934240.0, + 1865455.0, + 1759138.0, + 1627941.0, + 1548125.0, + 1444160.0, + 1376935.0, + 1331998.0, + 1324054.0, + 1284597.0, + 1202913.0, + 1141741.0, + 1076341.0, + 1018165.0, + 965883.0, + 914972.0, + 858813.0, + 800859.0, + 742718.0, + 698833.0, + 668222.0, + 645629.0, + 615383.0, + 569813.0, + 529578.0, + 498142.0, + 468674.0, + 446318.0, + 423099.0, + 401830.0, + 381871.0, + 359133.0, + 338351.0, + 318575.0, + 298227.0, + 275594.0, + 263288.0, + 248693.0, + 232365.0, + 217334.0, + 203430.0, + 189018.0, + 177903.0, + 169473.0, + 159401.0, + 145345.0, + 135923.0, + 127928.0, + 119769.0, + 112945.0, + 106856.0, + 99795.0, + 92578.0, + 87111.0, + 80382.0, + 73118.0, + 905858.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 704.0, + 5789.0, + 5148.0, + 7422.0, + 12400.0, + 2775.0, + 11415.0, + 14753.0, + 13410.0, + 12594.0, + 8050.0, + 5306.0, + 6027.0, + 6427.0, + 12065.0, + 11286.0, + 5670.0, + 6064.0, + 4913.0, + 5346.0, + 5859.0, + 8185.0, + 9001.0, + 6816.0, + 7923.0, + 5974.0, + 5273.0, + 5807.0, + 11785.0, + 14515.0, + 8772.0, + 7270.0, + 6826.0, + 5779.0, + 5547.0, + 5194.0, + 11728.0, + 4635.0, + 5778.0, + 7279.0, + 5697.0, + 5256.0, + 6501.0, + 5874.0, + 4093.0, + 3745.0, + 3337.0, + 3781.0, + 3301.0, + 3147.0, + 3478.0, + 3247.0, + 3482.0, + 3067.0, + 2261.0, + 2270.0, + 2050.0, + 1920.0, + 2410.0, + 1595.0, + 1206.0, + 1167.0, + 1330.0, + 1643.0, + 1958.0, + 1123.0, + 1174.0, + 825.0, + 825.0, + 1060.0, + 869.0, + 794.0, + 578.0, + 658.0, + 519.0, + 466.0, + 628.0, + 523.0, + 459.0, + 393.0, + 362.0, + 226.0, + 253.0, + 234.0, + 229.0, + 237.0, + 259.0, + 266.0, + 235.0, + 150.0, + 161.0, + 140.0, + 146.0, + 211.0, + 244.0, + 178.0, + 153.0, + 144.0, + 147.0, + 133.0, + 121.0, + 71.0, + 83.0, + 71.0, + 70.0, + 54.0, + 47.0, + 51.0, + 48.0, + 49.0, + 36.0, + 32.0, + 28.0, + 31.0, + 20.0, + 34.0, + 25.0, + 21.0, + 29.0, + 20.0, + 16.0, + 28.0, + 26.0, + 39.0, + 23.0, + 19.0, + 17.0, + 12.0, + 16.0, + 95.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2228.0, + 0.0, + 1.0, + 0.0, + 0.0, + 888.0, + 1416.0, + 1680.0, + 12580.0, + 384818.0, + 1929285.0, + 1374626.0, + 522384.0, + 1256168.0, + 1028657.0, + 2136552.0, + 943007.0, + 982475.0, + 522778.0, + 458580.0, + 478335.0, + 598666.0, + 1558618.0, + 970886.0, + 714126.0, + 428327.0, + 532814.0, + 590839.0, + 819780.0, + 1025616.0, + 1060235.0, + 1127459.0, + 1046900.0, + 1169909.0, + 1255099.0, + 2284672.0, + 1630015.0, + 1096110.0, + 964006.0, + 939297.0, + 933921.0, + 923646.0, + 1065615.0, + 1145897.0, + 918150.0, + 913702.0, + 976673.0, + 862772.0, + 890838.0, + 885091.0, + 934887.0, + 1079815.0, + 1134025.0, + 808744.0, + 845238.0, + 767957.0, + 723710.0, + 778328.0, + 817912.0, + 677405.0, + 650508.0, + 627649.0, + 594963.0, + 644296.0, + 673395.0, + 592174.0, + 500358.0, + 491830.0, + 510411.0, + 520866.0, + 553378.0, + 520423.0, + 464197.0, + 438474.0, + 428083.0, + 392511.0, + 392329.0, + 370101.0, + 310393.0, + 294301.0, + 289682.0, + 267812.0, + 257736.0, + 249314.0, + 249364.0, + 235084.0, + 230695.0, + 206736.0, + 193008.0, + 178940.0, + 173517.0, + 160823.0, + 157829.0, + 153239.0, + 142446.0, + 127343.0, + 119132.0, + 109335.0, + 103633.0, + 92882.0, + 86984.0, + 81869.0, + 75909.0, + 68999.0, + 67772.0, + 63766.0, + 62862.0, + 55704.0, + 49846.0, + 46685.0, + 44334.0, + 41536.0, + 37663.0, + 34812.0, + 33391.0, + 32326.0, + 29655.0, + 26760.0, + 24807.0, + 22312.0, + 20818.0, + 19733.0, + 18342.0, + 15925.0, + 15494.0, + 13737.0, + 13218.0, + 12165.0, + 10805.0, + 10425.0, + 8936.0, + 8298.0, + 8064.0, + 7271.0, + 7470.0, + 7376.0, + 67681.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 8.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 6.0, + 4.0, + 9.0, + 14.0, + 13.0, + 7.0, + 8.0, + 4.0, + 6.0, + 22.0, + 11.0, + 8.0, + 9.0, + 15.0, + 21.0, + 18.0, + 32.0, + 40.0, + 310.0, + 1320.0, + 150.0, + 34.0, + 38.0, + 56.0, + 32.0, + 45.0, + 46.0, + 26.0, + 33.0, + 23.0, + 12.0, + 21.0, + 16.0, + 37.0, + 18.0, + 12.0, + 29.0, + 8.0, + 3.0, + 2.0, + 5.0, + 5.0, + 5.0, + 4.0, + 2.0, + 14.0, + 1.0, + 1.0, + 8.0, + 2.0, + 5.0, + 31.0, + 1.0, + 1.0, + 5.0, + 2.0, + 5.0, + 6.0, + 5.0, + 4.0, + 1.0, + 0.0, + 2.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 16.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 6.0, + 0.0, + 3.0, + 6.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 8.0, + 16.0, + 309.0, + 1077.0, + 114.0, + 337.0, + 275.0, + 151.0, + 186.0, + 203.0, + 165.0, + 65.0, + 87.0, + 102.0, + 177.0, + 79.0, + 77.0, + 49.0, + 52.0, + 61.0, + 62.0, + 67.0, + 94.0, + 134.0, + 306.0, + 1138.0, + 157.0, + 154.0, + 215.0, + 210.0, + 163.0, + 189.0, + 192.0, + 962.0, + 333.0, + 293.0, + 404.0, + 449.0, + 245.0, + 173.0, + 162.0, + 532.0, + 315.0, + 235.0, + 245.0, + 184.0, + 187.0, + 182.0, + 126.0, + 131.0, + 137.0, + 107.0, + 127.0, + 98.0, + 117.0, + 161.0, + 97.0, + 99.0, + 75.0, + 85.0, + 60.0, + 43.0, + 28.0, + 30.0, + 23.0, + 45.0, + 34.0, + 61.0, + 41.0, + 47.0, + 62.0, + 39.0, + 37.0, + 28.0, + 53.0, + 26.0, + 20.0, + 24.0, + 27.0, + 34.0, + 29.0, + 13.0, + 18.0, + 29.0, + 15.0, + 18.0, + 17.0, + 10.0, + 11.0, + 16.0, + 11.0, + 20.0, + 21.0, + 21.0, + 19.0, + 15.0, + 14.0, + 5.0, + 5.0, + 5.0, + 3.0, + 11.0, + 12.0, + 4.0, + 2.0, + 3.0, + 5.0, + 4.0, + 2.0, + 3.0, + 0.0, + 1.0, + 2.0, + 2.0, + 2.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 8.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 16.0, + 14.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 12.0, + 392.0, + 385.0, + 789.0, + 697.0, + 324.0, + 297.0, + 260.0, + 222.0, + 242.0, + 373.0, + 201.0, + 167.0, + 197.0, + 216.0, + 295.0, + 198.0, + 188.0, + 159.0, + 195.0, + 352.0, + 219.0, + 284.0, + 250.0, + 205.0, + 470.0, + 1067.0, + 544.0, + 182.0, + 363.0, + 508.0, + 526.0, + 456.0, + 541.0, + 505.0, + 370.0, + 491.0, + 1177.0, + 270.0, + 271.0, + 298.0, + 424.0, + 671.0, + 382.0, + 320.0, + 283.0, + 266.0, + 283.0, + 250.0, + 231.0, + 208.0, + 188.0, + 184.0, + 196.0, + 205.0, + 256.0, + 271.0, + 261.0, + 210.0, + 335.0, + 255.0, + 182.0, + 132.0, + 189.0, + 129.0, + 100.0, + 90.0, + 162.0, + 122.0, + 56.0, + 30.0, + 33.0, + 40.0, + 35.0, + 53.0, + 56.0, + 95.0, + 91.0, + 138.0, + 85.0, + 91.0, + 54.0, + 77.0, + 41.0, + 37.0, + 43.0, + 37.0, + 30.0, + 20.0, + 20.0, + 13.0, + 22.0, + 30.0, + 23.0, + 20.0, + 18.0, + 15.0, + 18.0, + 14.0, + 19.0, + 22.0, + 15.0, + 11.0, + 10.0, + 8.0, + 15.0, + 18.0, + 9.0, + 8.0, + 9.0, + 6.0, + 12.0, + 3.0, + 3.0, + 5.0, + 3.0, + 3.0, + 1.0, + 1.0, + 3.0, + 1.0, + 7.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4.0, + 15.0, + 106.0, + 55.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 5.0, + 4.0, + 2.0, + 2.0, + 2.0, + 9.0, + 12.0, + 5.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 3.0, + 31.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 0.0, + 0.0, + 1.0, + 1.0, + 4.0, + 0.0, + 3.0, + 4.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 4.0, + 6.0, + 0.0, + 1.0, + 1.0, + 2.0, + 4.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1922.0, + 216168.0, + 1604958.0, + 25745.0, + 106.0, + 0.0, + 0.0, + 0.0, + 0.0, + 33058.0, + 750099.0, + 1146697.0, + 1961629.0, + 269623.0, + 109654.0, + 298688.0, + 532408.0, + 809051.0, + 486098.0, + 331575.0, + 233794.0, + 199097.0, + 217874.0, + 493979.0, + 785383.0, + 559174.0, + 245271.0, + 220183.0, + 302222.0, + 500256.0, + 438328.0, + 445826.0, + 371231.0, + 406495.0, + 459239.0, + 400443.0, + 432589.0, + 366745.0, + 276633.0, + 275773.0, + 215944.0, + 199879.0, + 199533.0, + 196443.0, + 309470.0, + 232036.0, + 201354.0, + 203704.0, + 185507.0, + 180180.0, + 159822.0, + 191030.0, + 251789.0, + 274911.0, + 187484.0, + 150387.0, + 123167.0, + 108748.0, + 122253.0, + 114782.0, + 98092.0, + 89659.0, + 83170.0, + 78074.0, + 77266.0, + 90588.0, + 83349.0, + 67575.0, + 64501.0, + 59660.0, + 53604.0, + 51039.0, + 52713.0, + 55402.0, + 50298.0, + 44493.0, + 39477.0, + 35685.0, + 34874.0, + 27203.0, + 24126.0, + 27939.0, + 20812.0, + 19135.0, + 18471.0, + 19458.0, + 19142.0, + 18653.0, + 16917.0, + 14422.0, + 14283.0, + 11646.0, + 10826.0, + 10417.0, + 10385.0, + 10127.0, + 9356.0, + 8309.0, + 7966.0, + 6963.0, + 6097.0, + 5306.0, + 5240.0, + 4289.0, + 3724.0, + 3784.0, + 3418.0, + 3577.0, + 3521.0, + 3146.0, + 2781.0, + 2349.0, + 2117.0, + 1997.0, + 2011.0, + 2222.0, + 2063.0, + 1704.0, + 1632.0, + 1319.0, + 1336.0, + 1315.0, + 1382.0, + 1089.0, + 833.0, + 744.0, + 747.0, + 754.0, + 598.0, + 609.0, + 659.0, + 636.0, + 433.0, + 427.0, + 363.0, + 316.0, + 321.0, + 293.0, + 3112.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1566.0, + 660561.0, + 284576.0, + 30077.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 181862.0, + 6270988.0, + 488640.0, + 761876.0, + 1438275.0, + 1000768.0, + 994492.0, + 1244000.0, + 813544.0, + 566745.0, + 464519.0, + 461372.0, + 481487.0, + 858337.0, + 964252.0, + 577708.0, + 423111.0, + 441698.0, + 576619.0, + 1059583.0, + 919272.0, + 860701.0, + 664399.0, + 750938.0, + 937767.0, + 999002.0, + 1006642.0, + 789114.0, + 690390.0, + 688343.0, + 651159.0, + 564866.0, + 537825.0, + 561048.0, + 884321.0, + 517418.0, + 540475.0, + 564447.0, + 597654.0, + 584791.0, + 551467.0, + 760879.0, + 952148.0, + 712293.0, + 787560.0, + 497565.0, + 380783.0, + 379939.0, + 409608.0, + 413784.0, + 409035.0, + 369970.0, + 343029.0, + 329323.0, + 336254.0, + 411397.0, + 336341.0, + 256749.0, + 228833.0, + 226324.0, + 224281.0, + 227237.0, + 241797.0, + 246814.0, + 216282.0, + 201295.0, + 184372.0, + 180717.0, + 156659.0, + 133568.0, + 130108.0, + 117117.0, + 110728.0, + 100062.0, + 96883.0, + 100361.0, + 96992.0, + 91327.0, + 77051.0, + 73227.0, + 65242.0, + 58901.0, + 54987.0, + 55000.0, + 52492.0, + 49843.0, + 47643.0, + 44074.0, + 41620.0, + 39022.0, + 35083.0, + 31336.0, + 28711.0, + 26436.0, + 24139.0, + 22148.0, + 20794.0, + 21212.0, + 20694.0, + 18554.0, + 17637.0, + 15612.0, + 14528.0, + 14137.0, + 13071.0, + 12424.0, + 11535.0, + 9919.0, + 8974.0, + 7990.0, + 7277.0, + 6768.0, + 7179.0, + 6338.0, + 6049.0, + 5200.0, + 4462.0, + 4277.0, + 4681.0, + 4311.0, + 3967.0, + 3710.0, + 3223.0, + 2794.0, + 2593.0, + 2541.0, + 2553.0, + 2106.0, + 20259.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 83.0, + 9306.0, + 1738.0, + 5.0, + 0.0, + 0.0, + 0.0, + 4.0, + 25.0, + 79.0, + 669.0, + 1343.0, + 1937.0, + 1287.0, + 699.0, + 517.0, + 535.0, + 635.0, + 388.0, + 376.0, + 703.0, + 631.0, + 603.0, + 846.0, + 906.0, + 609.0, + 347.0, + 392.0, + 361.0, + 427.0, + 488.0, + 397.0, + 339.0, + 317.0, + 279.0, + 335.0, + 465.0, + 481.0, + 331.0, + 299.0, + 290.0, + 278.0, + 205.0, + 178.0, + 695.0, + 245.0, + 202.0, + 281.0, + 182.0, + 238.0, + 239.0, + 271.0, + 216.0, + 128.0, + 102.0, + 120.0, + 61.0, + 69.0, + 153.0, + 79.0, + 59.0, + 89.0, + 74.0, + 71.0, + 43.0, + 30.0, + 62.0, + 42.0, + 82.0, + 71.0, + 83.0, + 56.0, + 63.0, + 48.0, + 33.0, + 56.0, + 41.0, + 27.0, + 41.0, + 50.0, + 39.0, + 41.0, + 40.0, + 40.0, + 24.0, + 6.0, + 24.0, + 17.0, + 9.0, + 17.0, + 14.0, + 14.0, + 30.0, + 13.0, + 10.0, + 10.0, + 11.0, + 10.0, + 10.0, + 7.0, + 10.0, + 13.0, + 22.0, + 4.0, + 8.0, + 6.0, + 9.0, + 9.0, + 1.0, + 5.0, + 8.0, + 3.0, + 4.0, + 0.0, + 1.0, + 4.0, + 3.0, + 0.0, + 7.0, + 6.0, + 6.0, + 1.0, + 3.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 7424.0, + 960134.0, + 94427.0, + 1088.0, + 4719.0, + 334.0, + 120.0, + 119.0, + 359.0, + 17624.0, + 696845.0, + 1100211.0, + 893269.0, + 63645.0, + 131994.0, + 196342.0, + 154238.0, + 168727.0, + 202975.0, + 142900.0, + 112231.0, + 123012.0, + 149015.0, + 544941.0, + 276946.0, + 147268.0, + 84145.0, + 80658.0, + 149953.0, + 147428.0, + 126780.0, + 94910.0, + 92247.0, + 133164.0, + 148248.0, + 211423.0, + 188447.0, + 116783.0, + 112331.0, + 102551.0, + 74823.0, + 79117.0, + 83822.0, + 123988.0, + 219718.0, + 86419.0, + 92131.0, + 80150.0, + 75908.0, + 68217.0, + 65163.0, + 79496.0, + 97426.0, + 72559.0, + 54875.0, + 46672.0, + 43224.0, + 45342.0, + 43648.0, + 39314.0, + 34751.0, + 35560.0, + 39882.0, + 32634.0, + 42350.0, + 38678.0, + 34601.0, + 26034.0, + 24491.0, + 27211.0, + 26353.0, + 23147.0, + 19684.0, + 18821.0, + 16701.0, + 15919.0, + 14348.0, + 15342.0, + 12621.0, + 9643.0, + 9240.0, + 8022.0, + 7369.0, + 7345.0, + 7290.0, + 8112.0, + 8157.0, + 6769.0, + 6396.0, + 5990.0, + 5172.0, + 4715.0, + 4452.0, + 4493.0, + 3914.0, + 3441.0, + 2706.0, + 2614.0, + 2612.0, + 1970.0, + 1838.0, + 1684.0, + 1458.0, + 1277.0, + 1458.0, + 1345.0, + 1332.0, + 1611.0, + 1411.0, + 1047.0, + 1031.0, + 1060.0, + 937.0, + 1419.0, + 1127.0, + 529.0, + 498.0, + 433.0, + 401.0, + 384.0, + 349.0, + 312.0, + 311.0, + 278.0, + 256.0, + 322.0, + 183.0, + 155.0, + 161.0, + 144.0, + 170.0, + 271.0, + 259.0, + 168.0, + 787.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 124.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 354569.0, + 1344874.0, + 164270.0, + 1185318.0, + 254807.0, + 244620.0, + 252804.0, + 455442.0, + 264564.0, + 214627.0, + 187827.0, + 197038.0, + 225751.0, + 297060.0, + 652823.0, + 236188.0, + 181138.0, + 208740.0, + 244666.0, + 289169.0, + 355849.0, + 345073.0, + 374054.0, + 468176.0, + 775547.0, + 465460.0, + 622412.0, + 565619.0, + 484713.0, + 436315.0, + 383819.0, + 291328.0, + 268606.0, + 290184.0, + 350395.0, + 271564.0, + 293188.0, + 318337.0, + 334672.0, + 334602.0, + 355083.0, + 465026.0, + 561353.0, + 408718.0, + 508457.0, + 316003.0, + 289270.0, + 304442.0, + 323984.0, + 319935.0, + 333104.0, + 314223.0, + 287982.0, + 268108.0, + 274627.0, + 288774.0, + 229854.0, + 228977.0, + 173093.0, + 179858.0, + 171885.0, + 179904.0, + 189161.0, + 200962.0, + 167173.0, + 162182.0, + 163232.0, + 134415.0, + 121511.0, + 111492.0, + 107598.0, + 103944.0, + 102085.0, + 92089.0, + 90565.0, + 89420.0, + 88071.0, + 83451.0, + 69152.0, + 65623.0, + 60437.0, + 57936.0, + 55913.0, + 55389.0, + 53166.0, + 51105.0, + 47186.0, + 42574.0, + 41481.0, + 38218.0, + 37100.0, + 33161.0, + 31310.0, + 29111.0, + 28476.0, + 27226.0, + 26782.0, + 25137.0, + 24399.0, + 22083.0, + 20997.0, + 19292.0, + 18039.0, + 18432.0, + 16861.0, + 14992.0, + 14448.0, + 13114.0, + 12020.0, + 10762.0, + 10068.0, + 9378.0, + 9207.0, + 8017.0, + 7697.0, + 7055.0, + 6116.0, + 6164.0, + 6161.0, + 5452.0, + 5134.0, + 5080.0, + 5199.0, + 4814.0, + 4461.0, + 4021.0, + 3835.0, + 3601.0, + 39515.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 17791.0, + 11302.0, + 15025.0, + 132.0, + 19.0, + 0.0, + 0.0, + 9.0, + 1.0, + 33.0, + 187.0, + 434.0, + 1653.0, + 1213.0, + 704.0, + 765.0, + 1221.0, + 1071.0, + 1024.0, + 815.0, + 852.0, + 864.0, + 867.0, + 943.0, + 855.0, + 2219.0, + 1773.0, + 1097.0, + 990.0, + 669.0, + 1085.0, + 518.0, + 551.0, + 417.0, + 459.0, + 597.0, + 669.0, + 875.0, + 1038.0, + 783.0, + 537.0, + 515.0, + 558.0, + 489.0, + 384.0, + 421.0, + 343.0, + 375.0, + 427.0, + 561.0, + 503.0, + 601.0, + 544.0, + 415.0, + 274.0, + 247.0, + 255.0, + 393.0, + 306.0, + 380.0, + 330.0, + 336.0, + 275.0, + 215.0, + 144.0, + 117.0, + 206.0, + 217.0, + 179.0, + 124.0, + 114.0, + 154.0, + 180.0, + 209.0, + 239.0, + 114.0, + 81.0, + 78.0, + 88.0, + 36.0, + 27.0, + 41.0, + 46.0, + 55.0, + 40.0, + 50.0, + 67.0, + 49.0, + 33.0, + 71.0, + 42.0, + 23.0, + 16.0, + 21.0, + 18.0, + 25.0, + 19.0, + 46.0, + 14.0, + 15.0, + 9.0, + 10.0, + 32.0, + 21.0, + 19.0, + 9.0, + 9.0, + 23.0, + 20.0, + 16.0, + 20.0, + 11.0, + 16.0, + 16.0, + 7.0, + 25.0, + 27.0, + 21.0, + 3.0, + 7.0, + 13.0, + 10.0, + 16.0, + 6.0, + 11.0, + 13.0, + 15.0, + 12.0, + 16.0, + 11.0, + 7.0, + 2.0, + 0.0, + 0.0, + 0.0, + 60.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2304112.0, + 2761.0, + 10802.0, + 68.0, + 51.0, + 36.0, + 24.0, + 16.0, + 3847.0, + 11144.0, + 16116.0, + 80777.0, + 127408.0, + 33529.0, + 82533.0, + 108971.0, + 100935.0, + 102063.0, + 106198.0, + 114017.0, + 115269.0, + 162408.0, + 143522.0, + 137537.0, + 271278.0, + 258060.0, + 96370.0, + 126002.0, + 95350.0, + 105739.0, + 139780.0, + 193465.0, + 133572.0, + 127582.0, + 224828.0, + 346499.0, + 144503.0, + 154290.0, + 154812.0, + 132259.0, + 105570.0, + 102398.0, + 109590.0, + 103541.0, + 154835.0, + 141556.0, + 103281.0, + 112506.0, + 130346.0, + 126545.0, + 113562.0, + 114344.0, + 128385.0, + 140957.0, + 138660.0, + 137983.0, + 79007.0, + 74675.0, + 88699.0, + 95887.0, + 85461.0, + 76979.0, + 66752.0, + 57612.0, + 58258.0, + 75301.0, + 75774.0, + 66577.0, + 49381.0, + 47429.0, + 48003.0, + 49033.0, + 49257.0, + 44605.0, + 44110.0, + 38296.0, + 38107.0, + 35604.0, + 32188.0, + 27288.0, + 25169.0, + 24157.0, + 23599.0, + 20346.0, + 19072.0, + 19490.0, + 19241.0, + 17388.0, + 16545.0, + 15150.0, + 15338.0, + 12503.0, + 13187.0, + 12792.0, + 11292.0, + 10298.0, + 9441.0, + 10117.0, + 7942.0, + 7441.0, + 7251.0, + 6403.0, + 5885.0, + 5538.0, + 4491.0, + 4052.0, + 3920.0, + 4646.0, + 3789.0, + 3273.0, + 3082.0, + 2596.0, + 2565.0, + 2603.0, + 2451.0, + 2211.0, + 1836.0, + 1962.0, + 1583.0, + 1511.0, + 1384.0, + 1114.0, + 1033.0, + 1068.0, + 1080.0, + 1100.0, + 1087.0, + 827.0, + 716.0, + 806.0, + 979.0, + 631.0, + 669.0, + 628.0, + 734.0, + 4656.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 17.0, + 79.0, + 111.0, + 245.0, + 51.0, + 37.0, + 61.0, + 30.0, + 51.0, + 165.0, + 121.0, + 99.0, + 101.0, + 48.0, + 35.0, + 35.0, + 10.0, + 14.0, + 13.0, + 40.0, + 6.0, + 4.0, + 2.0, + 8.0, + 0.0, + 3.0, + 2.0, + 4.0, + 7.0, + 9.0, + 37.0, + 45.0, + 23.0, + 15.0, + 21.0, + 25.0, + 6.0, + 8.0, + 11.0, + 21.0, + 30.0, + 19.0, + 14.0, + 13.0, + 14.0, + 11.0, + 1.0, + 6.0, + 12.0, + 15.0, + 12.0, + 11.0, + 8.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 2.0, + 0.0, + 2.0, + 0.0, + 4.0, + 2.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 4.0, + 8.0, + 5.0, + 6.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1968.0, + 23.0, + 565.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 50.0, + 72.0, + 102.0, + 377.0, + 270.0, + 112.0, + 95.0, + 42.0, + 23.0, + 4.0, + 5.0, + 22.0, + 24.0, + 24.0, + 48.0, + 62.0, + 100.0, + 58.0, + 57.0, + 35.0, + 24.0, + 15.0, + 26.0, + 21.0, + 16.0, + 25.0, + 24.0, + 32.0, + 19.0, + 18.0, + 21.0, + 12.0, + 5.0, + 8.0, + 11.0, + 22.0, + 19.0, + 11.0, + 15.0, + 5.0, + 3.0, + 17.0, + 65.0, + 93.0, + 199.0, + 38.0, + 5.0, + 2.0, + 10.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 3.0, + 2.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 3.0, + 3.0, + 0.0, + 2.0, + 0.0, + 2.0, + 1.0, + 5.0, + 1.0, + 2.0, + 4.0, + 2.0, + 0.0, + 2.0, + 0.0, + 1.0, + 3.0, + 1.0, + 1.0, + 0.0, + 1.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1012.0, + 112.0, + 0.0, + 0.0, + 1.0, + 139.0, + 234.0, + 488.0, + 2851.0, + 34865.0, + 104431.0, + 9185.0, + 6524.0, + 4086.0, + 4252.0, + 5162.0, + 4103.0, + 4436.0, + 4395.0, + 4675.0, + 6798.0, + 9798.0, + 16912.0, + 12277.0, + 7100.0, + 6881.0, + 9109.0, + 15514.0, + 15464.0, + 9538.0, + 9986.0, + 11553.0, + 18114.0, + 31011.0, + 19086.0, + 19285.0, + 10256.0, + 9973.0, + 8127.0, + 10413.0, + 7471.0, + 18072.0, + 14417.0, + 14946.0, + 7456.0, + 8112.0, + 6543.0, + 6044.0, + 5985.0, + 6828.0, + 7118.0, + 6810.0, + 7071.0, + 4446.0, + 4200.0, + 4817.0, + 5881.0, + 5053.0, + 5581.0, + 4082.0, + 4088.0, + 3840.0, + 3111.0, + 3979.0, + 4809.0, + 3535.0, + 2953.0, + 2059.0, + 3051.0, + 3224.0, + 3321.0, + 1408.0, + 1424.0, + 1260.0, + 1969.0, + 1298.0, + 2745.0, + 1113.0, + 994.0, + 968.0, + 888.0, + 879.0, + 894.0, + 1134.0, + 692.0, + 980.0, + 735.0, + 667.0, + 514.0, + 400.0, + 372.0, + 329.0, + 499.0, + 366.0, + 250.0, + 347.0, + 364.0, + 274.0, + 156.0, + 123.0, + 129.0, + 78.0, + 160.0, + 113.0, + 138.0, + 166.0, + 109.0, + 88.0, + 71.0, + 65.0, + 108.0, + 54.0, + 112.0, + 54.0, + 61.0, + 67.0, + 48.0, + 45.0, + 35.0, + 27.0, + 33.0, + 29.0, + 49.0, + 248.0, + 18.0, + 20.0, + 15.0, + 8.0, + 22.0, + 172.0 + ] + ] + ], + "radius_of_gyration_histogram": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 3.0, + 12.0, + 23.0, + 87.0, + 164.0, + 253.0, + 477.0, + 714.0, + 1319.0, + 1732.0, + 2386.0, + 3418.0, + 5730.0, + 10060.0, + 18674.0, + 34541.0, + 60106.0, + 97930.0, + 150468.0, + 210344.0, + 278671.0, + 346130.0, + 399669.0, + 436319.0, + 448469.0, + 441026.0, + 413218.0, + 375242.0, + 327566.0, + 282426.0, + 239205.0, + 200625.0, + 167322.0, + 137928.0, + 112325.0, + 92813.0, + 75387.0, + 60475.0, + 47765.0, + 37769.0, + 29783.0, + 23720.0, + 18752.0, + 14492.0, + 10712.0, + 8238.0, + 6185.0, + 4479.0, + 3147.0, + 2288.0, + 1599.0, + 1212.0, + 747.0, + 503.0, + 360.0, + 197.0, + 134.0, + 104.0, + 79.0, + 38.0, + 37.0, + 18.0, + 12.0, + 16.0, + 8.0 + ] +} diff --git a/geom/explicit_h/distributions.pt b/geom/explicit_h/distributions.pt new file mode 100644 index 0000000000000000000000000000000000000000..3d8135f29eef5dad8c533d8dc93c560c3c958b99 --- /dev/null +++ b/geom/explicit_h/distributions.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40a31704ab58d0349daff35083d43dcf4771ef368fd9c8a8503c94fe844016c5 +size 63581 diff --git a/geom/explicit_h/edge_tokens.txt b/geom/explicit_h/edge_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..59a3b678b20991c4db5881d2f6b9f0f2fc214357 --- /dev/null +++ b/geom/explicit_h/edge_tokens.txt @@ -0,0 +1,4 @@ + +1 +2 +3 diff --git a/geom/explicit_h/epoch_050.ckpt b/geom/explicit_h/epoch_050.ckpt new file mode 100644 index 0000000000000000000000000000000000000000..1143331b031c2bf08c45627807c70259f4c75ce0 --- /dev/null +++ b/geom/explicit_h/epoch_050.ckpt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:016c4a5bbb4f5ef36d1d62dff772ecf8d384e8ab3e2c94ebeb4c4357183f690a +size 509181385 diff --git a/geom/explicit_h/manifest.json b/geom/explicit_h/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..dd983fc59bf72b4a4e5fa76b49da8261af21268e --- /dev/null +++ b/geom/explicit_h/manifest.json @@ -0,0 +1,602 @@ +{ + "README.md": { + "bytes": 701, + "sha256": "9947149d5b311da2f381b57f135811eb1eade2dba84cdf452ae69f5e11ba1016" + }, + "atom_tokens.txt": { + "bytes": 22, + "sha256": "c9b712563ad2509593aa48fddbb5145aa42a70c4791255b8eea2984f549cae6a" + }, + "charge_tokens.txt": { + "bytes": 12, + "sha256": "e2533aa090751f1006a74d165aa2d4a7ac23dbf73a65eb624c2a81216cc7dff5" + }, + "config.yaml": { + "bytes": 10648, + "sha256": "e448c580096659af3b7e7e718e395565aa397b7b6666bc148576c7a455940a66" + }, + "distributions.json": { + "bytes": 220286, + "sha256": "17bcf85c09486e5070aa8b3be4f4411fa01e6b3cf1b39c5488a5f46149f52e0f" + }, + "distributions.pt": { + "bytes": 63581, + "sha256": "40a31704ab58d0349daff35083d43dcf4771ef368fd9c8a8503c94fe844016c5" + }, + "edge_tokens.txt": { + "bytes": 16, + "sha256": "42b635ef938d2ec5a601044e4b01f3f84ed121f1bfaac984d86f180a8ccba3fd" + }, + "epoch_050.ckpt": { + "bytes": 509181385, + "sha256": "016c4a5bbb4f5ef36d1d62dff772ecf8d384e8ab3e2c94ebeb4c4357183f690a" + }, + "metadata.json": { + "bytes": 1094, + "sha256": "b23b8944369765d12b2cbae0f5d48614225e44cb59deff8bb0e49783318ddb31" + }, + "runtime/configs/callbacks/callbacks.yaml": { + "bytes": 699, + "sha256": "dce0adebf5956dce687d64a074238f9c12866373bfe21df6658d967d6bfd1f90" + }, + "runtime/configs/data/chembl3d/chembl3d.yaml": { + "bytes": 278, + "sha256": "a96974d90353a850c4a1d0bac27de5f1fc8724b81cc5bbc122aab6f76f3051c5" + }, + "runtime/configs/data/chembl3d/implicit_h.yaml": { + "bytes": 81, + "sha256": "08da196d0288cf1fe4dba8dd407c49d9bee003607504beb59f79e5d6b9eaef0c" + }, + "runtime/configs/data/chembl3d/max100.yaml": { + "bytes": 246, + "sha256": "052dba7b3f3efce990019518823476bd6bf1381bb5f606822c9fc7a485e6d0bf" + }, + "runtime/configs/data/chembl3d/max100_implicit_h.yaml": { + "bytes": 79, + "sha256": "152325f11c7d1500469d4ee381100ae12247e6a2e0b90e5ef7501b405144bb2d" + }, + "runtime/configs/data/geom/geom.yaml": { + "bytes": 428, + "sha256": "736c04e86c029bed65ce3ed022f937dd418ddc70d4b9fc064b38d979bf29ee6d" + }, + "runtime/configs/data/geom/implicit_h.yaml": { + "bytes": 73, + "sha256": "c6b3ff5496af997de0c28e5aa7265903d6d0c8b59aab001ab21d6a64baeb772a" + }, + "runtime/configs/data/geom/scaffold_decoration.yaml": { + "bytes": 1107, + "sha256": "d317728a98961e98a1f03bcc3b0f8c513169ca23b68ea07ba807b99066e7ca80" + }, + "runtime/configs/data/molecular.yaml": { + "bytes": 5005, + "sha256": "f79ade5ddbf7d08f2eecbb2aa9c3e6b182b8a8e7e48960aff5e5edda5601bdc9" + }, + "runtime/configs/data/qm9/qm9.yaml": { + "bytes": 3648, + "sha256": "3beea93868a1e889b049dcc2ccbc3b7489fa4672e964138654bc512ea01ab9f2" + }, + "runtime/configs/data/qm9/scaffold_decoration.yaml": { + "bytes": 890, + "sha256": "40b36f2bc25bf4052cdd676a26d9e528cb4f7b13deddbb1660f454f97b159c40" + }, + "runtime/configs/default.yaml": { + "bytes": 1294, + "sha256": "bcf9744acbf53afa4305ffd6e9cf95e6f99571750488eaa8cad85896a51aca66" + }, + "runtime/configs/defaults/chembl3d/chembl3d.yaml": { + "bytes": 90, + "sha256": "f09553bc670e6c87ac8c1f8bbd0a8df73f1e83b02a3dcf9d6d06b915ac3bce93" + }, + "runtime/configs/defaults/chembl3d/implicit_h.yaml": { + "bytes": 102, + "sha256": "a0ee1440984eb4c9d3c96a5d1c18df9f59b8e42c499f8a340ccd8deb8c454cd8" + }, + "runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml": { + "bytes": 331, + "sha256": "518571405333a40e1602ee4c77fd153b56fd53e77aaa2aaf530796142f58d4b8" + }, + "runtime/configs/defaults/chembl3d/train_2x4090.yaml": { + "bytes": 826, + "sha256": "0e6b01b264ab1ae70ae7f144ecd0a03d242e24f02feb087e3d5e2b7bc24790da" + }, + "runtime/configs/defaults/core.yaml": { + "bytes": 431, + "sha256": "0dcfb68e2fece1dc277f647d3b3769af882cf8175e0c3b479bc50029e2538b1c" + }, + "runtime/configs/defaults/geom/geom.yaml": { + "bytes": 425, + "sha256": "f447a1bdd39e211b5cf43c4cce56e708da6472d58f79e87885621ec4899c1c8e" + }, + "runtime/configs/defaults/geom/implicit_h.yaml": { + "bytes": 98, + "sha256": "9d3a188e6bd2a2b01609ba0d652184bb8a2f7e27fd89d4a3031568051cae605e" + }, + "runtime/configs/defaults/geom/scaffold_decoration.yaml": { + "bytes": 828, + "sha256": "bcce546bce2b0b7bfaf7c0663e05423fd03c952bf6e52cbbf31976723661f0d1" + }, + "runtime/configs/defaults/qm9/qm9.yaml": { + "bytes": 132, + "sha256": "f83bd8e10802df07c2b1933dd40597807e3ee8a8cc39349ab14eb6f722f8475e" + }, + "runtime/configs/defaults/qm9/scaffold_decoration.yaml": { + "bytes": 348, + "sha256": "3bb8b6a757ea4921c31df2c0869949baef703df980b964d9636f52b64ac39267" + }, + "runtime/configs/embeddings/geom.yaml": { + "bytes": 803, + "sha256": "06793f4d446d899197e3db28d064592af9049612ee78cb0718a63e3d6fdc3151" + }, + "runtime/configs/embeddings/qm9.yaml": { + "bytes": 849, + "sha256": "de57af7f1ff9b44dbb952f852c0177789196105b77f396298ff8e0218c0626b7" + }, + "runtime/configs/heads/head.yaml": { + "bytes": 2475, + "sha256": "238e2346041e0aedccd16a6ed4066e163004c0e45a2cbc6fd1f6d17e16eb44ed" + }, + "runtime/configs/insertion_head/gmm.yaml": { + "bytes": 488, + "sha256": "1e26914eed9b4971e0f6ff0b61bbdb34103233d346719a55ba6273dd3d785c05" + }, + "runtime/configs/insertion_head/unimodal.yaml": { + "bytes": 495, + "sha256": "547a6391c3b341e3e369f8f4c7aab9752c67dfbbb533388d25956e9011b305e8" + }, + "runtime/configs/integrator/integrator.yaml": { + "bytes": 1580, + "sha256": "8f0d9aafb0b2727cd58154e1fd97df693c8b7d4f5a3fb5efcb9188553f54660f" + }, + "runtime/configs/logging/wandb_logger.yaml": { + "bytes": 139, + "sha256": "a9a2787e048d4eb70f2ff3c49d4355f1a5a382047c8619203b1f9131d829928d" + }, + "runtime/configs/loss_weights/calibrated_geom.yaml": { + "bytes": 644, + "sha256": "d4763c84aa9e1eb77fb2b456384332a4b3a84b072d560918b2357222a43f4ca1" + }, + "runtime/configs/loss_weights/calibrated_geom_frozen.yaml": { + "bytes": 1141, + "sha256": "a08acb61d67a852051dbded08f8bb67f21101888fd0a22a96a522781d28c570d" + }, + "runtime/configs/loss_weights/tuned_weights.yaml": { + "bytes": 420, + "sha256": "2bb5884465666d6fc638f787fd79257e4126c69a5cc55b55615fc45e02e65894" + }, + "runtime/configs/loss_weights/uniform.yaml": { + "bytes": 336, + "sha256": "3c14d6443048f69d436fe03792703a33c67f368ecbb349de2fd145da4c96eabc" + }, + "runtime/configs/model/backbones/dit.yaml": { + "bytes": 4039, + "sha256": "65b0f08be8fb307b8b01e7b460fd083fe60d48148fbece378d7e5a23dd4a9fb0" + }, + "runtime/configs/model/finetune/lora_finetune.yaml": { + "bytes": 328, + "sha256": "6334a5112499c0f3bc1fe5de224e2b6b3265dc524c8d6b9048113868fb864558" + }, + "runtime/configs/model/finetune/scaffold_finetune.yaml": { + "bytes": 5659, + "sha256": "1aad0d33e9b5ae95d9f50b5201a29bb8677fe42ea2bcf59e0e61d053ef1dfddb" + }, + "runtime/configs/optimizer/muon_aux_adam.yaml": { + "bytes": 1212, + "sha256": "3b0a6189f3ba81f85f7d26c6f6ad7f64dcb25098a14e411211d7a62983a7180c" + }, + "runtime/configs/resume_geom.yaml": { + "bytes": 13525, + "sha256": "abdca1624682973bc89829625c2c01abdaa3f318daa51a08da189967c8253195" + }, + "runtime/configs/schedule/beta.yaml": { + "bytes": 78, + "sha256": "75c485edfd70016b255282adcf5de25dfb95c9e32bb6587017fb8935ffe64eaa" + }, + "runtime/configs/schedule/cubic.yaml": { + "bytes": 54, + "sha256": "a998a2d3390ab5daf4df079852ae8daa907687cf059f55c500409c9493949c0c" + }, + "runtime/configs/schedule/fast_power.yaml": { + "bytes": 126, + "sha256": "22f36106bd43ca253ffea412e00539f2b07efbc49109b97c1232df6a827a2baf" + }, + "runtime/configs/schedule/linear.yaml": { + "bytes": 55, + "sha256": "f7881af26080ab5569e67b39f6eae2733c033b9623020e6271959f09fac45fa0" + }, + "runtime/configs/schedule/smoothstep.yaml": { + "bytes": 70, + "sha256": "f6e8651953d93f0d18c997d40c199b3d80ddd352a2041cc51e29adc9a7f22778" + }, + "runtime/configs/trainer/trainer.yaml": { + "bytes": 1402, + "sha256": "6f78e83ad46529bee7a2878b20727ff403420a515c1629bd73a608a2fb654f5f" + }, + "runtime/pyproject.toml": { + "bytes": 4464, + "sha256": "d4854128f81144905cef68d37222d0d06fb372e2bd148e49177e0ff082a7b01f" + }, + "runtime/requirements-tested.txt": { + "bytes": 177, + "sha256": "fac030b53775fd8ed8f89b17050ea74180f99dfab270b1d75d4ca3c0573ede76" + }, + "runtime/src/morph/__init__.py": { + "bytes": 79, + "sha256": "bb402ea5c6f79e569205cc2bf4168d06430753ce8499ca4ffb8614743626fd59" + }, + "runtime/src/morph/__main__.py": { + "bytes": 149, + "sha256": "95a7983479559ca2b15c7afa3881b3142ef4636583501184469b972df7646d4f" + }, + "runtime/src/morph/_vendor/__init__.py": { + "bytes": 72, + "sha256": "8480cd966a03870a7142e1763094bf00e221d2c3c2b1af1d4d458fb8b7f37a20" + }, + "runtime/src/morph/_vendor/geom_drugs_preprocessing.py": { + "bytes": 11328, + "sha256": "33a9c21d29df269a5f342a0b0e8bced0fc620559f0cb3a7a4cfabd5c369ceccb" + }, + "runtime/src/morph/_vendor/muon.py": { + "bytes": 16405, + "sha256": "c61af8c2c4a635cc682b84e279851cb54fedc6fb935d8eb11aafbeb1c5e81fc6" + }, + "runtime/src/morph/cli.py": { + "bytes": 9723, + "sha256": "27eee3fd6d6410747f12b19b670a74f50a51b842a50b1eff4e75c46d0d08a986" + }, + "runtime/src/morph/config.py": { + "bytes": 704, + "sha256": "bd3562c030611f87cc514308f155ad93740dd59927732f71b2e15009012d78ff" + }, + "runtime/src/morph/dataset/__init__.py": { + "bytes": 620, + "sha256": "44ae493354fed0fec4b410f46e0c1fb0351b5fbe5f404ff50f77181e60a39cee" + }, + "runtime/src/morph/dataset/conditional/__init__.py": { + "bytes": 238, + "sha256": "e46c747069acbf3d82407ccfcf96f2e33608c010a429f9abdb064bb71795d134" + }, + "runtime/src/morph/dataset/conditional/scaffold/__init__.py": { + "bytes": 167, + "sha256": "6ef414b31cceb7303c174d720e0d0666efd6f6678c97b3cf86d33c799d2153dd" + }, + "runtime/src/morph/dataset/conditional/scaffold/cutters.py": { + "bytes": 12282, + "sha256": "b29e0d7b1758461cda868bcba56d58b21ce76a45a9365bed73f7a2735103616f" + }, + "runtime/src/morph/dataset/conditional/scaffold/datamodule.py": { + "bytes": 1402, + "sha256": "ecd4a5819d3654f8f76e3e0d059a60bfc9d0ac2ec343514d45517e7094c7c282" + }, + "runtime/src/morph/dataset/conditional/scaffold/lmdb.py": { + "bytes": 4243, + "sha256": "54ea075ebb4a8ed481f93f57e9a5e25504918b6595bde106f2489e21e7e73b5b" + }, + "runtime/src/morph/dataset/conditional/scaffold/wrappers.py": { + "bytes": 7720, + "sha256": "a44d61452b676dc1dfd8d6bd44c09b22d463c8129b23cf98ec3903d10a693f2c" + }, + "runtime/src/morph/dataset/core/__init__.py": { + "bytes": 376, + "sha256": "7bab9c675f0850efc50e14a9898accfde5f8d1c1e2420414ac8c0986822d27e0" + }, + "runtime/src/morph/dataset/core/atomic_data.py": { + "bytes": 708, + "sha256": "7d59ec16ed5b3cf82b57b49dd4d7799862709353a65f4d473d4571ba91b86fa6" + }, + "runtime/src/morph/dataset/core/batch.py": { + "bytes": 23924, + "sha256": "7b2b2afd749b621b1ca890d192e4dc9378b61ead9ef34b90f04dc449e6bad369" + }, + "runtime/src/morph/dataset/core/containers.py": { + "bytes": 19637, + "sha256": "adae656c3fbbe5cfe32f378a419c7e1076f653163ffe7d2c1d1d0abc71a99cd7" + }, + "runtime/src/morph/dataset/core/conversion.py": { + "bytes": 2224, + "sha256": "0842565c503048a86a517c202dd0cc7c07e9296a841601b178ec38c43f903a6a" + }, + "runtime/src/morph/dataset/core/flow_matching_wrapper.py": { + "bytes": 7568, + "sha256": "7672ce6dc416bbccae1471b8e4fb66f44adf7eaf8f0f2fa151f1268e9513a17d" + }, + "runtime/src/morph/dataset/core/insertion_data.py": { + "bytes": 14393, + "sha256": "7e99e4d59b29ea537708b5f7887ed3e61b4b31ebf80d55fe5a0c6823ff8a439c" + }, + "runtime/src/morph/dataset/core/insertion_targets.py": { + "bytes": 6614, + "sha256": "2e62e89125310572735d916e97c8a544cfb3eb76fb167ecd134b5f8b0424df8c" + }, + "runtime/src/morph/dataset/core/lightning_datamodule.py": { + "bytes": 7057, + "sha256": "4d525cbcde4a64d7cac41837e0379c40097cc38fbbc2f54ff30ff7e3ab83de4c" + }, + "runtime/src/morph/dataset/core/lmdb.py": { + "bytes": 10111, + "sha256": "748b7a10f8fb20f74c267de393cc3e1933ea86debde99d8fdaaef94e0a9afd29" + }, + "runtime/src/morph/dataset/core/preprocessing.py": { + "bytes": 20145, + "sha256": "da9cffd6beb6da63ee749dd8b60d4d0509bcd937778014739b04ffc599dde066" + }, + "runtime/src/morph/dataset/core/properties.py": { + "bytes": 1952, + "sha256": "4dc63574f5fde284245078adf50fb2d8a9ac16443b8276154c03b7703cc2e937" + }, + "runtime/src/morph/dataset/core/representation.py": { + "bytes": 6591, + "sha256": "0ecac55e1faf9748826ec8f4c7f1a274263214ea3f68cc69de7047b55dd6a65f" + }, + "runtime/src/morph/dataset/core/tokenized_decode.py": { + "bytes": 6559, + "sha256": "483afb3d468113da33cc8699998e6439fcde2264ee7ca479724206360584a2b8" + }, + "runtime/src/morph/dataset/core/vocab.py": { + "bytes": 2236, + "sha256": "f8618488f7bcdc8a58d8b9f3443b764eac60985a33ead1bcc2d17d3bdc213329" + }, + "runtime/src/morph/dataset/unconditional/__init__.py": { + "bytes": 403, + "sha256": "d7cd3b85745ad1a4d884c0a11a64b3ab9cedcc64e30f604dbe83f5beb14fa97b" + }, + "runtime/src/morph/dataset/unconditional/chembl3d.py": { + "bytes": 9854, + "sha256": "d5cfd1ee4ddd3085b95b9a678d761d4f0beef99ac5ecfb21d3dfb3cae59653eb" + }, + "runtime/src/morph/dataset/unconditional/geom.py": { + "bytes": 4071, + "sha256": "12bc792c2a61b3e6c29ae265b9997ba610765e268408e873918b5a65225945be" + }, + "runtime/src/morph/dataset/unconditional/qm9.py": { + "bytes": 9520, + "sha256": "06b6086cb1234b283cd3a45daad2e06d3bdb5d45810b873f860c82969ccbfe53" + }, + "runtime/src/morph/flow_matching/__init__.py": { + "bytes": 78, + "sha256": "674f4630aa8421d7e75c913741c6efb1a163fbc38ce909c6cdb011f9ba9ef48e" + }, + "runtime/src/morph/flow_matching/assignment.py": { + "bytes": 16050, + "sha256": "b6ad66538f5bd7f14e0eee2a0ffd25a4110d1504e057a0a9eb63a354d9f6aadb" + }, + "runtime/src/morph/flow_matching/integration.py": { + "bytes": 33356, + "sha256": "f64b7b9ed2a79c11932b72e498e57eef924b5c85a05aa6893ddfe11e2d4595e9" + }, + "runtime/src/morph/flow_matching/interpolation.py": { + "bytes": 22169, + "sha256": "6ef2e03626fd4b3194c68df6e2410ab6e630f4891f97a8328b4eb0b3e18c7dcd" + }, + "runtime/src/morph/flow_matching/prior.py": { + "bytes": 2389, + "sha256": "0f9b77274238b23be7a2ad88ee8e6dcbb785817fcfa3a35aa863b489d2a6e05d" + }, + "runtime/src/morph/flow_matching/sampling_step.py": { + "bytes": 2806, + "sha256": "c768682a47da1b7852838e20b39317528717fc0485cbc35030b3ca71d7d845bb" + }, + "runtime/src/morph/flow_matching/schedules.py": { + "bytes": 6776, + "sha256": "6fd8396a05052ae7831babc75ca40a7e748fb9edffe82bb2559533afa949b2a9" + }, + "runtime/src/morph/inference/__init__.py": { + "bytes": 732, + "sha256": "63047340b4c7ed784e969b7d54035ab737282ee2acc6eeb26ba6cb3d52b4f573" + }, + "runtime/src/morph/inference/bundles.py": { + "bytes": 6395, + "sha256": "f6e2d46e6e428452a58208e78c83da01d6c786f10f7062643a093fb6750dafe6" + }, + "runtime/src/morph/inference/catalog.py": { + "bytes": 6215, + "sha256": "53a27b86d39641fd4964da82f4b1875598a0cdb1533f981074c6b7a14b4174b7" + }, + "runtime/src/morph/inference/generate.py": { + "bytes": 11889, + "sha256": "c3181fbf6187cf22b476431ed7c0fdea4c74ea8e97acf270f486318e43646d19" + }, + "runtime/src/morph/inference/hub.py": { + "bytes": 9739, + "sha256": "fa1f43937d66f0ab4b7b9c11ed749aef8d23444b2df3257c36ff72ecd3b821ca" + }, + "runtime/src/morph/inference/module.py": { + "bytes": 3820, + "sha256": "415e0f9c624c191855874b8dd88d5e7aa18941632bc403a9c0b685bbf74f1d60" + }, + "runtime/src/morph/inference/registry.py": { + "bytes": 4314, + "sha256": "8ca3e4246e00707ed0d646e6e760acebd5e3d3443e5e8d5f71f3b54653993fa9" + }, + "runtime/src/morph/model/README.md": { + "bytes": 10319, + "sha256": "9c06b5b90300f7449048640417116aa8869dc1a75f106cbdd9344a89f6e446d3" + }, + "runtime/src/morph/model/__init__.py": { + "bytes": 80, + "sha256": "1c7c3875387d5ba7f9166a804ea5a7ef7bd469728c1ad5c5003150d44e33be51" + }, + "runtime/src/morph/model/backbones/__init__.py": { + "bytes": 64, + "sha256": "a1a1419590c11aa6ed429f8b6b293470e30fe767f5571d2517b0883efb071e0c" + }, + "runtime/src/morph/model/backbones/conditional_dit.py": { + "bytes": 25451, + "sha256": "411fe1d0fd17c24e03e153d57f299aa0436bc5749e2064d5574681878429795d" + }, + "runtime/src/morph/model/backbones/dit.py": { + "bytes": 31262, + "sha256": "e2798baaf31bc8004a17c2432c56af9c4b12651d259cc0deaf5f18c83c6ddd33" + }, + "runtime/src/morph/model/build.py": { + "bytes": 3442, + "sha256": "e69f4231815f09c094d787f11edc49e222cbdc970edc6e416b6b94a9b88d4b21" + }, + "runtime/src/morph/model/finetune/__init__.py": { + "bytes": 282, + "sha256": "8746b1e29e5a3b3b1805389257270b90f5d30995783f2cf41b9fb533c485fc0c" + }, + "runtime/src/morph/model/finetune/base/__init__.py": { + "bytes": 803, + "sha256": "6e0ded0b3a5c2a76ccb85b05cf63bcc54e145bd1be07d933a737d6151433d856" + }, + "runtime/src/morph/model/finetune/base/conditional_backbone.py": { + "bytes": 2853, + "sha256": "1353e96443eacf3ce88473a30d02ab4f1c8b6a9c90763ea61c58d353b806b378" + }, + "runtime/src/morph/model/finetune/base/context_encoder.py": { + "bytes": 15124, + "sha256": "8d5dfc91ec73120161ed0a51dcc7edac442004ad4d5213ba34d9d11cfd1eee15" + }, + "runtime/src/morph/model/finetune/base/finetune_base.py": { + "bytes": 9526, + "sha256": "2efa00fd8eed1096a9cf8799dee4abfd8b27de1c0600e22a906dddaa089c4ca0" + }, + "runtime/src/morph/model/finetune/scaffold/__init__.py": { + "bytes": 366, + "sha256": "6345e71c8b03ee5eb0dcd105c0532acf73a1795bf6360b2847be600bcecd40ba" + }, + "runtime/src/morph/model/finetune/scaffold/scaffold.py": { + "bytes": 1984, + "sha256": "6de7a6083b4303ae422f03f15b5b40b1233d48bfc2dd1e0aa83a4eaa8adf40dd" + }, + "runtime/src/morph/model/finetune/scaffold/scaffold_context.py": { + "bytes": 6086, + "sha256": "d9446254770e3c3ea2c634a0636ab82a718514a7cb69760d756e9235cfbe2814" + }, + "runtime/src/morph/model/layers/__init__.py": { + "bytes": 189, + "sha256": "79671fbdea358c330a45b7fd3d842119b32b1d548804b3d441bbfb7dbd703b69" + }, + "runtime/src/morph/model/layers/attention.py": { + "bytes": 21038, + "sha256": "7efc416ac8fec0236909661ad0693a0aef613ff97a964fb6d8252cb49c60b5c6" + }, + "runtime/src/morph/model/layers/embedding.py": { + "bytes": 14356, + "sha256": "efe924190da5904e05495f421dcc313134cebe0dee9bf665060e1e5781cd95ef" + }, + "runtime/src/morph/model/layers/gmm.py": { + "bytes": 6753, + "sha256": "628e90c73bdbb3e70104214f80bbf8a94896ddbe22a9a3fc40cce34b20d2565f" + }, + "runtime/src/morph/model/layers/heads.py": { + "bytes": 30913, + "sha256": "7113c29a6be81c93ca980dc19d0847d2015ac36702683e4f1adedd549aed46ae" + }, + "runtime/src/morph/model/layers/kernels.py": { + "bytes": 12483, + "sha256": "be0c52b69c7612946244755e2865de0491440a41163cf828168eb750a8f929cf" + }, + "runtime/src/morph/model/layers/lora.py": { + "bytes": 5947, + "sha256": "7194750605e7b5df9a02e4c4a39cf29c982f117c40198c99c73211c85ecdfe4b" + }, + "runtime/src/morph/model/lightning/__init__.py": { + "bytes": 339, + "sha256": "c53943a4eb1e45568cebfb538655cc5eb7ead46284bbcd515800102c89177e52" + }, + "runtime/src/morph/model/lightning/_optimizer.py": { + "bytes": 19578, + "sha256": "f77519db8d3458c05aec1543cb4cd487995cf398abcc71b62ea1835cb132165e" + }, + "runtime/src/morph/model/lightning/_sampling.py": { + "bytes": 15344, + "sha256": "2694ba5cd0b315147285ba268ed5ad246d6091d2426fcd7b21f78d0eb0a8fca1" + }, + "runtime/src/morph/model/lightning/_setup.py": { + "bytes": 6537, + "sha256": "835f510094110c4dbc590a88d9b94d41474ae87b442785a949328feda5b9afce" + }, + "runtime/src/morph/model/lightning/_training.py": { + "bytes": 22479, + "sha256": "0451c3c4f1ad46cc5380615a3fbf8452d1fb4a0d27d69c885a411a3612c0997c" + }, + "runtime/src/morph/model/lightning/_validation.py": { + "bytes": 11509, + "sha256": "03890457e504eed96f23dd8633a6af4317a9ed61e80b3e4ec935aaf9e63038d7" + }, + "runtime/src/morph/model/lightning/base.py": { + "bytes": 9294, + "sha256": "ce9159e7b387ece0a3ce010b164f675b81980d2f617b5b05d00f96695ee33e28" + }, + "runtime/src/morph/model/losses/__init__.py": { + "bytes": 190, + "sha256": "bb9de5950496ed184a32e11834d8e6f3b7d406773509ffa6c406a86bb5249fae" + }, + "runtime/src/morph/model/losses/functional.py": { + "bytes": 11765, + "sha256": "42ae52f604a0cb8b7fe08955cfd273945ca068727994f20b02c80cdee3a00e1b" + }, + "runtime/src/morph/utils/__init__.py": { + "bytes": 78, + "sha256": "eb76ca86f046931eaea693a696317e8b3ce0b8ad8695a92e3744fd1892f2b9b8" + }, + "runtime/src/morph/utils/checkpoints.py": { + "bytes": 2301, + "sha256": "750302eb039e3947a5d4c84e4e7d04eb46e974cc0031ef1d197a3573e20eae4b" + }, + "runtime/src/morph/utils/edge_alignment.py": { + "bytes": 4896, + "sha256": "0edcde93cfa3a2d825e783ea5cf64715aa248e59ffb8c6a23d4490d74976a71e" + }, + "runtime/src/morph/utils/graph_math.py": { + "bytes": 6755, + "sha256": "2bae326c3538d757aac66dcfc4a0b5d023b17bd987d814d3dab9a57f9ce60552" + }, + "runtime/src/morph/utils/loss_accumulation.py": { + "bytes": 15115, + "sha256": "0f3eca42145e715290576dd86f4bcfb1553714ec4cabeda9246cfaf1e4f4323c" + }, + "runtime/src/morph/utils/lr_schedulers.py": { + "bytes": 5554, + "sha256": "b954c12df413ca6676263ef1af960e2a4ff52692acfd4223e72a6e02122a3638" + }, + "runtime/src/morph/utils/metrics/__init__.py": { + "bytes": 6730, + "sha256": "abec143d21e36343fff54db0ca8042111b9e70da426567b13ec36d6cb05696b6" + }, + "runtime/src/morph/utils/metrics/chemistry.py": { + "bytes": 34152, + "sha256": "61b598b99fad41571f9334b28f94fbeeeb0282d1b80423b1f2bc8f14180884e8" + }, + "runtime/src/morph/utils/metrics/distribution.py": { + "bytes": 3348, + "sha256": "0a6c25c4e65843308059a3effc44e1072837e747926074c1d7c47ba7ee72cb46" + }, + "runtime/src/morph/utils/metrics/plotting.py": { + "bytes": 4270, + "sha256": "686d491d7fbc83cf35f772649a869dc1a271966d2a4fa6162d6e2334db735723" + }, + "runtime/src/morph/utils/metrics/posecheck_strain.py": { + "bytes": 6695, + "sha256": "60a4751cce0e23d87d8602135de0c9850182fe97f20632cb9095445bdc6b0365" + }, + "runtime/src/morph/utils/metrics/tensor.py": { + "bytes": 13503, + "sha256": "3fcb867cde5d47f81725acc3b862fc56af5ecce23dee3a11ebe6d05c1772c6fe" + }, + "runtime/src/morph/utils/rdkit_utils.py": { + "bytes": 9018, + "sha256": "0fddc055c6de60893272fe8c50c92de29c9e72b7819827caac753e4cd0c6f1e5" + }, + "runtime/src/morph/utils/run_config.py": { + "bytes": 8025, + "sha256": "0c289120887a051379523af528ebc9310f356f3b92efb898ddaf773dbf698aa2" + }, + "runtime/src/morph/utils/vocab_utils.py": { + "bytes": 6343, + "sha256": "7b5506971b5092865b86311173b24d7fb70a881f123c57fd8696c412a25d618b" + }, + "runtime/uv.lock": { + "bytes": 611263, + "sha256": "6fd6609500ea661b3b5c9f17d0997c5709295ca2cbbe16c97fc8259178fa931e" + }, + "training_config.yaml": { + "bytes": 13428, + "sha256": "4fd11b415e9b4a599344431218db7f9960ba443dc29787410a4566ce5dacb434" + }, + "validation.json": { + "bytes": 294, + "sha256": "5acbbb78be426e03abd58c3dc062b30b000e91e815c329dfa7f1422c9866b740" + } +} diff --git a/geom/explicit_h/metadata.json b/geom/explicit_h/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..f8a7aa8209a52cf95a99b59a9c607dad9b212045 --- /dev/null +++ b/geom/explicit_h/metadata.json @@ -0,0 +1,48 @@ +{ + "dataset": "geom", + "variant": "explicit_h", + "includes_explicit_hydrogens": true, + "source_max_total_atoms_including_h": 72, + "training_run": "geom-explicit-e48-resume-16520626", + "resumed_from_completed_epoch": 48, + "source_snapshot": "geom_resume_e48_20261008", + "checkpoint_format": "Original Lightning checkpoint, current + EMA + optimizer state", + "epoch_numbering": "Completed epochs (one-based); original checkpoint epoch is zero-based", + "checkpoints": [ + { + "filename": "epoch_050.ckpt", + "original_filename": "epoch=49-step=441050.ckpt", + "completed_epochs": 50, + "checkpoint_epoch_index": 49, + "global_step": 441050, + "bytes": 509181385, + "sha256": "016c4a5bbb4f5ef36d1d62dff772ecf8d384e8ab3e2c94ebeb4c4357183f690a" + } + ], + "atom_tokens": [ + "Br", + "C", + "Cl", + "F", + "H", + "I", + "N", + "O", + "P", + "S" + ], + "edge_tokens": [ + "", + "1", + "2", + "3" + ], + "charge_tokens": [ + "-2", + "-1", + "0", + "1", + "2" + ], + "coordinate_std_angstrom": 2.4406259059906006 +} diff --git a/geom/explicit_h/runtime/configs/callbacks/callbacks.yaml b/geom/explicit_h/runtime/configs/callbacks/callbacks.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1fe407944f322f2e45a3f2789d65ac6dbcfbc112 --- /dev/null +++ b/geom/explicit_h/runtime/configs/callbacks/callbacks.yaml @@ -0,0 +1,20 @@ +# @package _global_ + +## Put your callback args here +callbacks: + monitor_metric: ${if:${oc.eval:"'${representation}' in ('geometric_graph', 'molecule')"},val/validity,val/batch/pair_dist_l1} + monitor_metric_mode: ${if:${oc.eval:"'${representation}' in ('geometric_graph', 'molecule')"},max,min} + + # model_checkpoints -> best-by-metric (top-1, monitored) + # every_n_epochs_checkpoint -> latest (no monitor; overwrites every epoch) + model_checkpoints: + save_top_k: 1 + save_last: False + verbose: False + + every_n_epochs_checkpoint: + every_n_epochs: 1 + save_top_k: 1 + save_last: False + verbose: False + save_on_train_epoch_end: True # Force per-train-epoch firing diff --git a/geom/explicit_h/runtime/configs/data/chembl3d/chembl3d.yaml b/geom/explicit_h/runtime/configs/data/chembl3d/chembl3d.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1d8c2a257fbe3e481311ea14796c419d6430f996 --- /dev/null +++ b/geom/explicit_h/runtime/configs/data/chembl3d/chembl3d.yaml @@ -0,0 +1,8 @@ +# LoQI ChEMBL3D source format, without dataset-specific size/element filters. +defaults: + - /data/molecular@_here_ + - _self_ + +dataset_name: chembl3d +dataset_target: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset +source_dir: ${oc.env:CHEMBL3D_SOURCE_DIR,null} diff --git a/geom/explicit_h/runtime/configs/data/chembl3d/implicit_h.yaml b/geom/explicit_h/runtime/configs/data/chembl3d/implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..aed8c377319531469115e8ab7684ee8721cf45b1 --- /dev/null +++ b/geom/explicit_h/runtime/configs/data/chembl3d/implicit_h.yaml @@ -0,0 +1,5 @@ +defaults: + - /data/chembl3d/chembl3d@_here_ + - _self_ + +hydrogen_mode: implicit diff --git a/geom/explicit_h/runtime/configs/data/chembl3d/max100.yaml b/geom/explicit_h/runtime/configs/data/chembl3d/max100.yaml new file mode 100644 index 0000000000000000000000000000000000000000..62f9a2adf37959b4ebfb757a16fe50512e7d3295 --- /dev/null +++ b/geom/explicit_h/runtime/configs/data/chembl3d/max100.yaml @@ -0,0 +1,7 @@ +# Inclusive total-atom limit, applied before hydrogen removal. +defaults: + - /data/chembl3d/chembl3d@_here_ + - _self_ + +max_atoms: 100 +cache_name: ${data.dataset_name}_max${data.max_atoms}${if:${eq:${data.hydrogen_mode},implicit},_implicit_h,''} diff --git a/geom/explicit_h/runtime/configs/data/chembl3d/max100_implicit_h.yaml b/geom/explicit_h/runtime/configs/data/chembl3d/max100_implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..005cef8a084acdae21f91fea19615b12b4a3b315 --- /dev/null +++ b/geom/explicit_h/runtime/configs/data/chembl3d/max100_implicit_h.yaml @@ -0,0 +1,5 @@ +defaults: + - /data/chembl3d/max100@_here_ + - _self_ + +hydrogen_mode: implicit diff --git a/geom/explicit_h/runtime/configs/data/geom/geom.yaml b/geom/explicit_h/runtime/configs/data/geom/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2dec6705c8ff3a78a6c126b392c5ad4400f9a910 --- /dev/null +++ b/geom/explicit_h/runtime/configs/data/geom/geom.yaml @@ -0,0 +1,11 @@ +# GEOM-Drugs source format and default molecule subset. +defaults: + - /data/molecular@_here_ + - _self_ + +dataset_name: geom +dataset_target: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset +source_dir: ${oc.env:GEOM_SOURCE_DIR,null} # Optional prefiltered pickle directory. +# Preserve the established GEOM subset. Limits apply before hydrogen removal. +max_atoms: 72 +allowed_atom_symbols: [H, C, N, O, S, F, Cl, Br, P, I] diff --git a/geom/explicit_h/runtime/configs/data/geom/implicit_h.yaml b/geom/explicit_h/runtime/configs/data/geom/implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..53fcd5513e4798e67b6ffd85a7786d783ede7e41 --- /dev/null +++ b/geom/explicit_h/runtime/configs/data/geom/implicit_h.yaml @@ -0,0 +1,5 @@ +defaults: + - /data/geom/geom@_here_ + - _self_ + +hydrogen_mode: implicit diff --git a/geom/explicit_h/runtime/configs/data/geom/scaffold_decoration.yaml b/geom/explicit_h/runtime/configs/data/geom/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..784e3b44c463f4885489f28b09a956b01a5e9580 --- /dev/null +++ b/geom/explicit_h/runtime/configs/data/geom/scaffold_decoration.yaml @@ -0,0 +1,24 @@ +# GEOM-Drugs data config for the **scaffold decoration** task. +# +# Mirrors configs/data/qm9/scaffold_decoration.yaml but on GEOM-Drugs: source +# is the selected scaffold of each target; target is the full molecule. The model +# learns to grow it by inserting decoration atoms around the fixed scaffold. +# +# Inherits from configs/data/geom/geom.yaml and overrides: +# - datamodule._target_ -> ScaffoldLightningDataModule +# +# The scaffold cache is built lazily into ``scaffold_groups_dir`` on first run +# (one file per split and scaffold kind) and reused afterwards. +defaults: + - /data/geom/geom@_here_ + - _self_ + +scaffold_groups_dir: ${data.preprocessing.root}/processed/scaffold_groups +scaffold_kind: rgroup # murcko, rgroup (BRICS), heavy (generate H), linker +# Evaluate each distinct molecule/scaffold pair; training always draws randomly. +enumerate_slicings: false +datamodule: + _target_: morph.dataset.conditional.scaffold.datamodule.ScaffoldLightningDataModule + scaffold_groups_dir: ${data.scaffold_groups_dir} + scaffold_kind: ${data.scaffold_kind} + enumerate_slicings: ${data.enumerate_slicings} diff --git a/geom/explicit_h/runtime/configs/data/molecular.yaml b/geom/explicit_h/runtime/configs/data/molecular.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a8d1975ed1b63f482b2110dd404ea56ffa46c15b --- /dev/null +++ b/geom/explicit_h/runtime/configs/data/molecular.yaml @@ -0,0 +1,136 @@ +# Shared drug-like molecular training and preprocessing configuration. + +root_path: ${oc.env:PROJECT_ROOT}/data +preprocess_workers: 10 +preprocess_chunk_size: 128 +statistics_workers: ${data.preprocess_workers} +hydrogen_mode: explicit +# The mode is part of the cache path; changing it discovers fresh vocab/statistics. +cache_name: ${data.dataset_name}${if:${eq:${data.hydrogen_mode},implicit},_implicit_h,''} +source_dir: null +max_atoms: null +allowed_atom_symbols: null + +n_atoms_strategy: "uniform" # "approx", "empirical", "learned" +# Uniform source-count support padding; explicit so inference and training can +# be matched or ablated without code changes. +uniform_n_atoms_padding: 5 +optimal_transport: "equivariant" +# Physical Angstroms (converted to normalized coordinates internally). These +# reproduce the former normalized 0.25 / 0.2 scales at GEOM's training-set std. +# Physical Angstroms. Insertion bridge noise vanishes as (1-t); move noise is +# the persistent training-time jitter floor for matched atoms and insertions +# that are already present. +ins_noise_scale: 0.65 +move_noise_scale: 0.5 + +# Drug-like datasets contain charged species; allow them in validity metrics. +allow_charged: true + + +# Preprocessing configuration - computes tokens from data and distributions from training dataset only +preprocessing: + _target_: morph.dataset.core.preprocessing.Preprocessing + _recursive_: false + root: ${data.root_path}/${data.cache_name} + num_workers: ${data.statistics_workers} + train_dataset: ${data.datamodule.datasets.train} + atom_tokens_path: ${data.preprocessing.root}/processed/atom_tokens.txt + edge_tokens_path: ${data.preprocessing.root}/processed/edge_tokens.txt + charge_tokens_path: ${data.preprocessing.root}/processed/charge_tokens.txt + distributions_path: ${data.preprocessing.root}/processed/distributions.pt + # Tokens and distributions will be discovered from data and saved to files + # On subsequent runs, they will be loaded from the files + +vocab: null + +interpolator: + _target_: morph.flow_matching.interpolation.Interpolator + distributions: null + optimal_transport: ${data.optimal_transport} + ins_noise_scale: ${data.ins_noise_scale} + move_noise_scale: ${data.move_noise_scale} + # Training-only categorical corruption; pairs are scaled per molecule to + # match the expected number of changed atoms. Clean targets stay unchanged. + atom_type_noise_prob: 0.0 + # OT matching (cost always squared-Euclidean; move cost in Angstrom). c_move + # scales the squared move distance (A^2); c_ins/c_del/c_sub are fixed A^2 costs, + # directly comparable. At 6.125 each, delete+insert costs 3.5^2, so atoms + # farther than 3.5 A apart are left unmatched by default. + pre_align_iters: 2 # equivariant-OT inner loop (Hungarian+Kabsch) iters + c_move: 1.0 + c_sub: 0.0 + c_ins: 6.125 + c_del: 6.125 + del_schedule: ${schedule} + ins_schedule: ${schedule} + sub_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + sub_e_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + +datamodule: + _target_: morph.dataset.core.lightning_datamodule.LightningDataModule + # tokens and distributions will be passed from preprocessing in run.py + + vocab: ${data.vocab} + distributions: null + representation: ${representation} + + datasets: + train: + _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + + val: + - _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + + test: + - _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + + interpolator: ${data.interpolator} + + rotate: ${model.rotate} + n_augmentations: ${model.n_augmentations} + + num_workers: + train: 10 + val: 0 + test: 0 + + batch_size: + train: 320 + val: 128 + test: 64 + + n_atoms_strategy: ${data.n_atoms_strategy} + uniform_n_atoms_padding: ${data.uniform_n_atoms_padding} + time_dist: ${model.module.time_dist} diff --git a/geom/explicit_h/runtime/configs/data/qm9/qm9.yaml b/geom/explicit_h/runtime/configs/data/qm9/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..992049b566f18acb2744568adcf61e47ecfc2342 --- /dev/null +++ b/geom/explicit_h/runtime/configs/data/qm9/qm9.yaml @@ -0,0 +1,106 @@ +## QM9 data and preprocessing configuration. + +dataset_name: qm9 +root_path: ${oc.env:PROJECT_ROOT}/data +preprocess_workers: 0 + +n_atoms_strategy: "uniform" # "approx", "empirical", "learned" +# Expand both ends of the empirical count support for the uniform flow source. +# QM9's minimum is already the hard lower bound of 3, so positive padding is +# necessarily asymmetric; set to 0 for an unpadded 3--29 diagnostic prior. +uniform_n_atoms_padding: 5 +optimal_transport: "equivariant" +# Physical Angstroms (converted to normalized coordinates internally). Insertion +# bridge noise vanishes as (1-t); move noise is the persistent training-time +# jitter floor for matched atoms and insertions that are already present. +ins_noise_scale: 0.35 +move_noise_scale: 0.3 + +# QM9 contains only neutral molecules; charged species are treated as invalid. +allow_charged: false + + +# Preprocessing configuration - computes tokens from data and distributions from training dataset only +preprocessing: + _target_: morph.dataset.core.preprocessing.Preprocessing + _recursive_: false + root: ${data.root_path}/qm9 + train_dataset: ${data.datamodule.datasets.train} + # Canonical (representation-agnostic) cache. One preprocessing per dataset; + # representation-specific projection happens at runtime in the wrapper. + atom_tokens_path: ${data.preprocessing.root}/processed/atom_tokens.txt + edge_tokens_path: ${data.preprocessing.root}/processed/edge_tokens.txt + charge_tokens_path: ${data.preprocessing.root}/processed/charge_tokens.txt + distributions_path: ${data.preprocessing.root}/processed/distributions.pt + +vocab: None + +interpolator: + _target_: morph.flow_matching.interpolation.Interpolator + distributions: None + optimal_transport: ${data.optimal_transport} + ins_noise_scale: ${data.ins_noise_scale} + move_noise_scale: ${data.move_noise_scale} + # OT matching (cost always squared-Euclidean; move cost in Angstrom). c_move + # scales the squared move distance (A^2); c_ins/c_del/c_sub are fixed A^2 costs, + # directly comparable. At 6.125 each, delete+insert costs 3.5^2, so atoms + # farther than 3.5 A apart are left unmatched by default. + pre_align_iters: 2 # equivariant-OT inner loop (Hungarian+Kabsch) iters + c_move: 1.0 + c_sub: 0.0 + c_ins: 6.125 + c_del: 6.125 + del_schedule: ${schedule} + ins_schedule: ${schedule} + sub_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + sub_e_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + +datamodule: + _target_: morph.dataset.core.lightning_datamodule.LightningDataModule + # tokens and distributions will be passed from preprocessing in run.py + + vocab: ${data.vocab} + distributions: None + representation: ${representation} + + datasets: + train: + _target_: morph.dataset.unconditional.qm9.FlowMatchingQM9Dataset + root: ${data.root_path}/qm9 + vocab: ${data.vocab} + distributions: None + + val: + - _target_: morph.dataset.unconditional.qm9.FlowMatchingQM9Dataset + root: ${data.root_path}/qm9 + vocab: ${data.vocab} + distributions: None + + test: + - _target_: morph.dataset.unconditional.qm9.FlowMatchingQM9Dataset + root: ${data.root_path}/qm9 + vocab: ${data.vocab} + distributions: None + + interpolator: ${data.interpolator} + + rotate: ${model.rotate} + n_augmentations: ${model.n_augmentations} + + num_workers: + train: 10 + val: 0 + test: 0 + + batch_size: + train: 1024 + val: 128 + test: 32 + + n_atoms_strategy: ${data.n_atoms_strategy} + uniform_n_atoms_padding: ${data.uniform_n_atoms_padding} + time_dist: ${model.module.time_dist} diff --git a/geom/explicit_h/runtime/configs/data/qm9/scaffold_decoration.yaml b/geom/explicit_h/runtime/configs/data/qm9/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8c636cf25fc5a740906f6210a7430457794b92fe --- /dev/null +++ b/geom/explicit_h/runtime/configs/data/qm9/scaffold_decoration.yaml @@ -0,0 +1,22 @@ +# QM9 data config for the **scaffold decoration** task. +# +# Source is the selected scaffold of each target; target is the full molecule. +# The model learns to grow the molecule by inserting decoration atoms +# around the fixed scaffold. +# +# Inherits from configs/data/qm9/qm9.yaml and overrides: +# - datamodule._target_ → ScaffoldLightningDataModule +# +defaults: + - /data/qm9/qm9@_here_ + - _self_ + +scaffold_groups_dir: ${data.root_path}/qm9/processed/scaffold_groups +scaffold_kind: rgroup # murcko, rgroup (BRICS), heavy (generate H), linker +# Evaluate each distinct molecule/scaffold pair; training always draws randomly. +enumerate_slicings: false +datamodule: + _target_: morph.dataset.conditional.scaffold.datamodule.ScaffoldLightningDataModule + scaffold_groups_dir: ${data.scaffold_groups_dir} + scaffold_kind: ${data.scaffold_kind} + enumerate_slicings: ${data.enumerate_slicings} diff --git a/geom/explicit_h/runtime/configs/default.yaml b/geom/explicit_h/runtime/configs/default.yaml new file mode 100644 index 0000000000000000000000000000000000000000..62ac4294601048087b24a7825a283abf44a2d68e --- /dev/null +++ b/geom/explicit_h/runtime/configs/default.yaml @@ -0,0 +1,25 @@ +defaults: + - _self_ + # Single-knob presets bundle + # `data` + `embeddings` (with dataset-tuned ExtrapolatableCount params) + # plus the shared `core` group selections. Override individual groups as + # usual, e.g. `defaults=geom/geom model=backbones/dit embeddings=qm9`. + - defaults: qm9/qm9 + +# What the model trains on. Datasets declare their CAPABILITIES; run.py validates +# the requested mode against them before preprocessing. +# pointcloud — x, a only (charges and edges are dummy singletons) +# charged_pointcloud — x, a, c (edges are dummy singletons) +# geometric_graph — x, a, edge_index, e (charges are dummy neutral) +# molecule — full chemistry (current behavior) +representation: molecule +seed: 42 + +# Run directory: ////. Root is +# MORPH_RUN_ROOT (scratch, set in .env) so every run's outputs/checkpoints/wandb +# land off-repo; falls back to /outputs for ad-hoc runs with no env set. +# wandb id is MORPH_RUN_ID (same as WANDB_RUN_ID). Set in the shell before +# torchrun, or set automatically for single-process runs in run.py before Hydra composes. +hydra: + run: + dir: ${oc.env:MORPH_RUN_ROOT,${hydra:runtime.cwd}/outputs}/${hydra:runtime.choices.data}/${now:%Y-%m-%d}/${oc.env:MORPH_RUN_ID} diff --git a/geom/explicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml b/geom/explicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml new file mode 100644 index 0000000000000000000000000000000000000000..da60c3219bac2bd1cc23d5cf1eaca85084c2cb89 --- /dev/null +++ b/geom/explicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml @@ -0,0 +1,5 @@ +# @package _global_ +defaults: + - core + - /data: chembl3d/chembl3d + - /embeddings: geom diff --git a/geom/explicit_h/runtime/configs/defaults/chembl3d/implicit_h.yaml b/geom/explicit_h/runtime/configs/defaults/chembl3d/implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..132977af082d8fe9724b85773371f19049621c6a --- /dev/null +++ b/geom/explicit_h/runtime/configs/defaults/chembl3d/implicit_h.yaml @@ -0,0 +1,5 @@ +# @package _global_ +defaults: + - /defaults/core + - /data: chembl3d/implicit_h + - /embeddings: geom diff --git a/geom/explicit_h/runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml b/geom/explicit_h/runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..640868cdedc8b9216e8a77238ef22cd183977353 --- /dev/null +++ b/geom/explicit_h/runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml @@ -0,0 +1,10 @@ +# @package _global_ +defaults: + - /defaults/chembl3d/train_2x4090 + - override /data: chembl3d/max100_implicit_h + - _self_ + +# Train from scratch: the atom vocabulary/statistics differ from the H model. +# Preserve global batch 640, eval every epoch, both checkpoint callbacks every 5. +logging: + project: morph-chembl3d-implicit-h diff --git a/geom/explicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml b/geom/explicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0f8da8debe24d260bc2fb7854e9905d31e9548b1 --- /dev/null +++ b/geom/explicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml @@ -0,0 +1,40 @@ +# @package _global_ +defaults: + - /defaults/core + - /data: chembl3d/max100 + - /embeddings: geom + - _self_ + +# Profiled on max100 ChEMBL: 2 GPUs * 320 * 1 = 640 effective global batch. +data: + datamodule: + train_prefetch_factor: 1 + batch_size: + train: 320 + val: 32 + test: 32 + num_workers: + train: 20 + val: 2 + test: 2 + +trainer: + trainer: + accelerator: gpu + devices: 2 + accumulate_grad_batches: 1 + max_epochs: -1 + check_val_every_n_epoch: 1 + num_val_samples: 4096 + # Validation runs during fit; leave wall-clock headroom for final checkpointing. + do_validate: false + do_predict: false + final_checkpoint_path: final.ckpt + +callbacks: + model_checkpoints: + every_n_epochs: 5 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link diff --git a/geom/explicit_h/runtime/configs/defaults/core.yaml b/geom/explicit_h/runtime/configs/defaults/core.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a8a7114d33bddc4ec3a5011213e925285a426e40 --- /dev/null +++ b/geom/explicit_h/runtime/configs/defaults/core.yaml @@ -0,0 +1,13 @@ +# @package _global_ +# Shared default groups that don't change between QM9 / GEOM runs. +# Dataset-specific presets (defaults/qm9/qm9.yaml, defaults/geom/geom.yaml) inherit +# from this and only add `data` + `embeddings`. +defaults: + - /heads: head + - /insertion_head: unimodal + - /model: backbones/dit + - /schedule: smoothstep + - /trainer: trainer + - /callbacks: callbacks + - /logging: wandb_logger + - /integrator: integrator diff --git a/geom/explicit_h/runtime/configs/defaults/geom/geom.yaml b/geom/explicit_h/runtime/configs/defaults/geom/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c9a4106610671357d9e2d8b0ca39bdbcca5c8f9d --- /dev/null +++ b/geom/explicit_h/runtime/configs/defaults/geom/geom.yaml @@ -0,0 +1,20 @@ +# @package _global_ +# GEOM preset: shared core + GEOM data and embeddings. +defaults: + - core + - /data: geom/geom + - /embeddings: geom + - _self_ + +# Evaluate every epoch; keep every fifth completed epoch for releases/resuming. +trainer: + trainer: + check_val_every_n_epoch: 1 + +callbacks: + model_checkpoints: + every_n_epochs: 5 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link diff --git a/geom/explicit_h/runtime/configs/defaults/geom/implicit_h.yaml b/geom/explicit_h/runtime/configs/defaults/geom/implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6d448b2e063b23e1f95e00129a168b6751adb5e1 --- /dev/null +++ b/geom/explicit_h/runtime/configs/defaults/geom/implicit_h.yaml @@ -0,0 +1,5 @@ +# @package _global_ +defaults: + - /defaults/core + - /data: geom/implicit_h + - /embeddings: geom diff --git a/geom/explicit_h/runtime/configs/defaults/geom/scaffold_decoration.yaml b/geom/explicit_h/runtime/configs/defaults/geom/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c2fd2d1ba1d5386433bc15d97cdbd0b77b04296b --- /dev/null +++ b/geom/explicit_h/runtime/configs/defaults/geom/scaffold_decoration.yaml @@ -0,0 +1,23 @@ +# @package _global_ +# GEOM-Drugs **scaffold decoration** fine-tune preset. +# +# Mirrors defaults/qm9/scaffold_decoration.yaml on GEOM-Drugs. Bare-scaffold +# source -> full-molecule target: the model learns to grow a molecule by +# inserting decoration atoms around the fixed scaffold. +# +# Differences from the QM9 scaffold preset: +# - data -> geom/scaffold_decoration (GEOM base datasets) +# - embeddings -> geom +# +# Reuses the scaffold_finetune model config (loads the pretrained unconditional +# DiT into the clean lane + adds the node/edge conditioning and the gated +# cross-attn blocks). +# +# Pair with a GEOM unconditional DiT checkpoint via +# ``model.module.pretrained_ckpt=...``. +defaults: + - core + - /data: geom/scaffold_decoration + - /embeddings: geom + - override /model: finetune/scaffold_finetune + - _self_ diff --git a/geom/explicit_h/runtime/configs/defaults/qm9/qm9.yaml b/geom/explicit_h/runtime/configs/defaults/qm9/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a43bdfbcd6bb9fb6964e0be073e11d28f2f399bc --- /dev/null +++ b/geom/explicit_h/runtime/configs/defaults/qm9/qm9.yaml @@ -0,0 +1,6 @@ +# @package _global_ +# QM9 preset: shared core + QM9 data and embeddings. +defaults: + - core + - /data: qm9/qm9 + - /embeddings: qm9 diff --git a/geom/explicit_h/runtime/configs/defaults/qm9/scaffold_decoration.yaml b/geom/explicit_h/runtime/configs/defaults/qm9/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cf721f81b4422726df6382286e23c38787f75cce --- /dev/null +++ b/geom/explicit_h/runtime/configs/defaults/qm9/scaffold_decoration.yaml @@ -0,0 +1,11 @@ +# @package _global_ +# QM9 **scaffold decoration** fine-tune preset. +# +# Bare-scaffold source → full-molecule target. The model learns to grow +# a molecule by inserting decoration atoms around the fixed scaffold. +defaults: + - core + - /data: qm9/scaffold_decoration + - /embeddings: qm9 + - override /model: finetune/scaffold_finetune + - _self_ diff --git a/geom/explicit_h/runtime/configs/embeddings/geom.yaml b/geom/explicit_h/runtime/configs/embeddings/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..51d9fda0862b44cff795661c2f1e91217c7743ab --- /dev/null +++ b/geom/explicit_h/runtime/configs/embeddings/geom.yaml @@ -0,0 +1,28 @@ +# Shared embedding stack tuned for GEOM-Drugs. +# - node_count_embedding uses ExtrapolatableCountEmbedding with periods/max_count +# scaled up for the much larger atom counts in GEOM-Drugs. + +atom_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.atom_tokens}} + embedding_dim: 64 + out_dim: 128 + +edge_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.edge_tokens}} + embedding_dim: 64 + out_dim: 128 + +time_embedding: + _target_: morph.model.layers.embedding.TimeEmbedding + embedding_dim: 128 + out_dim: 128 + +node_count_embedding: + _target_: morph.model.layers.embedding.ExtrapolatableCountEmbedding + embedding_dim: 64 + out_dim: 128 + min_period: 128.0 + max_period: 1024.0 + max_count: 96.0 diff --git a/geom/explicit_h/runtime/configs/embeddings/qm9.yaml b/geom/explicit_h/runtime/configs/embeddings/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4dcc81608316e2105165576aaf8d915e2317f97d --- /dev/null +++ b/geom/explicit_h/runtime/configs/embeddings/qm9.yaml @@ -0,0 +1,29 @@ +# Shared embedding stack tuned for QM9. +# - node_count_embedding uses ExtrapolatableCountEmbedding so the model can +# generalise slightly outside the training atom-count support. +# - Defaults sized for QM9 (training counts in [3, 29]). + +atom_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.atom_tokens}} + embedding_dim: 64 + out_dim: 128 + +edge_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.edge_tokens}} + embedding_dim: 64 + out_dim: 128 + +time_embedding: + _target_: morph.model.layers.embedding.TimeEmbedding + embedding_dim: 128 + out_dim: 128 + +node_count_embedding: + _target_: morph.model.layers.embedding.ExtrapolatableCountEmbedding + embedding_dim: 64 + out_dim: 128 + min_period: 64.0 + max_period: 512.0 + max_count: 32.0 diff --git a/geom/explicit_h/runtime/configs/heads/head.yaml b/geom/explicit_h/runtime/configs/heads/head.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bef544d639b98055af99cb253ea8dc0a82486473 --- /dev/null +++ b/geom/explicit_h/runtime/configs/heads/head.yaml @@ -0,0 +1,75 @@ +# Standard molecular DiT readouts. The insertion-content head is an independent +# Hydra choice (``insertion_head=unimodal`` by default, or ``=gmm``). + +heads: + _target_: morph.model.layers.heads.MultiHeadModule + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + + heads_configs: + graph_aggregation: mean + + node_heads: + atom_type_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.atom_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + + charge_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.charge_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + + ins_rate_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_rate: ${model.head_initialization.ins_rate} + + do_del_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_del_probability} + + do_sub_a_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_sub_a_probability} + + edge_heads: + edge_type_head: + input_dim: ${model.head_edge_dim} + output_dim: ${len:${data.vocab.edge_tokens}} + adapter_dim: ${model.head_edge_adapter_dim} + group: state + + do_sub_e_head: + input_dim: ${model.head_edge_dim} + output_dim: 1 + adapter_dim: ${model.head_edge_adapter_dim} + group: state + initial_probability: ${model.head_initialization.do_sub_e_probability} + +ins_edge_head: + _target_: morph.model.layers.heads.InsertionEdgeHead + _recursive_: false + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_edge_dim} + output_init_std: ${model.head_initialization.output_std} + n_edge_types: ${len:${data.vocab.edge_tokens}} + n_atom_types: ${len:${data.vocab.atom_tokens}} + n_charge_types: ${len:${data.vocab.charge_tokens}} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false diff --git a/geom/explicit_h/runtime/configs/insertion_head/gmm.yaml b/geom/explicit_h/runtime/configs/insertion_head/gmm.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5dedca7c0ff62a5d3fcfaf43de5aa79a161d813f --- /dev/null +++ b/geom/explicit_h/runtime/configs/insertion_head/gmm.yaml @@ -0,0 +1,16 @@ +# Optional mixture-density insertion-content head. +mode: gmm +dimension: 3 +num_components: 10 + +model: + _target_: morph.model.layers.heads.GMMInsertionHead + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_adapter_dim} + output_init_std: ${model.head_initialization.output_std} + num_components: ${insertion_head.num_components} + sigma_min: 0.05 + sigma_max: 2.0 + sigma_init: 0.5 + N_a: ${len:${data.vocab.atom_tokens}} + N_c: ${len:${data.vocab.charge_tokens}} diff --git a/geom/explicit_h/runtime/configs/insertion_head/unimodal.yaml b/geom/explicit_h/runtime/configs/insertion_head/unimodal.yaml new file mode 100644 index 0000000000000000000000000000000000000000..85148799b512c78531aa577a18d36bbdc70f9cba --- /dev/null +++ b/geom/explicit_h/runtime/configs/insertion_head/unimodal.yaml @@ -0,0 +1,13 @@ +# Default insertion-content head: one nearest pending atom per spawning node. +mode: unimodal +dimension: 3 +num_components: 1 + +model: + _target_: morph.model.layers.heads.UnimodalInsertionHead + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_adapter_dim} + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + N_a: ${len:${data.vocab.atom_tokens}} + N_c: ${len:${data.vocab.charge_tokens}} diff --git a/geom/explicit_h/runtime/configs/integrator/integrator.yaml b/geom/explicit_h/runtime/configs/integrator/integrator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d0c167b0138fc54d8413743649543e4b2428c05a --- /dev/null +++ b/geom/explicit_h/runtime/configs/integrator/integrator.yaml @@ -0,0 +1,30 @@ +integrator: + _target_: morph.flow_matching.integration.RateIntegrator + vocab: ${data.vocab} + distributions: None + insertion_head_mode: ${insertion_head.mode} + insertion_dimension: ${insertion_head.dimension} + insertion_num_components: ${insertion_head.num_components} + # strategy used for sampling time during **integration** + # NOTE can be different from the time strategy used for the model (see below) + time_strategy: log # "linear", "log", or "beta" + # shape params for time_strategy: beta (Beta inverse-CDF spacing). + # defaults match the model's beta_sched (concentration1=2.0, concentration0=1.0) + time_beta_alpha: 2.0 + time_beta_beta: 1.0 + num_integration_steps: 100 + ins_noise_scale: ${data.ins_noise_scale} + # When true, freshly-inserted nodes are pushed to the t+dt marginal (kappa-mix + # on atom/bond types, (1-t)*ins_noise on x) to match training. Charge remains + # a clean prediction target rather than an interpolated state. When false, + # insertions enter at the model's clean prediction. + renoise_insertions: true + # Integrate the known categorical schedule hazard over every time interval + # instead of using its left-endpoint Euler approximation. + # With either method, the final interval always completes pending discrete + # transitions using the learned edit probabilities (no extra model call). + exact_schedule_integration: true + del_schedule: ${data.interpolator.del_schedule} + ins_schedule: ${data.interpolator.ins_schedule} + sub_schedule: ${data.interpolator.sub_schedule} + sub_e_schedule: ${data.interpolator.sub_e_schedule} diff --git a/geom/explicit_h/runtime/configs/logging/wandb_logger.yaml b/geom/explicit_h/runtime/configs/logging/wandb_logger.yaml new file mode 100644 index 0000000000000000000000000000000000000000..162b88b38e78138334d36469b72bccb633924517 --- /dev/null +++ b/geom/explicit_h/runtime/configs/logging/wandb_logger.yaml @@ -0,0 +1,6 @@ +## Put your model args here +enabled: true +project: morph-${data.dataset_name} +name: ${oc.env:MORPH_RUN_ID,base} +save_dir: . +offline: false diff --git a/geom/explicit_h/runtime/configs/loss_weights/calibrated_geom.yaml b/geom/explicit_h/runtime/configs/loss_weights/calibrated_geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ddc9b38bc200adc1832e091277ad3aeb88e89a5d --- /dev/null +++ b/geom/explicit_h/runtime/configs/loss_weights/calibrated_geom.yaml @@ -0,0 +1,18 @@ +# @package model.module.loss_weights +# Balance-only (geometric-mean=1) calibration weights, scaled down by a global +# factor of 0.2 (1/5). At the original geometric-mean=1 scale the realized +# full-model grad-norm was ~5, far above gradient_clip_val=1.0, so clipping +# fired on every step. The 0.2 factor brings the realized norm to ~1.0 so the +# clip becomes a spike safety-valve again. Re-running calibration now applies +# this scaling automatically (see trainer.target_total_grad_norm). +c: 0.164 +do_del: 0.20 +do_sub_a: 0.30 +do_sub_e: 0.24 +ins_e: 0.21 +ins_e_ii: 0.22 +ins_state: 0.14 +ins_rate: 0.20 +sub_a_class: 0.15 +sub_e_class: 0.19 +x: 0.22 diff --git a/geom/explicit_h/runtime/configs/loss_weights/calibrated_geom_frozen.yaml b/geom/explicit_h/runtime/configs/loss_weights/calibrated_geom_frozen.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d0e9e3b6f7e1814dce089db7a5b9497f3e7fd860 --- /dev/null +++ b/geom/explicit_h/runtime/configs/loss_weights/calibrated_geom_frozen.yaml @@ -0,0 +1,25 @@ +# @package model.module.loss_weights +# Balance-only (geometric-mean = 1) loss weights — ratios only. The learning +# rate owns step size and gradient_clip_val stays a pure spike catch, so these +# are NOT scaled to a target grad-norm (unlike the older calibrated_geom.yaml, +# which baked in a ~0.2 global scale to hit the clip target). +# +# Source: frozen init-state calibration (trainer.calibrate_mode=loss, +# calibrate_freeze_model=true), geom / DiT / uncond, +# run 2026-06-07 (outputs/geom/uncond/cal_then_lr/2026-06-07_12-29-25/calibrate). +# Because the model is held at init, every probe batch is an unbiased snapshot of +# the initial per-term losses — so the three binary do_* losses come out equal +# (all BCE at p=0.5 = ln2), unlike the previous 2500-step *training* probe +# (calibrated_geom.yaml) which gave them different weights purely from differing +# training rates. See notebooks/lr_calibration_analysis.ipynb for the comparison. +c: 0.745146 +do_del: 1.730107 +do_sub_a: 1.730107 +do_sub_e: 1.730106 +ins_e: 0.867232 +ins_e_ii: 0.867232 +ins_state: 0.519379 +ins_rate: 1.128104 +sub_a_class: 0.522109 +sub_e_class: 0.867232 +x: 1.298792 diff --git a/geom/explicit_h/runtime/configs/loss_weights/tuned_weights.yaml b/geom/explicit_h/runtime/configs/loss_weights/tuned_weights.yaml new file mode 100644 index 0000000000000000000000000000000000000000..681de9e73d6cd6836226366549221edb368be363 --- /dev/null +++ b/geom/explicit_h/runtime/configs/loss_weights/tuned_weights.yaml @@ -0,0 +1,15 @@ +# @package model.module.loss_weights +# Canonical tuned weights for molecular training. Backbones can select this +# preset explicitly, and task-specific experiments may override the group. The +# package directive mounts the values under model.module.loss_weights. +do_sub_a: 0.2 +sub_a_class: 0.03 +do_sub_e: 0.25 +sub_e_class: 0.5 +do_del: 0.4 +ins_rate: 0.15 +ins_state: 0.01 +ins_e: 0.4 +x: 2.0 # was 1.0 +c: 0.5 +ins_e_ii: 0.03 diff --git a/geom/explicit_h/runtime/configs/loss_weights/uniform.yaml b/geom/explicit_h/runtime/configs/loss_weights/uniform.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fb279b4f7a159f8098ad5aa9b06059570949c270 --- /dev/null +++ b/geom/explicit_h/runtime/configs/loss_weights/uniform.yaml @@ -0,0 +1,15 @@ +# @package model.module.loss_weights +# Uniform 1.0 weight per loss component. The key set matches +# LightningModuleRates.LOSS_GROUPS exactly (the 11 actual loss terms +# emitted by shared_step). +do_sub_a: 1.0 +sub_a_class: 1.0 +do_sub_e: 1.0 +sub_e_class: 1.0 +do_del: 1.0 +ins_rate: 1.0 +ins_state: 1.0 +ins_e: 1.0 +ins_e_ii: 1.0 +x: 1.0 +c: 1.0 diff --git a/geom/explicit_h/runtime/configs/model/backbones/dit.yaml b/geom/explicit_h/runtime/configs/model/backbones/dit.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5393a940e3628f9d0b299eb543b8e619ad03adaf --- /dev/null +++ b/geom/explicit_h/runtime/configs/model/backbones/dit.yaml @@ -0,0 +1,123 @@ +## Molecular DiT: the repository's first-class generative backbone. +## +## This intentionally keeps charge out of the state and adds neither an +## equivariant lane nor an evolving dense pair state. It retains the atom-count +## condition: time plus current graph size disambiguates otherwise identical +## local states and is especially useful while insertion/deletion is active. +defaults: + - /optimizer: muon_aux_adam + - /loss_weights: tuned_weights + - _self_ + +rotate: true +n_augmentations: 1 + +integrator: ${integrator.integrator} + +beta_sched: + _target_: torch.distributions.Beta + concentration1: 2.0 + concentration0: 1.0 + +linear_sched: + _target_: torch.distributions.Uniform + low: 0.0 + high: 1.0 + +sample_time_strategy: linear + +module: + _target_: morph.model.lightning.base.LightningModuleRates + insertion_head_mode: ${insertion_head.mode} + ins_noise_scale: ${data.ins_noise_scale} + ema_decay: 0.999 + ema_warmup_steps: 5000 + use_ema_for_eval: true + time_dist: ${if:${eq:${model.sample_time_strategy}, log}, ${model.beta_sched}, ${model.linear_sched}} + integrator: ${model.integrator} + vocab: ${data.vocab} + use_learnable_loss_weights: false + use_time_weights: false + + model: + _target_: morph.model.backbones.dit.DiTBackboneWithHeads + _recursive_: false + + atom_type_embedding_args: ${embeddings.atom_type_embedding} + edge_type_embedding_args: ${embeddings.edge_type_embedding} + time_embedding_args: ${embeddings.time_embedding} + node_count_embedding_args: ${embeddings.node_count_embedding} + # Sum the existing incident edge embeddings into each atom's input, excluding + # NO_BOND, through a zero-initialized projection. Uses the existing heads. + incident_bond_embedding: false + backbone_model_args: ${model.backbone} + heads_args: ${heads.heads} + insertion_head_args: ${insertion_head.model} + ins_edge_head_args: ${heads.ins_edge_head} + +head_node_dim: ${model.backbone.out_node_nf} +head_edge_dim: ${model.backbone.out_edge_nf} + +# Shared adapters avoid seven separate full-width MLPs. At the >150M preset, +# node/insertion adapters widen while pair readouts remain compact. +head_node_adapter_dim: 256 +head_edge_adapter_dim: 256 +head_insertion_adapter_dim: 256 +head_insertion_edge_dim: 256 +head_independent_adapters: true + +# Categorical biases are replaced at module construction with exact dataset +# marginals. There is no dataset-level transition-frequency statistic for the +# binary/rate heads, so their defaults deliberately preserve the old neutral +# predictions instead of inventing a corpus-independent edit prior. +head_initialization: + output_std: 1.0e-3 + coordinate_output_std: 0.0 + do_del_probability: 0.5 + do_sub_a_probability: 0.5 + do_sub_e_probability: 0.5 + ins_rate: 0.6931471805599453 + +token_weighting: + type_loss_token_weights: uniform + weight_alpha: 0.4 + +# Optional low-rank adaptation of the attention and SwiGLU projections. The +# adapter residual is zero at initialization, so enabling this while loading a +# pretrained checkpoint preserves its outputs exactly. +lora: + enabled: false + rank: 8 + alpha: 8.0 + dropout: 0.0 + targets: [qkv, proj, mlp.gate_value, mlp.out_proj] + freeze_base: true + +backbone: + _target_: morph.model.backbones.dit.DiTBackbone + _recursive_: false + d_input: ${embeddings.atom_type_embedding.out_dim} + d_cond: ${oc.eval:"${embeddings.time_embedding.out_dim} + ${embeddings.node_count_embedding.out_dim}"} + d_model: 512 + out_node_nf: 256 + out_edge_nf: 256 + num_layers: 8 + nhead: 16 + mlp_ratio: 4.0 + ffn_hidden_dim: null + ffn_multiple_of: 64 + qk_norm: true + coordinate_output_init_std: ${model.head_initialization.coordinate_output_std} + center_coordinate_output: true + in_edge_nf: ${embeddings.edge_type_embedding.out_dim} + edge_pair_dim: 256 + pair_bias_hidden_dim: 32 + num_atom_types: ${len:${data.vocab.atom_tokens}} + lora: ${model.lora} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false diff --git a/geom/explicit_h/runtime/configs/model/finetune/lora_finetune.yaml b/geom/explicit_h/runtime/configs/model/finetune/lora_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ae37397e74c5720f8f1c93835bc67e71f6157b29 --- /dev/null +++ b/geom/explicit_h/runtime/configs/model/finetune/lora_finetune.yaml @@ -0,0 +1,13 @@ +# Parameter-efficient fine-tuning of the unconditional DiT. +defaults: + - /model/backbones/dit@_here_ + - _self_ + +lora: + enabled: true + +module: + _target_: morph.model.finetune.base.LightningModuleRatesFinetune + pretrained_ckpt: ${oc.env:MORPH_PRETRAINED_CKPT} + pretrained_prefer_ema: true + freeze_pretrained_backbone: true diff --git a/geom/explicit_h/runtime/configs/model/finetune/scaffold_finetune.yaml b/geom/explicit_h/runtime/configs/model/finetune/scaffold_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..02cb1b117c555e97a2f2e5296403900fd09871b3 --- /dev/null +++ b/geom/explicit_h/runtime/configs/model/finetune/scaffold_finetune.yaml @@ -0,0 +1,134 @@ +# DiT backbone augmented with Flamingo-style gated cross-attention for +# scaffold-decoration fine-tuning. +# +# Conditioning (all derived from mol_t.keep_mask): +# - node_cond : per-atom scaffold membership (always on) +# - edge_cond : per-edge scaffold class (always on) +# - cross-attn: scaffold subgraph encoded by a shared LightweightContextEncoder +# (OPTIONAL — toggle via backbone.use_cross_attn): +# * false → option 1: node+edge only +# * true → option 2: node+edge+cross-attn +# +# Inherits everything from configs/model/backbones/dit.yaml and overrides: +# - module._target_ → LightningModuleRatesScaffoldFinetune (loads a pretrained +# unconditional DiT checkpoint and adds scaffold loss/inference masking) +# - model._target_ → ScaffoldConditionalDiTBackboneWithHeads (auto-builds the +# node/edge cond and, when use_cross_attn, the scaffold cross-attn tokens) +# - backbone._target_ → ConditionalDiTBackbone (DiT + gated cross-attn blocks) +# +# Provides a ``pretrained_ckpt`` field for the lightning module to load. +defaults: + - /model/backbones/dit@_here_ + - _self_ + +module: + _target_: morph.model.finetune.scaffold.LightningModuleRatesScaffoldFinetune + # Path to the unconditional DiT checkpoint to load weights from. + # ConditionalDiT keys are auto-retargeted (backbone.* → backbone.dit.*). + pretrained_ckpt: ${oc.env:MORPH_PRETRAINED_CKPT} + pretrained_prefer_ema: true + # If true, dispatches to ``self.model.freeze_pretrained()`` — for the + # cross-attn scaffold model this freezes the entire clean lane + # (embedding, ``backbone.dit``, all heads). Only the gated cross-attn + # blocks and edge gate plus the scaffold encoder train. + freeze_pretrained_backbone: true + optimizer_config: + optimizer: + lr: 1e-3 + aux_optimizer: + lr: 1e-4 + # Whether to exclude scaffold atoms from sub / x losses. These atoms are + # frozen at inference, so training them adds no useful signal. + exclude_scaffold_from_sub_loss: true + exclude_scaffold_from_x_loss: true + # Pin kept coordinates during integration and restore the input coordinate + # frame on output. To train flowing scaffold geometry, set this and the x-loss + # exclusion above to false. + freeze_keep_positions: true + + # Override the model target to the scaffold-aware cross-attn wrapper. + model: + _target_: morph.model.finetune.scaffold.ScaffoldConditionalDiTBackboneWithHeads + _recursive_: false + + atom_type_embedding_args: ${embeddings.atom_type_embedding} + edge_type_embedding_args: ${embeddings.edge_type_embedding} + time_embedding_args: ${embeddings.time_embedding} + node_count_embedding_args: ${embeddings.node_count_embedding} + + backbone_model_args: ${model.backbone} + + heads_args: ${heads.heads} + insertion_head_args: ${insertion_head.model} + ins_edge_head_args: ${heads.ins_edge_head} + + # Cross-attention context encoder (option 2). Required because the backbone + # below has use_cross_attn: true. For option 1 (node+edge only), set + # backbone.use_cross_attn=false AND null this (the two must agree). + xattn_encoder_args: ${model.xattn_encoder} + # Geometric correspondence prior (query atom ↔ co-located scaffold token). + use_distance_bias: true + +# Cross-attention context encoder (option 2): encodes the scaffold subgraph of +# mol_t (extracted via filter_nodes) into context tokens. ctx_dim must match +# model.backbone.ctx_dim. +# Node/edge conditioning is NOT configured here — the wrapper builds those directly +# from backbone.d_model / backbone.in_edge_nf. Null this (and set +# backbone.use_cross_attn=false) for node+edge-only scaffold conditioning. +xattn_encoder: + _target_: morph.model.finetune.base.LightweightContextEncoder + num_atom_types: ${len:${data.vocab.atom_tokens}} + num_charges: ${len:${data.vocab.charge_tokens}} + atom_embed_dim: 64 + charge_embed_dim: 16 + pos_embed_dim: 32 + ctx_dim: ${model.backbone.ctx_dim} + num_attn_layers: 4 + nhead: 8 + mlp_ratio: 4.0 + dropout: 0.0 + # Edge-based pair bias on the encoder's self-attention (RBF distance + + # edge-type of the scaffold's own bonds) so tokens mix with geometry/topology + # awareness, like the DiT backbone. num_edge_types/edge_embed_dim only used here. + use_pair_bias: true + num_edge_types: ${len:${data.vocab.edge_tokens}} + edge_embed_dim: 32 + num_rbf: 32 + rbf_dmax: 5.0 + pair_bias_hidden_dim: 32 + +# Override the backbone to use ConditionalDiTBackbone. +backbone: + _target_: morph.model.backbones.conditional_dit.ConditionalDiTBackbone + _recursive_: false + d_input: ${embeddings.atom_type_embedding.out_dim} + d_cond: ${oc.eval:"${embeddings.time_embedding.out_dim} + ${embeddings.node_count_embedding.out_dim}"} + d_model: 512 + out_node_nf: 256 + out_edge_nf: 256 + num_layers: 8 + nhead: 16 + mlp_ratio: 4.0 + ffn_hidden_dim: null + ffn_multiple_of: 64 + qk_norm: true + coordinate_output_init_std: ${model.head_initialization.coordinate_output_std} + center_coordinate_output: true + in_edge_nf: ${embeddings.edge_type_embedding.out_dim} + edge_pair_dim: 256 + pair_bias_hidden_dim: 32 + num_atom_types: ${len:${data.vocab.atom_tokens}} + lora: ${model.lora} + ctx_dim: 128 + use_cross_attn: true # cross-attention (cond_tokens) port + # Per-node conditioning: scaffold membership (which atoms to keep). + use_node_cond: true + # Per-edge conditioning: the scaffold encoder emits edge_cond at in_edge_nf. + use_edge_cond: true + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false diff --git a/geom/explicit_h/runtime/configs/optimizer/muon_aux_adam.yaml b/geom/explicit_h/runtime/configs/optimizer/muon_aux_adam.yaml new file mode 100644 index 0000000000000000000000000000000000000000..94cf30139d2d0799df91519152dc7a18c6380355 --- /dev/null +++ b/geom/explicit_h/runtime/configs/optimizer/muon_aux_adam.yaml @@ -0,0 +1,39 @@ +# @package model.module.optimizer_config +# Muon + auxiliary AdamW preset for the molecular DiT. Per-run overrides +# go on ``model.module.optimizer_config``: +# model.module.optimizer_config.optimizer.lr=0.005 ... +implementation: torch + +optimizer: + # no _target_ -> triggers Muon path in configure_optimizers + # The RTX 4090 Euler template selects native-arithmetic CUDA graph replay. + execution: ${oc.env:MORPH_MUON_EXECUTION,native} + lr: 0.01 + momentum: 0.95 + weight_decay: 0.0 + nesterov: true + ns_steps: 5 + eps: 1e-7 + adjust_lr_fn: original + +aux_optimizer: + lr: 3e-4 + betas: [0.9, 0.95] + eps: 1e-10 + weight_decay: 0.0 + +# Lightning's two-optimizer path uses manual optimization, so clipping is +# configured here and applied once to the full model gradient. +gradient_clip_val: 1.0 +gradient_clip_algorithm: norm + +scheduler: + _target_: morph.utils.lr_schedulers.MultiGroupCosineWarmupLR + # configure_optimizers injects each native optimizer here. + warmup_steps: 2000 # Typically 2-5% of total_steps + total_steps: 200000 # The total number of training iterations + min_lr_fraction: 0.05 # Decays to 5% of each optimizer's peak LR + last_epoch: -1 + +interval: step +monitor: null diff --git a/geom/explicit_h/runtime/configs/resume_geom.yaml b/geom/explicit_h/runtime/configs/resume_geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..44cc3d3305a27fe9c414e3846954b6c765df91e0 --- /dev/null +++ b/geom/explicit_h/runtime/configs/resume_geom.yaml @@ -0,0 +1,417 @@ +representation: molecule +seed: 42 +heads: + heads: + _target_: morph.model.layers.heads.MultiHeadModule + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + heads_configs: + graph_aggregation: mean + node_heads: + atom_type_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.atom_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + charge_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.charge_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + ins_rate_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_rate: ${model.head_initialization.ins_rate} + do_del_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_del_probability} + do_sub_a_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_sub_a_probability} + edge_heads: + edge_type_head: + input_dim: ${model.head_edge_dim} + output_dim: ${len:${data.vocab.edge_tokens}} + adapter_dim: ${model.head_edge_adapter_dim} + group: state + do_sub_e_head: + input_dim: ${model.head_edge_dim} + output_dim: 1 + adapter_dim: ${model.head_edge_adapter_dim} + group: state + initial_probability: ${model.head_initialization.do_sub_e_probability} + ins_edge_head: + _target_: morph.model.layers.heads.InsertionEdgeHead + _recursive_: false + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_edge_dim} + output_init_std: ${model.head_initialization.output_std} + n_edge_types: ${len:${data.vocab.edge_tokens}} + n_atom_types: ${len:${data.vocab.atom_tokens}} + n_charge_types: ${len:${data.vocab.charge_tokens}} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false +insertion_head: + mode: unimodal + dimension: 3 + num_components: 1 + model: + _target_: morph.model.layers.heads.UnimodalInsertionHead + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_adapter_dim} + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + N_a: ${len:${data.vocab.atom_tokens}} + N_c: ${len:${data.vocab.charge_tokens}} +model: + module: + optimizer_config: + implementation: torch + optimizer: + execution: ${oc.env:MORPH_MUON_EXECUTION,native} + lr: 0.01 + momentum: 0.95 + weight_decay: 0.0 + nesterov: true + ns_steps: 5 + eps: 1.0e-07 + adjust_lr_fn: original + aux_optimizer: + lr: 0.0003 + betas: + - 0.9 + - 0.95 + eps: 1.0e-10 + weight_decay: 0.0 + gradient_clip_val: 1.0 + gradient_clip_algorithm: norm + scheduler: + _target_: morph.utils.lr_schedulers.MultiGroupCosineWarmupLR + warmup_steps: 2000 + total_steps: 200000 + min_lr_fraction: 0.05 + last_epoch: -1 + interval: step + monitor: null + loss_weights: + do_sub_a: 0.2 + sub_a_class: 0.03 + do_sub_e: 0.25 + sub_e_class: 0.5 + do_del: 0.4 + ins_rate: 0.15 + ins_state: 0.01 + ins_e: 0.4 + x: 2.0 + c: 0.5 + ins_e_ii: 0.03 + _target_: morph.model.lightning.base.LightningModuleRates + insertion_head_mode: ${insertion_head.mode} + ins_noise_scale: ${data.ins_noise_scale} + ema_decay: 0.999 + ema_warmup_steps: 5000 + use_ema_for_eval: true + time_dist: ${if:${eq:${model.sample_time_strategy}, log}, ${model.beta_sched}, + ${model.linear_sched}} + integrator: ${model.integrator} + vocab: ${data.vocab} + use_learnable_loss_weights: false + use_time_weights: false + model: + _target_: morph.model.backbones.dit.DiTBackboneWithHeads + _recursive_: false + atom_type_embedding_args: ${embeddings.atom_type_embedding} + edge_type_embedding_args: ${embeddings.edge_type_embedding} + time_embedding_args: ${embeddings.time_embedding} + node_count_embedding_args: ${embeddings.node_count_embedding} + incident_bond_embedding: false + backbone_model_args: ${model.backbone} + heads_args: ${heads.heads} + insertion_head_args: ${insertion_head.model} + ins_edge_head_args: ${heads.ins_edge_head} + rotate: true + n_augmentations: 1 + integrator: ${integrator.integrator} + beta_sched: + _target_: torch.distributions.Beta + concentration1: 2.0 + concentration0: 1.0 + linear_sched: + _target_: torch.distributions.Uniform + low: 0.0 + high: 1.0 + sample_time_strategy: linear + head_node_dim: ${model.backbone.out_node_nf} + head_edge_dim: ${model.backbone.out_edge_nf} + head_node_adapter_dim: 256 + head_edge_adapter_dim: 256 + head_insertion_adapter_dim: 256 + head_insertion_edge_dim: 256 + head_independent_adapters: true + head_initialization: + output_std: 0.001 + coordinate_output_std: 0.0 + do_del_probability: 0.5 + do_sub_a_probability: 0.5 + do_sub_e_probability: 0.5 + ins_rate: 0.6931471805599453 + token_weighting: + type_loss_token_weights: uniform + weight_alpha: 0.4 + lora: + enabled: false + rank: 8 + alpha: 8.0 + dropout: 0.0 + targets: + - qkv + - proj + - mlp.gate_value + - mlp.out_proj + freeze_base: true + backbone: + _target_: morph.model.backbones.dit.DiTBackbone + _recursive_: false + d_input: ${embeddings.atom_type_embedding.out_dim} + d_cond: ${oc.eval:"${embeddings.time_embedding.out_dim} + ${embeddings.node_count_embedding.out_dim}"} + d_model: 512 + out_node_nf: 256 + out_edge_nf: 256 + num_layers: 8 + nhead: 16 + mlp_ratio: 4.0 + ffn_hidden_dim: null + ffn_multiple_of: 64 + qk_norm: true + coordinate_output_init_std: ${model.head_initialization.coordinate_output_std} + center_coordinate_output: true + in_edge_nf: ${embeddings.edge_type_embedding.out_dim} + edge_pair_dim: 256 + pair_bias_hidden_dim: 32 + num_atom_types: ${len:${data.vocab.atom_tokens}} + lora: ${model.lora} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false +schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 0.8 +trainer: + trainer: + deterministic: warn + devices: 2 + accelerator: auto + strategy: ddp_find_unused_parameters_false + precision: bf16-mixed + max_epochs: -1 + accumulate_grad_batches: 1 + num_sanity_val_steps: 0 + gradient_clip_val: null + gradient_clip_algorithm: norm + check_val_every_n_epoch: 1 + num_val_samples: 4096 + limit_val_batches: null + limit_predict_batches: 4 + max_time: + hours: 47 + minutes: 15 + do_train: true + do_validate: false + do_predict: false + checkpoint_path: /cluster/project/krause/frankem/morph-chem/outputs/geom_resume_e48_20261008/resume_epoch_048.ckpt + final_checkpoint_path: final.ckpt +callbacks: + monitor_metric: ${if:${oc.eval:"'${representation}' in ('geometric_graph', 'molecule')"},val/validity,val/batch/pair_dist_l1} + monitor_metric_mode: ${if:${oc.eval:"'${representation}' in ('geometric_graph', + 'molecule')"},max,min} + model_checkpoints: + save_top_k: 1 + save_last: false + verbose: false + every_n_epochs: 5 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link + verbose: false + save_on_train_epoch_end: true +logging: + enabled: true + project: morph-${data.dataset_name} + name: ${oc.env:MORPH_RUN_ID} + save_dir: . + offline: false +integrator: + integrator: + _target_: morph.flow_matching.integration.RateIntegrator + vocab: ${data.vocab} + distributions: None + insertion_head_mode: ${insertion_head.mode} + insertion_dimension: ${insertion_head.dimension} + insertion_num_components: ${insertion_head.num_components} + time_strategy: log + time_beta_alpha: 2.0 + time_beta_beta: 1.0 + num_integration_steps: 100 + ins_noise_scale: ${data.ins_noise_scale} + renoise_insertions: true + exact_schedule_integration: true + del_schedule: ${data.interpolator.del_schedule} + ins_schedule: ${data.interpolator.ins_schedule} + sub_schedule: ${data.interpolator.sub_schedule} + sub_e_schedule: ${data.interpolator.sub_e_schedule} +data: + root_path: /cluster/project/krause/frankem/morph-chem/data + preprocess_workers: 10 + preprocess_chunk_size: 128 + statistics_workers: ${data.preprocess_workers} + hydrogen_mode: explicit + cache_name: ${data.dataset_name}${if:${eq:${data.hydrogen_mode},implicit},_implicit_h,''} + source_dir: ${oc.env:GEOM_SOURCE_DIR,null} + max_atoms: 72 + allowed_atom_symbols: + - H + - C + - 'N' + - O + - S + - F + - Cl + - Br + - P + - I + n_atoms_strategy: uniform + uniform_n_atoms_padding: 5 + optimal_transport: equivariant + ins_noise_scale: 0.65 + move_noise_scale: 0.5 + allow_charged: true + preprocessing: + _target_: morph.dataset.core.preprocessing.Preprocessing + _recursive_: false + root: /cluster/project/krause/frankem/morph/data/geom + num_workers: ${data.statistics_workers} + train_dataset: ${data.datamodule.datasets.train} + atom_tokens_path: ${data.preprocessing.root}/processed/atom_tokens.txt + edge_tokens_path: ${data.preprocessing.root}/processed/edge_tokens.txt + charge_tokens_path: ${data.preprocessing.root}/processed/charge_tokens.txt + distributions_path: ${data.preprocessing.root}/processed/distributions.pt + vocab: null + interpolator: + _target_: morph.flow_matching.interpolation.Interpolator + distributions: null + optimal_transport: ${data.optimal_transport} + ins_noise_scale: ${data.ins_noise_scale} + move_noise_scale: ${data.move_noise_scale} + atom_type_noise_prob: 0.0 + pre_align_iters: 2 + c_move: 1.0 + c_sub: 0.0 + c_ins: 6.125 + c_del: 6.125 + del_schedule: ${schedule} + ins_schedule: ${schedule} + sub_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + sub_e_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + datamodule: + _target_: morph.dataset.core.lightning_datamodule.LightningDataModule + vocab: ${data.vocab} + distributions: null + representation: ${representation} + datasets: + train: + _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + val: + - _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + test: + - _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + interpolator: ${data.interpolator} + rotate: ${model.rotate} + n_augmentations: ${model.n_augmentations} + num_workers: + train: 20 + val: 0 + test: 0 + batch_size: + train: 320 + val: 32 + test: 32 + n_atoms_strategy: ${data.n_atoms_strategy} + uniform_n_atoms_padding: ${data.uniform_n_atoms_padding} + time_dist: ${model.module.time_dist} + train_prefetch_factor: 1 + dataset_name: geom + dataset_target: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset +embeddings: + atom_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.atom_tokens}} + embedding_dim: 64 + out_dim: 128 + edge_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.edge_tokens}} + embedding_dim: 64 + out_dim: 128 + time_embedding: + _target_: morph.model.layers.embedding.TimeEmbedding + embedding_dim: 128 + out_dim: 128 + node_count_embedding: + _target_: morph.model.layers.embedding.ExtrapolatableCountEmbedding + embedding_dim: 64 + out_dim: 128 + min_period: 128.0 + max_period: 1024.0 + max_count: 96.0 +hydra: + run: + dir: ${oc.env:MORPH_RUN_ROOT}/geom/geom/${now:%Y-%m-%d}/${oc.env:MORPH_RUN_ID} diff --git a/geom/explicit_h/runtime/configs/schedule/beta.yaml b/geom/explicit_h/runtime/configs/schedule/beta.yaml new file mode 100644 index 0000000000000000000000000000000000000000..282746a14cd54cfa9b5560340f90cde6d8762f2f --- /dev/null +++ b/geom/explicit_h/runtime/configs/schedule/beta.yaml @@ -0,0 +1,3 @@ +_target_: morph.flow_matching.schedules.BetaSchedule +k_alpha: 1.0 +k_beta: 1.5 diff --git a/geom/explicit_h/runtime/configs/schedule/cubic.yaml b/geom/explicit_h/runtime/configs/schedule/cubic.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fd5cf2d9e5dcb3213e5f25acddbf86209620219a --- /dev/null +++ b/geom/explicit_h/runtime/configs/schedule/cubic.yaml @@ -0,0 +1 @@ +_target_: morph.flow_matching.schedules.CubicSchedule diff --git a/geom/explicit_h/runtime/configs/schedule/fast_power.yaml b/geom/explicit_h/runtime/configs/schedule/fast_power.yaml new file mode 100644 index 0000000000000000000000000000000000000000..21757bb0f0037500dc106d10f3a01b1fd4655e22 --- /dev/null +++ b/geom/explicit_h/runtime/configs/schedule/fast_power.yaml @@ -0,0 +1,3 @@ +# Default kappa schedule: FastPowerSchedule with beta=2.5 +_target_: morph.flow_matching.schedules.FastPowerSchedule +beta: 2.0 diff --git a/geom/explicit_h/runtime/configs/schedule/linear.yaml b/geom/explicit_h/runtime/configs/schedule/linear.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f63c2e226275c32dc406db96f21c3aa488054a80 --- /dev/null +++ b/geom/explicit_h/runtime/configs/schedule/linear.yaml @@ -0,0 +1 @@ +_target_: morph.flow_matching.schedules.LinearSchedule diff --git a/geom/explicit_h/runtime/configs/schedule/smoothstep.yaml b/geom/explicit_h/runtime/configs/schedule/smoothstep.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ed29d5013930dafd40a1090a40d08b638976e3ba --- /dev/null +++ b/geom/explicit_h/runtime/configs/schedule/smoothstep.yaml @@ -0,0 +1,2 @@ +_target_: morph.flow_matching.schedules.SmoothstepSchedule +shift: 0.8 diff --git a/geom/explicit_h/runtime/configs/trainer/trainer.yaml b/geom/explicit_h/runtime/configs/trainer/trainer.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a894707dd858be394921733728c16e523ed8810b --- /dev/null +++ b/geom/explicit_h/runtime/configs/trainer/trainer.yaml @@ -0,0 +1,37 @@ +## Put your trainer args here +trainer: + deterministic: warn + devices: auto + accelerator: auto + #precision: 32 + strategy: ddp_find_unused_parameters_false + precision: "bf16-mixed" + max_epochs: 3000 + # Effective global batch = devices * batch_size.train * accumulation. The + # native optimizer mixin handles this because it steps Muon + AdamW. + accumulate_grad_batches: 1 + # A validation batch performs full generative integration, so Lightning's + # pre-training sanity pass is expensive and does not exercise optimization. + num_sanity_val_steps: 0 + # Muon + AdamW use Lightning manual optimization; clipping is configured in + # configs/optimizer/muon_aux_adam.yaml and applied to both branches together. + gradient_clip_val: null + gradient_clip_algorithm: norm + + # Validation performs full generative integration. The established QM9 + # training recipes use this cadence so sampling does not dominate fitting. + check_val_every_n_epoch: 50 + # profiler: simple + + # Fixed GLOBAL validation population. run.py converts this to a per-rank + # limit, giving the same 4096 molecules on 1, 2 or 4 GPUs. + num_val_samples: 4096 + # Explicit profiling escape hatch; a non-null value takes precedence over + # num_val_samples and is interpreted by Lightning per rank. + limit_val_batches: null + limit_predict_batches: 4 + +do_train: true +do_validate: true +do_predict: true +checkpoint_path: null diff --git a/geom/explicit_h/runtime/pyproject.toml b/geom/explicit_h/runtime/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..2ae9a1a1a7e8d23f6e2a25375297d821167faaf4 --- /dev/null +++ b/geom/explicit_h/runtime/pyproject.toml @@ -0,0 +1,137 @@ +[project] +name = "morph" +version = "0.1.0" +description = "Flow-matching molecular generation and scaffold decoration with a molecular DiT" +readme = "README.md" +keywords = ["molecular-generation", "flow-matching", "scaffold-decoration"] +requires-python = ">=3.11" +dependencies = [ + "click>=8.3.1", + "hydra-core>=1.3.2", + "huggingface-hub>=1.0,<2", + "lightning>=2.5.5", + "omegaconf>=2.3.0", + "numpy>=2.0", + "pandas>=2.3.3", + "rdkit>=2025.9.1", + "scipy>=1.15.0", + # Pre-release allowed: the NVIDIA 25.10 container ships a CUDA-enabled + # 2.9.0a0 build. Preserve that container build instead of replacing it with + # a mismatched PyPI Torch/torchvision pair. + "torch>=2.9.0a0, <2.9.1", + "torch-geometric>=2.7.0", + "torchmetrics>=1.8.0", + "wandb>=0.22.2", + "posebusters>=0.6.0", + "matplotlib>=3.10.8", + "lmdb>=2.2.0", + "py3dmol>=2.5.4", + "ipykernel>=7.2.0", + "ase>=3.28.0", +] + +[dependency-groups] +dev = ["pytest>=9.1.1", "ruff>=0.16.5"] + +[project.optional-dependencies] +flash-attn = ["flash-attn>=2.8.3, <2.8.4"] + +[project.scripts] +morph = "morph.cli:cli" + +[tool.uv.extra-build-dependencies] +flash-attn = [{ requirement = "torch", match-runtime = true }] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/morph"] + +[tool.hatch.build.targets.wheel.force-include] +"configs" = "morph/configs" + +[tool.ruff] +# Allow lines to be as long as 88. +line-length = 88 +# Keep attributed third-party implementations close to their upstream sources. +extend-exclude = ["src/morph/_vendor"] + +[tool.ruff.format] +docstring-code-format = true + +# Correctness, readability, and public API documentation. +[tool.ruff.lint] +# consider selecting "ISC", for implicit string concat +# consider selecting "T201", for print in production code +select = [ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "D", # pydocstyle + "E", # pycodestyle error + "E501", # line too long + "EXE", # flake8-executable + "F", # pyflakes + "FLY", # flynt + "I", # isort + "ICN", # flake8-import-conventions + "PD", # pandas-vet + "PERF", # perflint + "PIE", # flake8-pie + "PL", # pylint + "PT", # flake8-pytest-style + "PYI", # flakes8-pyi + "Q", # flake8-quotes + "RSE", # flake8-raise + "RUF", # Ruff-specific rules + "SIM", # flake8-simplify + "SLOT", # flakes8-slot + "TCH", # flake8-type-checking + "TID", # flake8-tidy-imports + "UP", # pyupgrade + "W", # pycodestyle warning + "W505", # line too long + "YTT", # flake8-2020 +] +# also consider ignoring "C901", for high mccabe function complexity +# also consider ignoring "ERA001", for found commented out code +ignore = [ + "EXE002", # shebang is not a python interpreter + "RUF012", # missing classvar type + "RUF013", # Optional prohibited by PEP484 + "B006", # Do not use mutable data structures for argument defaults + "ANN003", # missing type annotations for kwargs + "B019", # Use of functools.lru_cache on methods can lead to memory leaks + "C408", # unnecessary-collection-call + "COM812", # trailing comma missing + "D205", # 1 blank line required between summary line and description + "D415", # first line of docstring must end with punctuation + "DTZ005", # use of datetime.now() without timezone + "E731", # do not assign a lambda expression, use a def + "EM", # flake8-errmsg + "FBT001", # Boolean positional argument in function + "FBT002", # Boolean keyword argument in function + "NPY002", # TODO replace legacy np.random.seed + "PLR", # pylint refactor + "PLW2901", # Outer for loop variable overwritten by inner assignment target + "PT006", # pytest-parametrize-names-wrong-type + "PT011", # pytest-raises-too-broad + "PT013", # pytest-incorrect-pytest-import + "PT019", # pytest-fixture-param-without-value + "PTH", # prefer Path to os.path + "S301", # pickle can be unsafe + "S310", # url open functions can be unsafe + "TRY003", # long exception messages not defined in the exception class itself + "UP015", # unnecessary "r" in open call + "D107", # docstring for __init__ + "D212", # remove whitespace after opening quotes + "D202", # no blank lines after docstring + "D301", + "D100", + "D103", +] +pydocstyle.convention = "google" + +[env] +PROJECT_ROOT = "." diff --git a/geom/explicit_h/runtime/requirements-tested.txt b/geom/explicit_h/runtime/requirements-tested.txt new file mode 100644 index 0000000000000000000000000000000000000000..4cc1360cd620aa666cd121f9436f05787d607ede --- /dev/null +++ b/geom/explicit_h/runtime/requirements-tested.txt @@ -0,0 +1,10 @@ +torch==2.9.0 +numpy==2.4.2 +rdkit==2025.9.6 +pytorch-lightning==2.6.1 +torch-geometric==2.7.0 +hydra-core==1.3.2 +omegaconf==2.3.0 +huggingface-hub==1.33.0 +scipy==1.17.1 +wandb==0.25.0 diff --git a/geom/explicit_h/runtime/src/morph/__init__.py b/geom/explicit_h/runtime/src/morph/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..320a78e02ba300f6433f6b0131e073b0517f6353 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/__init__.py @@ -0,0 +1 @@ +"""morph — flow-matching generative modeling for molecules and materials.""" diff --git a/geom/explicit_h/runtime/src/morph/__main__.py b/geom/explicit_h/runtime/src/morph/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..bfc34c1fb7a9b7c2a1f0ec010fc40eb07495c7b1 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/__main__.py @@ -0,0 +1,6 @@ +"""Enable ``python -m morph ...`` (works without installing the console script).""" + +from morph.cli import cli + +if __name__ == "__main__": + cli() diff --git a/geom/explicit_h/runtime/src/morph/_vendor/__init__.py b/geom/explicit_h/runtime/src/morph/_vendor/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..24bba35366c470be8093d2f9f133cb384a991740 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/_vendor/__init__.py @@ -0,0 +1 @@ +"""Third-party reference code kept separate from Morph's public API.""" diff --git a/geom/explicit_h/runtime/src/morph/_vendor/geom_drugs_preprocessing.py b/geom/explicit_h/runtime/src/morph/_vendor/geom_drugs_preprocessing.py new file mode 100644 index 0000000000000000000000000000000000000000..b6037a4822dc690f1913505d661da1e11a488d96 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/_vendor/geom_drugs_preprocessing.py @@ -0,0 +1,296 @@ +""" +Vendored from the repository accompanying the following paper. + +@article{nikitin2025geom, + title={GEOM-Drugs Revisited: Toward More Chemically Accurate Benchmarks for 3D Molecule Generation}, + author={Nikitin, Filipp and Dunn, Ian and Koes, David Ryan and Isayev, Olexandr}, + journal={arXiv preprint arXiv:2505.00169}, + year={2025} +} + +GEOM-Drugs Dataset Processing and Validation Module + +This module implements the complete preprocessing pipeline for the GEOM-Drugs dataset, +including molecular sanitization, topology validation, and valency analysis. It addresses +chemical accuracy issues in the original dataset by applying rigorous validation criteria. + +Key Processing Steps: +1. Molecular sanitization using RDKit with full kekulization +2. Topology validation using distance-based bond length checking +3. Fragment filtering to remove disconnected molecules +4. Valency dictionary construction for stability analysis + +Topology Validation Strategy: +The module uses covalent radii and tolerance-based distance checking to validate +that molecular geometries are chemically reasonable. This catches issues like: +- Unrealistic bond lengths that would indicate poor conformer generation +- Overlapping atoms or impossible geometries +- Inconsistent connectivity across conformers + +Distance Tolerance Logic: +- Uses element-specific covalent radii as reference distances +- Applies 40% tolerance by default (configurable) +- Validates all conformers must have consistent topology +- Removes molecules with any invalid conformers +""" + +import json +import os +import pickle +from pathlib import Path + +import numpy as np +from rdkit import Chem +from rdkit.Chem import SanitizeFlags + +# Covalent radii dictionary for topology validation +# Values in Angstroms, used to determine expected bond lengths +covalent_radii = { + 1: 0.31, # Hydrogen + 6: 0.76, # Carbon + 7: 0.71, # Nitrogen + 8: 0.66, # Oxygen + 9: 0.57, # Fluorine + 15: 1.07, # Phosphorus + 16: 1.05, # Sulfur + 17: 1.02, # Chlorine + 35: 1.20, # Bromine + 53: 1.39 # Iodine +} + + +def get_reference_distance_matrix(adjacency_matrix, numbers): + """ + Calculate expected bond lengths based on covalent radii. + + """ + adjacency_mask = (adjacency_matrix > 0).astype(int) + # Get covalent radii for each atom, default to 1.5 Å for unknown elements + rads = np.array([covalent_radii.get(i, 1.5) for i in numbers]) + # Sum of covalent radii gives expected bond length + return rads[:, np.newaxis] + rads[np.newaxis, :] + + +def calculate_distance_map(coordinates): + # Broadcasting to compute all pairwise distances efficiently + diff = coordinates[:, :, np.newaxis, :] - coordinates[:, np.newaxis, :, :] + return np.linalg.norm(diff, axis=-1) + + +def check_topology(adjacency_matrix, numbers, coordinates, tolerance=0.4): + """ + Validate molecular topology using distance-based bond length checking. + + Validation Logic: + 1. Calculate expected bond lengths from covalent radii + 2. Measure actual bond lengths in conformer geometries + 3. Check if actual lengths are within tolerance of expected values + 4. Require ALL bonded pairs in ALL conformers to pass validation + + Args: + adjacency_matrix (np.ndarray): Molecular connectivity [n_atoms, n_atoms] + numbers (np.ndarray): Atomic numbers [n_atoms] + coordinates (np.ndarray): Conformer coordinates [n_conformers, n_atoms, 3] + tolerance (float): Relative tolerance for bond length validation (default: 0.4 = 40%) + + Returns: + np.ndarray: Boolean array indicating which conformers pass validation + """ + # Create mask for bonded atom pairs only + adjacency_mask = (adjacency_matrix > 0).astype(int) + + # Get expected distances and mask to bonded pairs only + ref_dist = get_reference_distance_matrix(adjacency_matrix, numbers) * adjacency_mask + + # Calculate actual distances and mask to bonded pairs only + data_dist = calculate_distance_map(coordinates) * adjacency_mask + + # Check if actual distances are within tolerance of expected distances + # tolerance is relative: actual_dist <= expected_dist * (1 + tolerance) + diffs = np.abs(data_dist - ref_dist[np.newaxis, :, :]) <= ( + ref_dist[np.newaxis, :, :] * tolerance) + + # All bonded pairs in all conformers must pass validation + return diffs.all(axis=(1, 2)) + + +def process_molecule(mol): + """ + Sanitize and kekulize a molecular structure with strict validation. + + Sanitization Steps Applied: + - SANITIZE_ALL: Complete RDKit sanitization pipeline + - Kekulization with clearAromaticFlags=True: Ensures proper aromatic/Kekulé form + + Fragment Filtering: + - Rejects molecules with multiple disconnected fragments + - Ensures analysis focuses on single molecular entities + + Args: + mol (rdkit.Chem.Mol): Input RDKit molecule object + + Returns: + rdkit.Chem.Mol or None: Sanitized molecule or None if processing fails + """ + try: + # Create a copy to avoid modifying the original + mol = Chem.Mol(mol) + + # Apply full RDKit sanitization pipeline + Chem.SanitizeMol(mol, sanitizeOps=SanitizeFlags.SANITIZE_ALL) + + # Kekulize with aromatic flag clearing for consistent valency analysis + # This ensures aromatic systems are properly handled in stability calculations + Chem.Kekulize(mol, clearAromaticFlags=True) + + except Exception: + # Any sanitization failure results in molecule rejection + return None + + # Reject molecules with multiple fragments (disconnected components) + if len(Chem.GetMolFrags(mol)) > 1: + return None + + return mol + + +def validate_topology(mol): + """ + Validate that all conformers of a molecule have reasonable geometries. + """ + # Extract molecular connectivity + adjacency_matrix = Chem.GetAdjacencyMatrix(mol) + + # Get atomic numbers for covalent radius calculations + numbers = np.array([atom.GetAtomicNum() for atom in mol.GetAtoms()]) + + # Extract conformer coordinates + conformers = mol.GetConformers() + if not conformers: + return False # No conformers = invalid + + coordinates = np.array([conf.GetPositions() for conf in conformers]) + + # Apply topology validation to all conformers + return check_topology(adjacency_matrix, numbers, coordinates).all() + + +def process_geom_drugs(input_folder, output_folder): + """ + Complete processing pipeline for GEOM-Drugs dataset. + + Processing Pipeline: + 1. Load raw pickle files from MiDi GEOM-Drugs splits + 2. Validate SMILES strings can be parsed by RDKit + 3. Apply molecular sanitization and kekulization + 4. Perform topology validation on all conformers + 5. Filter out molecules with invalid geometries or connectivity + 6. Build comprehensive valency dictionary for stability analysis + 7. Save cleaned data and analysis results + + Args: + input_folder (str): Path to directory containing raw GEOM-Drugs pickle files + output_folder (str): Path to save processed dataset and analysis files + + Output Files: + - {train,val,test}_data.pickle: Cleaned molecular datasets + - valency_dict.json: Comprehensive valency lookup table + + + """ + os.makedirs(output_folder, exist_ok=True) + valency_dict = {} + + # Process each data split independently + for split in ["test_data.pickle", "train_data.pickle", "val_data.pickle"]: + input_path = Path(input_folder) / split + output_path = Path(output_folder) / split + + if not input_path.exists(): + print(f"Skipping {input_path}, file not found.") + continue + + # Load raw dataset + with open(input_path, "rb") as f: + data = pickle.load(f) + + # Initialize statistics tracking + initial_size = len(data) + conformer_count = 0 + initial_conformer_count = 0 + conformer_counts = [] + + # Process molecules with in-place filtering + i = 0 + while i < len(data): + smiles, mols = data[i] + + # Validate SMILES can be parsed (sanity check) + reference_mol = Chem.MolFromSmiles(smiles) + if reference_mol is None: + del data[i] + continue + + initial_conformer_count += len(mols) + + # Apply molecular sanitization to all conformers + sanitized_mols = [process_molecule(mol) for mol in mols] + sanitized_mols = [mol for mol in sanitized_mols if mol is not None] + + # Require at least one valid conformer and all must pass topology validation + if not sanitized_mols or not all(validate_topology(mol) for mol in sanitized_mols): + del data[i] + continue + + # Build valency dictionary from validated molecules + # This creates an empirical valency table from the cleaned dataset + for mol in sanitized_mols: + for atom in mol.GetAtoms(): + element = atom.GetSymbol() + charge = str(atom.GetFormalCharge()) + valency = atom.GetExplicitValence() + valency_dict.setdefault(element, {}).setdefault(charge, []) + if valency not in valency_dict[element][charge]: + valency_dict[element][charge].append(valency) + + # Update statistics and store cleaned data + conformer_counts.append(len(sanitized_mols)) + conformer_count += len(sanitized_mols) + data[i] = (smiles, sanitized_mols) + i += 1 + + # Calculate and report processing statistics + final_size = len(data) + removed_molecules = initial_size - final_size + dropped_conformers = initial_conformer_count - sum(conformer_counts) + + print(f"\nProcessed {split}:") + print(f" Molecules saved: {final_size}") + print(f" Molecules removed: {removed_molecules} ({(removed_molecules / initial_size * 100):.2f}%)") + print(f" Kept conformers: {sum(conformer_counts)}") + print(f" Dropped conformers: {dropped_conformers} ({(dropped_conformers / initial_conformer_count * 100):.2f}%)") + + if conformer_counts: + print(f" Conformer count per molecule: min={min(conformer_counts)}, max={max(conformer_counts)}, mean={np.mean(conformer_counts):.2f}") + + # Save cleaned dataset + with open(output_path, "wb") as f: + pickle.dump(data, f) + + # Save comprehensive valency dictionary for stability analysis + valency_output = Path(output_folder) / "valency_dict.json" + with open(valency_output, "w") as f: + json.dump(valency_dict, f, indent=4) + + print(f"\nValency dictionary saved to {valency_output}") + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="Process GEOM-Drugs dataset.") + parser.add_argument("--input_folder", type=str, required=True, help="Path to midi_split folder.") + parser.add_argument("--output_folder", type=str, required=True, help="Path to save filtered results.") + args = parser.parse_args() + + process_geom_drugs(args.input_folder, args.output_folder) diff --git a/geom/explicit_h/runtime/src/morph/_vendor/muon.py b/geom/explicit_h/runtime/src/morph/_vendor/muon.py new file mode 100644 index 0000000000000000000000000000000000000000..92e596caf3d6194a5829602568048ea0680f6893 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/_vendor/muon.py @@ -0,0 +1,396 @@ +"""Vendored legacy Muon implementation retained for comparison benchmarks.""" + +import os + +import torch +import torch.distributed as dist + +# Newton-Schulz iteration count for Muon's orthogonalization. Batch-independent +# cost paid every optimizer step (the dominant fixed per-step overhead in a +# many-small-steps regime). Default 5; lower (3, even 2) trades orthogonalization +# accuracy for cheaper steps -- validate training quality before adopting. +_DEFAULT_NS_STEPS = int(os.environ.get("MORPH_MUON_NS_STEPS", "5")) + + +def _shape_buckets(params): + """Group parameters so each distributed all-gather has uniform shapes.""" + buckets = {} + for param in params: + key = (tuple(param.shape), param.dtype, param.device) + buckets.setdefault(key, []).append(param) + return buckets.values() + + +def zeropower_via_newtonschulz5(G, steps: int): + """ + Newton-Schulz iteration to compute the zeroth power / orthogonalization of G. We opt to use a + quintic iteration whose coefficients are selected to maximize the slope at zero. For the purpose + of minimizing steps, it turns out to be empirically effective to keep increasing the slope at + zero even beyond the point where the iteration no longer converges all the way to one everywhere + on the interval. This iteration therefore does not produce UV^T but rather something like US'V^T + where S' is diagonal with S_{ii}' ~ Uniform(0.5, 1.5), which turns out not to hurt model + performance at all relative to UV^T, where USV^T = G is the SVD. + """ + assert ( + G.ndim >= 2 + ) # batched Muon implementation by @scottjmaddox, and put into practice in the record by @YouJiacheng + a, b, c = (3.4445, -4.7750, 2.0315) + X = G.bfloat16() + if G.size(-2) > G.size(-1): + X = X.mT + + # Ensure spectral norm is at most 1 + X = X / (X.norm(dim=(-2, -1), keepdim=True) + 1e-7) + # Perform the NS iterations + for _ in range(steps): + A = X @ X.mT + B = ( + b * A + c * A @ A + ) # quintic computation strategy adapted from suggestion by @jxbz, @leloykun, and @YouJiacheng + X = a * X + B @ X + + if G.size(-2) > G.size(-1): + X = X.mT + return X + + +def muon_update_from_momentum( + grad, momentum, beta=0.95, ns_steps=_DEFAULT_NS_STEPS, nesterov=True +): + update = grad.lerp_(momentum, beta) if nesterov else momentum + if update.ndim == 4: # for the case of conv filters + update = update.view(len(update), -1) + update = zeropower_via_newtonschulz5(update, steps=ns_steps) + update *= max(1, grad.size(-2) / grad.size(-1)) ** 0.5 + return update + + +def muon_update(grad, momentum, beta=0.95, ns_steps=_DEFAULT_NS_STEPS, nesterov=True): + momentum.lerp_(grad, 1 - beta) + return muon_update_from_momentum(grad, momentum, beta, ns_steps, nesterov) + + +class Muon(torch.optim.Optimizer): + """ + Muon - MomentUm Orthogonalized by Newton-schulz + + https://kellerjordan.github.io/posts/muon/ + + Muon internally runs standard SGD-momentum, and then performs an orthogonalization post- + processing step, in which each 2D parameter's update is replaced with the nearest orthogonal + matrix. For efficient orthogonalization we use a Newton-Schulz iteration, which has the + advantage that it can be stably run in bfloat16 on the GPU. + + Muon should only be used for hidden weight layers. The input embedding, final output layer, + and any internal gains or biases should be optimized using a standard method such as AdamW. + Hidden convolutional weights can be trained using Muon by viewing them as 2D and then + collapsing their last 3 dimensions. + + Arguments: + lr: The learning rate, in units of spectral norm per update. + weight_decay: The AdamW-style weight decay. + momentum: The momentum. A value of 0.95 here is usually fine. + """ + + def __init__(self, params, lr=0.02, weight_decay=0, momentum=0.95): + defaults = dict(lr=lr, weight_decay=weight_decay, momentum=momentum) + assert ( + isinstance(params, list) + and len(params) >= 1 + and isinstance(params[0], torch.nn.Parameter) + ) + params = sorted(params, key=lambda x: x.size(), reverse=True) + super().__init__(params, defaults) + + @torch.no_grad() + def step(self, closure=None): + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + for group in self.param_groups: + params = group["params"] + # DDP gradients are already identical on every rank. Keep the cheap + # momentum recurrence complete on every rank so a rank-zero + # checkpoint contains every parameter's optimizer state; only the + # expensive Newton-Schulz update remains sharded. + momenta, grads = [], [] + for p in params: + if p.grad is None: + p.grad = torch.zeros_like(p) + state = self.state[p] + if "momentum_buffer" not in state: + state["momentum_buffer"] = torch.zeros_like(p) + momenta.append(state["momentum_buffer"]) + grads.append(p.grad) + torch._foreach_lerp_(momenta, grads, 1 - group["momentum"]) + + for bucket in _shape_buckets(params): + params_pad = bucket + [torch.empty_like(bucket[-1])] * ( + (-len(bucket)) % dist.get_world_size() + ) + for base_i in range(0, len(bucket), dist.get_world_size()): + if base_i + dist.get_rank() < len(bucket): + p = bucket[base_i + dist.get_rank()] + state = self.state[p] + update = muon_update_from_momentum( + p.grad, + state["momentum_buffer"], + beta=group["momentum"], + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update.reshape(p.shape), alpha=-group["lr"]) + dist.all_gather( + params_pad[base_i : base_i + dist.get_world_size()], + params_pad[base_i + dist.get_rank()], + ) + + return loss + + +class SingleDeviceMuon(torch.optim.Optimizer): + """ + Muon variant for usage in non-distributed settings. + """ + + def __init__(self, params, lr=0.02, weight_decay=0, momentum=0.95): + defaults = dict(lr=lr, weight_decay=weight_decay, momentum=momentum) + super().__init__(params, defaults) + + @torch.no_grad() + def step(self, closure=None): + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + for group in self.param_groups: + for p in group["params"]: + if p.grad is None: + # continue + p.grad = torch.zeros_like(p) # Force synchronization + state = self.state[p] + if len(state) == 0: + state["momentum_buffer"] = torch.zeros_like(p) + update = muon_update( + p.grad, state["momentum_buffer"], beta=group["momentum"] + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update.reshape(p.shape), alpha=-group["lr"]) + + return loss + + +def adam_update(grad, buf1, buf2, step, betas, eps): + buf1.lerp_(grad, 1 - betas[0]) + buf2.lerp_(grad.square(), 1 - betas[1]) + buf1c = buf1 / (1 - betas[0] ** step) + buf2c = buf2 / (1 - betas[1] ** step) + return buf1c / (buf2c.sqrt() + eps) + + +class MuonWithAuxAdam(torch.optim.Optimizer): + """ + Distributed Muon variant that can be used for all parameters in the network, since it runs an + internal AdamW for the parameters that are not compatible with Muon. The user must manually + specify which parameters shall be optimized with Muon and which with Adam by passing in a + list of param_groups with the `use_muon` flag set. + + The point of this class is to allow the user to have a single optimizer in their code, rather + than having both a Muon and an Adam which each need to be stepped. + + You can see an example usage below: + + https://github.com/KellerJordan/modded-nanogpt/blob/master/records/052525_MuonWithAuxAdamExample/b01550f9-03d8-4a9c-86fe-4ab434f1c5e0.txt#L470 + ``` + hidden_matrix_params = [ + p + for n, p in model.blocks.named_parameters() + if p.ndim >= 2 and "embed" not in n + ] + embed_params = [p for n, p in model.named_parameters() if "embed" in n] + scalar_params = [p for p in model.parameters() if p.ndim < 2] + head_params = [model.lm_head.weight] + + from muon import MuonWithAuxAdam + + adam_groups = [ + dict(params=head_params, lr=0.22), + dict(params=embed_params, lr=0.6), + dict(params=scalar_params, lr=0.04), + ] + adam_groups = [ + dict(**g, betas=(0.8, 0.95), eps=1e-10, use_muon=False) for g in adam_groups + ] + muon_group = dict( + params=hidden_matrix_params, lr=0.05, momentum=0.95, use_muon=True + ) + param_groups = [*adam_groups, muon_group] + optimizer = MuonWithAuxAdam(param_groups) + ``` + """ + + def __init__(self, param_groups): + for group in param_groups: + assert "use_muon" in group + if group["use_muon"]: + group["params"] = sorted( + group["params"], key=lambda x: x.size(), reverse=True + ) + # defaults + group["lr"] = group.get("lr", 0.02) + group["momentum"] = group.get("momentum", 0.95) + group["weight_decay"] = group.get("weight_decay", 0) + assert set(group.keys()) == set( + ["params", "lr", "momentum", "weight_decay", "use_muon"] + ) + else: + # defaults + group["lr"] = group.get("lr", 3e-4) + group["betas"] = group.get("betas", (0.9, 0.95)) + group["eps"] = group.get("eps", 1e-10) + group["weight_decay"] = group.get("weight_decay", 0) + assert set(group.keys()) == set( + ["params", "lr", "betas", "eps", "weight_decay", "use_muon"] + ) + super().__init__(param_groups, dict()) + + @torch.no_grad() + def step(self, closure=None): + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + for group in self.param_groups: + if group["use_muon"]: + params = group["params"] + # See ``Muon.step``: update the inexpensive momentum recurrence + # everywhere so every rank can write a complete checkpoint. + momenta, grads = [], [] + for p in params: + if p.grad is None: + p.grad = torch.zeros_like(p) + state = self.state[p] + if "momentum_buffer" not in state: + state["momentum_buffer"] = torch.zeros_like(p) + momenta.append(state["momentum_buffer"]) + grads.append(p.grad) + torch._foreach_lerp_(momenta, grads, 1 - group["momentum"]) + + for bucket in _shape_buckets(params): + params_pad = bucket + [torch.empty_like(bucket[-1])] * ( + (-len(bucket)) % dist.get_world_size() + ) + for base_i in range(0, len(bucket), dist.get_world_size()): + if base_i + dist.get_rank() < len(bucket): + p = bucket[base_i + dist.get_rank()] + state = self.state[p] + update = muon_update_from_momentum( + p.grad, + state["momentum_buffer"], + beta=group["momentum"], + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update.reshape(p.shape), alpha=-group["lr"]) + dist.all_gather( + params_pad[base_i : base_i + dist.get_world_size()], + params_pad[base_i + dist.get_rank()], + ) + else: + for p in group["params"]: + if p.grad is None: + # continue + p.grad = torch.zeros_like(p) # Force synchronization + state = self.state[p] + if len(state) == 0: + state["exp_avg"] = torch.zeros_like(p) + state["exp_avg_sq"] = torch.zeros_like(p) + state["step"] = 0 + state["step"] += 1 + update = adam_update( + p.grad, + state["exp_avg"], + state["exp_avg_sq"], + state["step"], + group["betas"], + group["eps"], + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update, alpha=-group["lr"]) + + return loss + + +class SingleDeviceMuonWithAuxAdam(torch.optim.Optimizer): + """ + Non-distributed variant of MuonWithAuxAdam. + """ + + def __init__(self, param_groups): + for group in param_groups: + assert "use_muon" in group + if group["use_muon"]: + # defaults + group["lr"] = group.get("lr", 0.02) + group["momentum"] = group.get("momentum", 0.95) + group["weight_decay"] = group.get("weight_decay", 0) + assert set(group.keys()) == set( + ["params", "lr", "momentum", "weight_decay", "use_muon"] + ) + else: + # defaults + group["lr"] = group.get("lr", 3e-4) + group["betas"] = group.get("betas", (0.9, 0.95)) + group["eps"] = group.get("eps", 1e-10) + group["weight_decay"] = group.get("weight_decay", 0) + assert set(group.keys()) == set( + ["params", "lr", "betas", "eps", "weight_decay", "use_muon"] + ) + super().__init__(param_groups, dict()) + + @torch.no_grad() + def step(self, closure=None): + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + for group in self.param_groups: + if group["use_muon"]: + for p in group["params"]: + if p.grad is None: + # continue + p.grad = torch.zeros_like(p) # Force synchronization + state = self.state[p] + if len(state) == 0: + state["momentum_buffer"] = torch.zeros_like(p) + update = muon_update( + p.grad, state["momentum_buffer"], beta=group["momentum"] + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update.reshape(p.shape), alpha=-group["lr"]) + else: + for p in group["params"]: + if p.grad is None: + # continue + p.grad = torch.zeros_like(p) # Force synchronization + state = self.state[p] + if len(state) == 0: + state["exp_avg"] = torch.zeros_like(p) + state["exp_avg_sq"] = torch.zeros_like(p) + state["step"] = 0 + state["step"] += 1 + update = adam_update( + p.grad, + state["exp_avg"], + state["exp_avg_sq"], + state["step"], + group["betas"], + group["eps"], + ) + p.mul_(1 - group["lr"] * group["weight_decay"]) + p.add_(update, alpha=-group["lr"]) + + return loss diff --git a/geom/explicit_h/runtime/src/morph/cli.py b/geom/explicit_h/runtime/src/morph/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..dbf0e33690827f5c08b58fb80581100b0c0d6b46 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/cli.py @@ -0,0 +1,334 @@ +# CLI subcommands import the scientific stack only when executed. +# ruff: noqa: PLC0415 + +"""``morph`` — molecular generation CLI. + +Run in-process (on a GPU node / via ``srun``): + + uv run --active --env-file .env morph generate -b 16 + # or, without (re)installing the console script: + uv run --active --env-file .env python -m morph generate -b 16 + +Model bundles are downloaded from Hugging Face on demand. +Use ``morph models list`` to inspect variants and available epochs. +""" + +from __future__ import annotations + +import click + + +def _catalog_options(command): + """Share explicit catalog source options across discovery and loading.""" + command = click.option( + "--catalog-repo", help="HF repository hosting catalog.json." + )(command) + command = click.option( + "--catalog-revision", help="Catalog commit, tag or branch (default: main)." + )(command) + return click.option( + "--catalog-file", + type=click.Path(exists=True, dir_okay=False), + help="Local catalog JSON; skips catalog network access.", + )(command) + + +def _get_catalog(repo, revision, path, cache_dir, offline, *, optional=False): + """Resolve an explicit override, or leave default discovery to the loader.""" + from morph.inference import load_catalog + + if optional and repo is None and revision is None and path is None: + return None + return load_catalog( + repo, + revision=revision, + path=path, + cache_dir=cache_dir, + local_files_only=offline, + ) + + +@click.group() +@click.version_option(package_name="morph") +def cli() -> None: + """Morph — molecular generation CLI.""" + + +@cli.group() +def models() -> None: + """Discover and download pretrained model bundles.""" + + +@models.command("list") +@_catalog_options +@click.option( + "--cache-dir", type=click.Path(file_okay=False), help="HF cache directory." +) +@click.option("--offline", is_flag=True, help="Use only the cached catalog.") +def list_models( + catalog_repo, catalog_revision, catalog_file, cache_dir, offline +) -> None: + """Discover models from the Hub catalog without downloading weights.""" + try: + catalog = _get_catalog( + catalog_repo, catalog_revision, catalog_file, cache_dir, offline + ) + except (ValueError, OSError, RuntimeError) as exc: + raise click.ClickException(str(exc)) from exc + + click.echo(f"{'MODEL':24} {'DATASET':12} {'EPOCHS':18} DEFAULT EPOCH") + for spec in catalog.models.values(): + epochs = ", ".join(map(str, spec.epochs)) or "from bundle" + latest = spec.select_epoch() or "from bundle" + click.echo(f"{spec.name:24} {spec.dataset or '-':12} {epochs:18} {latest}") + click.echo(f"Default model: {catalog.default_model or '(none)'}") + source = ( + f"{catalog.repo_id}@{catalog.revision}" + if catalog.repo_id + else str(catalog.path) + ) + click.echo(f"Catalog: {source}") + + +@models.command("download") +@_catalog_options +@click.argument("model_name") +@click.option("--epoch", type=click.IntRange(min=1), help="Completed training epoch.") +@click.option("--subfolder", help="Bundle subfolder inside the repository.") +@click.option( + "--revision", help="HF commit, tag or branch; catalog entries pin a commit." +) +@click.option( + "--filename", help="Checkpoint path relative to the bundle, instead of epoch." +) +@click.option( + "--cache-dir", type=click.Path(file_okay=False), help="HF cache directory." +) +@click.option( + "--local-dir", type=click.Path(file_okay=False), help="Export root directory." +) +@click.option( + "--offline", is_flag=True, help="Use cached files without network access." +) +def download_model( + model_name, + epoch, + subfolder, + revision, + filename, + cache_dir, + local_dir, + offline, + catalog_repo, + catalog_revision, + catalog_file, +) -> None: + """Download one epoch with its config, vocabularies and distributions. + + Auth: hf auth login, or set HF_TOKEN. Prints the local bundle directory. + """ + from morph.inference.hub import download_pretrained + + try: + catalog = _get_catalog( + catalog_repo, + catalog_revision, + catalog_file, + cache_dir, + offline, + optional=True, + ) + bundle = download_pretrained( + model_name, + epoch=epoch, + subfolder=subfolder, + revision=revision, + filename=filename, + catalog=catalog, + cache_dir=cache_dir, + local_dir=local_dir, + local_files_only=offline, + ) + except (ValueError, OSError, RuntimeError) as exc: + raise click.ClickException(str(exc)) from exc + click.echo(str(bundle.directory)) + + +@cli.command() +@_catalog_options +@click.option( + "-b", + "--batch", + "n_mols", + type=click.IntRange(min=1), + default=16, + show_default=True, + help="Number of molecules to generate.", +) +@click.option( + "-o", + "--output", + type=click.Path(), + show_default=True, + default="eval_outputs/denovo.sdf", + help="Output SDF of final-frame molecules (3D coords + SMILES/logP).", +) +@click.option( + "--model", + "model_name", + default=None, + help="Catalog name (morph models list) or Hugging Face owner/repo ID.", +) +@click.option( + "--epoch", type=click.IntRange(min=1), help="Epoch; defaults to latest published." +) +@click.option( + "--subfolder", help="Bundle subfolder inside the repository or local root." +) +@click.option( + "--revision", help="HF commit, tag or branch; catalog entries pin a commit." +) +@click.option( + "--filename", help="Checkpoint path relative to the bundle, instead of epoch." +) +@click.option( + "--cache-dir", type=click.Path(file_okay=False), help="HF cache directory." +) +@click.option( + "--offline", is_flag=True, help="Use cached files without network access." +) +@click.option( + "--device", + type=click.Choice(["auto", "cpu", "cuda"]), + default="auto", + show_default=True, +) +@click.option("--steps", type=click.IntRange(min=1), default=100, show_default=True) +@click.option( + "--ema/--no-ema", + default=True, + show_default=True, + help="Use EMA or current weights.", +) +@click.option( + "--weights-dir", + default=None, + type=click.Path(exists=True, file_okay=False), + help="Local variant bundle or download root; skips Hugging Face entirely.", +) +@click.option( + "--checkpoint", + default=None, + type=click.Path(exists=True, dir_okay=False), + help="Override local weights; requires --weights-dir with matching assets.", +) +@click.option( + "--save-trajs", + is_flag=True, + help="Also dump full trajectories (_trajs.pt).", +) +@click.option("--seed", default=42, show_default=True) +@click.option( + "--batch-size", + type=click.IntRange(min=1), + default=None, + help="Molecules per GPU batch (default: all requested molecules).", +) +def generate( + n_mols, + output, + model_name, + weights_dir, + checkpoint, + save_trajs, + seed, + batch_size, + epoch, + subfolder, + revision, + filename, + catalog_repo, + catalog_revision, + catalog_file, + cache_dir, + offline, + device, + steps, + ema, +) -> None: + """Generate molecules de-novo with an unconditional checkpoint. + + Downloads the selected checkpoint and matching assets on first use. + Uses the saved model config, eager execution, and EMA weights by default. + """ + from pathlib import Path + + import torch + + from morph.inference.generate import record_mol, write_sdf + from morph.inference.hub import load_pretrained + + try: + catalog = _get_catalog( + catalog_repo, + catalog_revision, + catalog_file, + cache_dir, + offline, + optional=True, + ) + model = load_pretrained( + model_name, + epoch=epoch, + subfolder=subfolder, + revision=revision, + filename=filename, + catalog=catalog, + device=device, + weights_dir=weights_dir, + checkpoint=checkpoint, + cache_dir=cache_dir, + local_files_only=offline, + steps=steps, + use_ema=ema, + ) + except (ValueError, OSError, RuntimeError) as exc: + raise click.ClickException(str(exc)) from exc + click.echo( + f"[morph generate] model={model.bundle.spec.name} " + f"ckpt={model.bundle.checkpoint} n={n_mols}" + ) + + trajectories, is_valid, vocab, _coord_std = model.generate( + n_mols=n_mols, + seed=seed, + batch_size=batch_size, + return_traj=save_trajs, + ) + + out = Path(output) + valid_trajectories = [ + traj for traj, valid in zip(trajectories, is_valid, strict=True) if valid + ] + n_written, _ = write_sdf(valid_trajectories, vocab, out) + click.echo(f"wrote {n_written}/{len(trajectories)} attempts -> {out}") + if save_trajs: + tp = out.with_name(out.stem + "_trajs.pt") + torch.save({"trajectories": trajectories, "is_valid": is_valid}, tp) + click.echo(f"wrote trajectories -> {tp}") + + rows = [record_mol(t, vocab) for t in trajectories] + for row, valid in zip(rows, is_valid, strict=True): + row["valid"] = valid + n_valid = sum(is_valid) + n_uniq = len({r["smiles"] for r in rows if r["valid"] and r["smiles"] is not None}) + click.echo(f" valid: {n_valid}/{len(rows)} | unique SMILES: {n_uniq}") + for i, r in enumerate(rows): + click.echo( + f" [{i}] valid={r['valid']} N={r['n_atoms']} " + f"logP={r['logp']} {r['smiles']}" + ) + + +if __name__ == "__main__": + cli() diff --git a/geom/explicit_h/runtime/src/morph/config.py b/geom/explicit_h/runtime/src/morph/config.py new file mode 100644 index 0000000000000000000000000000000000000000..da5bb482ee9d174a998bdb034503cd5cb02b8741 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/config.py @@ -0,0 +1,23 @@ +"""Shared OmegaConf resolver registration. + +Importing this module registers Morph's custom resolvers. Registration is +idempotent so entry points and libraries can safely import it in any order. +""" + +from omegaconf import OmegaConf + + +def register_resolvers() -> None: + """Register the resolvers used by Morph's Hydra configurations.""" + resolvers = { + "oc.eval": eval, + "len": len, + "if": lambda condition, true, false: true if condition else false, + "eq": lambda left, right: left == right, + } + for name, resolver in resolvers.items(): + if not OmegaConf.has_resolver(name): + OmegaConf.register_new_resolver(name, resolver) + + +register_resolvers() diff --git a/geom/explicit_h/runtime/src/morph/dataset/__init__.py b/geom/explicit_h/runtime/src/morph/dataset/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..10e5dc5a50476b29d2ae1d166f14155adb49730d --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/__init__.py @@ -0,0 +1,13 @@ +"""morph dataset layer. + +Organised into three groups so the dependency direction is legible +(``conditional`` -> ``unconditional`` -> ``core``, never upward): + +* :mod:`morph.dataset.core` -- shared infrastructure every dataset builds + on (the ``ChemAtomicData`` / ``ChemBatch`` containers, vocab, + representation, the flow-matching dataset wrapper, the base Lightning + datamodule, preprocessing and property caching). +* :mod:`morph.dataset.unconditional` -- concrete GEOM and QM9 sources, + each exposing a ``FlowMatching*`` variant. +* :mod:`morph.dataset.conditional` -- scaffold-decoration fine-tuning wrappers. +""" diff --git a/geom/explicit_h/runtime/src/morph/dataset/conditional/__init__.py b/geom/explicit_h/runtime/src/morph/dataset/conditional/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d905b6cdbea624870c29d8776265d212acf02755 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/conditional/__init__.py @@ -0,0 +1,7 @@ +"""Conditional / finetuning task wrappers. + +Each subpackage wraps an unconditional source into a paired / conditioned +dataset for a downstream finetuning task: + +* :mod:`morph.dataset.conditional.scaffold` -- bare-scaffold decoration. +""" diff --git a/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/__init__.py b/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7cf1ec8108fa1e6beef793cfdc5adf792c1c941d --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/__init__.py @@ -0,0 +1,5 @@ +"""Scaffold-decoration data. + +``cutters`` selects retained atom sets; ``wrappers`` builds flow-matching pairs; +``datamodule`` provides the Lightning data loaders. +""" diff --git a/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/cutters.py b/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/cutters.py new file mode 100644 index 0000000000000000000000000000000000000000..de0a5d2d1b24fc710e7d6164e2c176afdfe5f1a1 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/cutters.py @@ -0,0 +1,350 @@ +"""Select scaffold atom sets in the input molecule's original indexing. + +Murcko keeps the ring/linker core; R-group cuts retain the largest BRICS +fragment; heavy-atom cuts leave hydrogens to generate; linker cuts keep +multiple fragments, including their original hydrogens. The cached atom sets +feed the same keep-mask conditioning and integration path for every kind. +""" + +from __future__ import annotations + +from itertools import combinations + +from rdkit import Chem +from rdkit.Chem import BRICS, RWMol +from rdkit.Chem.Scaffolds import MurckoScaffold + +from morph.utils.rdkit_utils import IDX_BOND_MAP + +MAX_SCAFFOLD_AUTOMORPHISMS = 16 +MAX_CUTS = 4 +MAX_RGROUP_ATOMS = 10 +MIN_SCAFFOLD_FRACTION = 0.4 +MAX_SLICINGS = 32 + + +def _heavy_submol(mol): + """Heavy-atom copy of ``mol`` plus a new-index -> original-index map. + + Bond-cut rules operate on heavy atoms. Removing hydrogens from a copy + preserves aromatic flags, formal charges and the hydrogen counts needed + to sanitize heteroaromatic rings. The map restores the dataset's indexing. + """ + heavy = [a.GetIdx() for a in mol.GetAtoms() if a.GetAtomicNum() != 1] + if not heavy: + return None, None + try: + out = ( + Chem.Mol(mol) + if len(heavy) == mol.GetNumAtoms() + else Chem.RemoveAllHs(mol, sanitize=False) + ) + Chem.SanitizeMol(out) + except Exception: + return None, None + # RemoveAllHs keeps the relative order of the remaining atoms, so the map is + # positional -- but guard it, since a mismatch would silently mis-index every + # scaffold produced for this molecule. + if out.GetNumAtoms() != len(heavy): + return None, None + return out, heavy + + +def _brics_cuttable(heavy_mol) -> set: + """Bond indices BRICS considers synthetically accessible.""" + cuttable = set() + for (i, j), _ in BRICS.FindBRICSBonds(heavy_mol): + bond = heavy_mol.GetBondBetweenAtoms(int(i), int(j)) + # Ring bonds would open a cycle rather than detach a substituent. + if bond is not None and not bond.IsInRing(): + cuttable.add(bond.GetIdx()) + return cuttable + + +def _slice_on_bonds( + mol, + cuttable_fn, + max_cuts: int, + max_rgroup_atoms: int, + min_scaffold_fraction: float, + max_slicings: int, +) -> list[tuple]: + """Cut 1..``max_cuts`` of the candidate bonds; largest fragment is the core. + + A slicing is rejected when a severed piece exceeds ``max_rgroup_atoms`` + (that is a fragmentation, not a decoration) or the core falls below + ``min_scaffold_fraction`` of the molecule. + """ + heavy_mol, heavy_map = _heavy_submol(mol) + if heavy_mol is None: + return [] + n_heavy = heavy_mol.GetNumAtoms() + + cuttable = sorted(cuttable_fn(heavy_mol)) + if not cuttable: + return [] + + # Collect per cut-count, then round-robin when truncating. Returning the + # first ``max_slicings`` found instead would be a pure 1-cut sample, since + # enumeration walks k ascending -- multi-R slicings would never survive. + by_cuts, seen = {}, set() + for k in range(1, min(max_cuts, len(cuttable)) + 1): + bucket = by_cuts.setdefault(k, []) + for combo in combinations(cuttable, k): + try: + fragmented = Chem.FragmentOnBonds( + heavy_mol, list(combo), addDummies=False + ) + pieces = Chem.GetMolFrags(fragmented) + except Exception: + continue + if len(pieces) < 2: + continue + core = max(pieces, key=len) + if any(len(p) > max_rgroup_atoms for p in pieces if p is not core): + continue + if len(core) < min_scaffold_fraction * n_heavy: + continue + key = tuple(sorted(core)) + if key in seen: + continue + seen.add(key) + bucket.append(tuple(sorted(heavy_map[i] for i in key))) + + slicings = [] + for rank in range(max(len(b) for b in by_cuts.values()) if by_cuts else 0): + for k in sorted(by_cuts): + if rank < len(by_cuts[k]): + slicings.append(by_cuts[k][rank]) + if len(slicings) >= max_slicings: + return slicings + return slicings + + +def murcko_indices(mol) -> list[tuple]: + """All Bemis-Murcko scaffold matches, as heavy-atom index tuples.""" + heavy_mol, heavy_map = _heavy_submol(mol) + if heavy_mol is None: + return [] + try: + scaffold_smiles = Chem.MolToSmiles(MurckoScaffold.GetScaffoldForMol(heavy_mol)) + except Exception: + return [] + if not scaffold_smiles or "." in scaffold_smiles: + return [] + scaffold = Chem.MolFromSmiles(scaffold_smiles) + if scaffold is None: + return [] + matches = heavy_mol.GetSubstructMatches( + scaffold, uniquify=False, maxMatches=MAX_SCAFFOLD_AUTOMORPHISMS + ) + return [tuple(sorted(heavy_map[i] for i in match)) for match in matches] + + +def heavy_indices(mol, **_ignored) -> list[tuple]: + """The whole heavy-atom skeleton as the scaffold -- hydrogen decoration. + + A single match, not an automorphism list: the atoms are taken by index, not + matched through a substructure query, so there is nothing to enumerate. + """ + heavy = tuple(a.GetIdx() for a in mol.GetAtoms() if a.GetAtomicNum() != 1) + # A molecule with no hydrogens has nothing to decorate -- drop it, the same + # way an empty Murcko core drops a molecule from the scaffold task. + return [heavy] if heavy and len(heavy) < mol.GetNumAtoms() else [] + + +def rgroup_indices( + mol, + max_cuts: int = MAX_CUTS, + max_rgroup_atoms: int = MAX_RGROUP_ATOMS, + min_scaffold_fraction: float = MIN_SCAFFOLD_FRACTION, + max_slicings: int = MAX_SLICINGS, +) -> list[tuple]: + """BRICS slicings retaining the largest fragment as the scaffold.""" + return _slice_on_bonds( + mol, + _brics_cuttable, + max_cuts, + max_rgroup_atoms, + min_scaffold_fraction, + max_slicings, + ) + + +def linker_indices( + mol, + min_frag: int = 3, + min_linker: int = 1, + max_linker: int = 12, + max_group: int = 3, + max_slicings: int = MAX_SLICINGS, + **_ignored, +) -> list[tuple]: + """BRICS linker cuts retaining at least two fragments and their hydrogens. + + The removed linker is a connected group of up to ``max_group`` BRICS + fragments with ``min_linker`` to ``max_linker`` heavy atoms. Each retained + component has at least ``min_frag`` heavy atoms. Only acyclic bonds are cut; + rings can occur inside the linker. Returned indices address the original + all-atom molecule, including hydrogens belonging to retained fragments. + """ + heavy_mol, heavy_map = _heavy_submol(mol) + if heavy_mol is None: + return [] + # BRICS bonds in FULL-atom indices (BRICS runs on the heavy skeleton; the bond + # atoms are heavy). We fragment on the FULL graph so each fragment carries its + # own hydrogens (GEOM is all-atom) and the kept fragments stay intact. + brics = [] + for (i, j), _ in BRICS.FindBRICSBonds(heavy_mol): + b = heavy_mol.GetBondBetweenAtoms(int(i), int(j)) + if b is not None and not b.IsInRing(): + brics.append((heavy_map[int(i)], heavy_map[int(j)])) + if len(brics) < 2: # need ≥2 cuts to isolate a linker between fragments + return [] + + N = mol.GetNumAtoms() + is_heavy = [mol.GetAtomWithIdx(a).GetAtomicNum() > 1 for a in range(N)] + adj = [[] for _ in range(N)] + for b in mol.GetBonds(): + u, v = b.GetBeginAtomIdx(), b.GetEndAtomIdx() + adj[u].append(v) + adj[v].append(u) + cut = {frozenset(e) for e in brics} + + # BRICS fragments = components of the FULL graph with BRICS bonds removed. + frag_of = [-1] * N + frag_atoms: list[set] = [] + for s in range(N): + if frag_of[s] != -1: + continue + comp, stack = set(), [s] + frag_of[s] = len(frag_atoms) + while stack: + x = stack.pop() + comp.add(x) + for y in adj[x]: + if frozenset((x, y)) in cut or frag_of[y] != -1: + continue + frag_of[y] = len(frag_atoms) + stack.append(y) + frag_atoms.append(comp) + nf = len(frag_atoms) + if nf < 3: # need a linker + ≥2 fragments + return [] + + def nheavy(atoms): + return sum(1 for a in atoms if is_heavy[a]) + + fadj = [set() for _ in range(nf)] + for u, v in brics: + if frag_of[u] != frag_of[v]: + fadj[frag_of[u]].add(frag_of[v]) + fadj[frag_of[v]].add(frag_of[u]) + + # Connected fragment subsets (size 1..max_group) — linker candidates. + subsets: set = set() + stack = [frozenset([f]) for f in range(nf)] + while stack: + s = stack.pop() + if s in subsets: + continue + subsets.add(s) + if len(s) >= max_group: + continue + for f in s: + for nb in sorted(fadj[f]): + if nb not in s: + stack.append(s | {nb}) + + keep_sets: set = set() + for S in sorted(subsets, key=lambda s: (len(s), tuple(sorted(s)))): + latoms = set().union(*(frag_atoms[f] for f in S)) + if not (min_linker <= nheavy(latoms) <= max_linker): # size = HEAVY atoms + continue + # Kept fragment components after removing S; each must be ≥ min_frag heavy. + seen: set = set(S) + keep_atoms: set = set() + comps, ok = 0, True + for f in range(nf): + if f in seen: + continue + comps += 1 + catoms, cstack = set(), [f] + seen.add(f) + while cstack: + x = cstack.pop() + catoms |= frag_atoms[x] + for y in fadj[x]: + if y not in seen: + seen.add(y) + cstack.append(y) + if nheavy(catoms) < min_frag: + ok = False + break + keep_atoms |= catoms + if ok and comps >= 2: + keep_sets.add( + tuple(sorted(keep_atoms)) + ) # FULL-atom kept fragments (with H) + if len(keep_sets) >= max_slicings: + break + return sorted(keep_sets) + + +SCAFFOLD_KINDS = { + "murcko": murcko_indices, + "rgroup": rgroup_indices, + "heavy": heavy_indices, + "linker": linker_indices, +} + + +def scaffold_indices(mol, kind: str = "rgroup", **params) -> list[tuple[int, ...]]: + """Return distinct kept atom sets for the selected scaffold convention.""" + try: + cutter = SCAFFOLD_KINDS[kind] + except KeyError: + raise ValueError( + f"unknown scaffold_kind {kind!r}; expected one of {sorted(SCAFFOLD_KINDS)}" + ) from None + # Atom order inside a cut is irrelevant to filter_nodes. Deduplicate + # automorphisms so evaluation counts each retained subgraph only once. + return list(dict.fromkeys(tuple(sorted(cut)) for cut in cutter(mol, **params))) + + +def _atom_indices_for_data( + data, scaffold_kind: str = "rgroup" +) -> list[tuple[int, ...]]: + """Return the chosen cutter's scaffold atom sets for one molecule. + + The molecule is reconstructed from stored atom and bond tensors so the + matches stay in the dataset's atom-index space. + """ + rwmol = RWMol() + charges = getattr(data, "charges", None) + for idx, z_val in enumerate(data.z.tolist()): + atom = Chem.Atom(int(z_val)) + if charges is not None: + atom.SetFormalCharge(int(charges[idx])) + rwmol.AddAtom(atom) + for k in range(data.edge_index.shape[1]): + i, j = int(data.edge_index[0, k]), int(data.edge_index[1, k]) + if i < j: + rwmol.AddBond(i, j, IDX_BOND_MAP[int(data.edge_attr[k])]) + try: + mol = rwmol.GetMol() + Chem.SanitizeMol(mol) + except Exception: + return [] + + return scaffold_indices(mol, scaffold_kind) + + +def compute_scaffold_atom_indices( + dataset, scaffold_kind: str = "rgroup" +) -> list[list[tuple[int, ...]]]: + """Return alternative scaffold atom sets for every dataset molecule.""" + return [ + _atom_indices_for_data(dataset.get(idx), scaffold_kind) + for idx in range(len(dataset)) + ] diff --git a/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/datamodule.py b/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/datamodule.py new file mode 100644 index 0000000000000000000000000000000000000000..2cd77ef47e018297b248a50fccf4850ab70ec741 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/datamodule.py @@ -0,0 +1,40 @@ +"""Lightning datamodule for scaffold decoration.""" + +from __future__ import annotations + +from morph.dataset.conditional.scaffold.wrappers import ( + FlowMatchingDatasetWrapperScaffold, +) +from morph.dataset.core.lightning_datamodule import LightningDataModule + + +class ScaffoldLightningDataModule(LightningDataModule): + """Wrap base datasets using a selectable retained-subgraph convention.""" + + wrapper_cls = FlowMatchingDatasetWrapperScaffold + + def __init__( + self, + *args, + scaffold_groups_dir: str, + scaffold_kind: str = "rgroup", + enumerate_slicings: bool = False, + **kwargs, + ): + super().__init__(*args, **kwargs) + self._scaffold_groups_dir = scaffold_groups_dir + self._scaffold_kind = scaffold_kind + self._enumerate_slicings = bool(enumerate_slicings) + + def _wrapper_kwargs(self, stage: str) -> dict: + kwargs = super()._wrapper_kwargs(stage) + kwargs["scaffold_groups_dir"] = self._scaffold_groups_dir + kwargs["scaffold_kind"] = self._scaffold_kind + kwargs["enumerate_slicings"] = self._enumerate_slicings and stage != "train" + return kwargs + + def val_dataloader(self): + """Retain the final partial batch when evaluating every scaffold.""" + return self._eval_dataloaders( + self.val_datasets, "val", drop_last=not self._enumerate_slicings + ) diff --git a/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/lmdb.py b/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/lmdb.py new file mode 100644 index 0000000000000000000000000000000000000000..840161214e30bb096a35f65e2cca05ac9a52205a --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/lmdb.py @@ -0,0 +1,143 @@ +"""Lazy LMDB reader for per-molecule scaffold atom matches.""" + +from __future__ import annotations + +import os +import pickle +from typing import TYPE_CHECKING + +import numpy as np + +from morph.dataset.conditional.scaffold.cutters import SCAFFOLD_KINDS +from morph.dataset.core.lmdb import open_read_env + +if TYPE_CHECKING: + import lmdb + +_META_N = b"meta:n" +_META_NONEMPTY = b"meta:scaffold_nonempty_indices" + + +def _kind_suffix(scaffold_kind: str) -> str: + if scaffold_kind not in SCAFFOLD_KINDS: + raise ValueError( + f"unknown scaffold_kind {scaffold_kind!r}; " + f"expected one of {sorted(SCAFFOLD_KINDS)}" + ) + # Existing caches without a suffix contain Murcko matches. + return "" if scaffold_kind == "murcko" else f"_{scaffold_kind}" + + +def scaffold_lmdb_path( + scaffold_groups_dir: str, stage: str, scaffold_kind: str = "rgroup" +) -> str: + """Return the scaffold LMDB path for this split and cutter.""" + return os.path.join( + scaffold_groups_dir, f"scaffold_{stage}{_kind_suffix(scaffold_kind)}.lmdb" + ) + + +def scaffold_pt_path( + scaffold_groups_dir: str, stage: str, scaffold_kind: str = "rgroup" +) -> str: + """Return the fallback cache path, preserving existing Murcko filenames.""" + return os.path.join( + scaffold_groups_dir, + f"scaffold_groups_{stage}{_kind_suffix(scaffold_kind)}.pt", + ) + + +def _key(prefix: str, i: int) -> bytes: + return f"{prefix}:{int(i)}".encode() + + +def compute_filter_metadata(atom_indices) -> list[int]: + """Return indices whose molecules have a non-empty scaffold match.""" + return [i for i, matches in enumerate(atom_indices) if matches] + + +class _LazyField: + """Subscriptable view of one LMDB key prefix.""" + + __slots__ = ("_cache", "_default", "_prefix") + + def __init__(self, cache: ScaffoldLMDBCache, prefix: str, default): + self._cache = cache + self._prefix = prefix + self._default = default + + def __getitem__(self, i): + return self._cache._read(self._prefix, i, self._default) + + +_READ_ENVS: dict[str, lmdb.Environment] = {} +_READ_TXNS: dict[tuple[str, int], object] = {} + + +def _shared_read_env(path: str) -> lmdb.Environment: + env = _READ_ENVS.get(path) + if env is None: + env = open_read_env(path) + _READ_ENVS[path] = env + return env + + +def _shared_read_txn(path: str): + key = (path, os.getpid()) + txn = _READ_TXNS.get(key) + if txn is None: + txn = _shared_read_env(path).begin() + _READ_TXNS[key] = txn + return txn + + +class ScaffoldLMDBCache: + """Lazy reader over a scaffold LMDB shared by dataloader workers.""" + + def __init__(self, lmdb_path: str): + self.lmdb_path = lmdb_path + with _shared_read_env(lmdb_path).begin() as txn: + self.n = pickle.loads(txn.get(_META_N)) + self._filtered = np.asarray( + pickle.loads(txn.get(_META_NONEMPTY)), dtype=np.int64 + ) + self.atom_indices = _LazyField(self, "ai", []) + + def _read(self, prefix: str, i, default): + raw = _shared_read_txn(self.lmdb_path).get(_key(prefix, int(i))) + return default if raw is None else pickle.loads(raw) + + def filtered_indices(self): + """Indices of source molecules with cached scaffold annotations.""" + return self._filtered + + +class _DictScaffoldCache: + """Adapter exposing the LMDB cache interface over a legacy ``.pt`` dict.""" + + def __init__(self, data: dict): + self.atom_indices = data["scaffold_atom_indices"] + self.n = len(self.atom_indices) + self._filtered = np.asarray( + compute_filter_metadata(self.atom_indices), dtype=np.int64 + ) + + def filtered_indices(self): + return self._filtered + + +def load_scaffold_cache( + base_dataset, + scaffold_groups_dir: str, + stage: str, + *, + legacy_loader, + scaffold_kind: str = "rgroup", +): + """Load the lazy LMDB cache, falling back to the legacy ``.pt`` cache.""" + lmdb_path = scaffold_lmdb_path(scaffold_groups_dir, stage, scaffold_kind) + if os.path.isdir(lmdb_path): + return ScaffoldLMDBCache(lmdb_path) + return _DictScaffoldCache( + legacy_loader(base_dataset, scaffold_groups_dir, stage, scaffold_kind) + ) diff --git a/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/wrappers.py b/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/wrappers.py new file mode 100644 index 0000000000000000000000000000000000000000..8cc5ad15e367400a5cffbd36b61e05e856546c57 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/conditional/scaffold/wrappers.py @@ -0,0 +1,204 @@ +"""Flow-matching dataset wrapper for scaffold decoration.""" + +from __future__ import annotations + +import os +import random +import tempfile + +import numpy as np +import torch + +from morph.dataset.conditional.scaffold.cutters import compute_scaffold_atom_indices +from morph.dataset.conditional.scaffold.lmdb import ( + load_scaffold_cache, + scaffold_pt_path, +) +from morph.dataset.core.flow_matching_wrapper import FlowMatchingDatasetWrapper +from morph.utils.graph_math import build_fully_connected_edge_index + + +def rebuild_atomic_with_edges(data, edge_index, edge_types): + """Type-preserving copy of ``data`` with fully replaced connectivity.""" + out = data.clone() + out.edge_index = edge_index.long() + out.edge_types = edge_types.long() + return out + + +def _load_or_build_scaffold_cache( + base_dataset, + scaffold_groups_dir: str, + stage: str, + scaffold_kind: str = "rgroup", +) -> dict: + """Load or lazily build scaffold atom sets for this split and cutter.""" + if scaffold_groups_dir is None: + raise ValueError("scaffold_groups_dir must be provided") + path = scaffold_pt_path(scaffold_groups_dir, stage, scaffold_kind) + cache = torch.load(path, weights_only=False) if os.path.exists(path) else {} + matches = cache.get("scaffold_atom_indices") + if ( + matches is None + or len(matches) != len(base_dataset) + or (len(matches) > 0 and not isinstance(matches[0], list)) + or cache.get("scaffold_kind", scaffold_kind) != scaffold_kind + ): + cache = { + "scaffold_kind": scaffold_kind, + "scaffold_atom_indices": compute_scaffold_atom_indices( + base_dataset, scaffold_kind + ), + } + os.makedirs(scaffold_groups_dir, exist_ok=True) + # A reader must never observe a partly written first-run cache. + with tempfile.NamedTemporaryFile( + dir=scaffold_groups_dir, suffix=".pt", delete=False + ) as tmp: + tmp_path = tmp.name + try: + torch.save(cache, tmp_path) + os.replace(tmp_path, path) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + return cache + + +class FlowMatchingDatasetWrapperScaffold(FlowMatchingDatasetWrapper): + """Build retained-subgraph → full-molecule flow-matching examples. + + ``keep_mask`` marks the scaffold atoms in the interpolated state. Scaffold + atoms are placed first by the scaffold-aware interpolator. + Training draws a random cut per visit. Evaluation can enumerate all + distinct molecule/scaffold pairs with ``enumerate_slicings=True``. + """ + + def __init__( + self, + base_dataset, + distributions, + interpolator, + vocab, + representation=None, + n_atoms_strategy="uniform", + uniform_n_atoms_padding: int = 5, + time_dist=None, + stage="train", + rotate: bool = False, + n_augmentations: int = 1, + scaffold_groups_dir: str | None = None, + scaffold_kind: str = "rgroup", + enumerate_slicings: bool = False, + ): + base_kwargs = dict( + n_atoms_strategy=n_atoms_strategy, + uniform_n_atoms_padding=uniform_n_atoms_padding, + time_dist=time_dist, + stage=stage, + rotate=rotate, + n_augmentations=n_augmentations, + ) + if representation is not None: + base_kwargs["representation"] = representation + super().__init__( + base_dataset, + distributions, + interpolator, + vocab, + **base_kwargs, + ) + cache = load_scaffold_cache( + base_dataset, + scaffold_groups_dir, + stage, + legacy_loader=_load_or_build_scaffold_cache, + scaffold_kind=scaffold_kind, + ) + self._scaffold_atom_indices = cache.atom_indices + self._filtered_indices = cache.filtered_indices() + self._slicing_offsets = None + if enumerate_slicings and stage != "train": + # One offset per molecule avoids millions of Python (mol, cut) + # pairs for large evaluation sets. Deduplicate legacy automorphisms. + counts = [ + len(self._scaffold_choices(int(i))) for i in self._filtered_indices + ] + self._slicing_offsets = np.concatenate( + (np.zeros(1, dtype=np.int64), np.cumsum(counts, dtype=np.int64)) + ) + + def __len__(self): + """Count molecules, or distinct molecule/scaffold evaluation pairs.""" + if self._slicing_offsets is not None: + return int(self._slicing_offsets[-1]) + return len(self._filtered_indices) + + def _scaffold_choices(self, base_idx: int) -> list[tuple[int, ...]]: + return list( + dict.fromkeys( + tuple(sorted(cut)) for cut in self._scaffold_atom_indices[base_idx] + ) + ) + + def _select_source(self, base_idx: int, target, slicing_idx: int | None = None): + choices = self._scaffold_choices(base_idx) + scaffold_indices = ( + random.choice(choices) if slicing_idx is None else choices[slicing_idx] + ) + mask = torch.zeros(target.num_nodes, dtype=torch.bool) + mask[torch.as_tensor(scaffold_indices, dtype=torch.long)] = True + source = target.filter_nodes(mask) + kept = sorted(int(i) for i in scaffold_indices) + pairs = [(i, target_idx) for i, target_idx in enumerate(kept)] + return source, pairs + + def __getitem__(self, index): + """Build an interpolated training item or a kept-subgraph evaluation pair.""" + index = int(index) + if index < 0: + index += len(self) + if not 0 <= index < len(self): + raise IndexError(index) + slicing_idx = None + if self._slicing_offsets is not None: + molecule_idx = int( + np.searchsorted(self._slicing_offsets, index, side="right") - 1 + ) + slicing_idx = int(index - self._slicing_offsets[molecule_idx]) + index = molecule_idx + base_idx = int(self._filtered_indices[index]) + target = self.base_dataset[base_idx] + source, scaffold_pairs = self._select_source(base_idx, target, slicing_idx) + + if self.stage == "train": + t = self.time_dist.sample((1,)).squeeze(0) + t = torch.clamp(t, min=0.0, max=1 - 1e-8) + mol_t, mol_1, ins_targets = self._interpolate_train_atomic( + source, + target, + t, + scaffold_pairs=scaffold_pairs, + ) + keep_mask = torch.zeros(mol_t.num_nodes, dtype=torch.long) + keep_mask[: len(scaffold_pairs)] = 1 + mol_t.add_node_property("keep_mask", keep_mask) + + if hasattr(target, "y") and target.y is not None: + mol_t.add_system_property("y", target.y) + return self._training_output(mol_t, mol_1, ins_targets, t) + + keep_mask = torch.ones(source.num_nodes, dtype=torch.long) + n_nodes = source.num_nodes + edge_index = build_fully_connected_edge_index(n_nodes) + edge_dtype = source.e.dtype if source.e is not None else torch.long + dense = torch.zeros(n_nodes, n_nodes, dtype=edge_dtype) + if source.edge_index is not None and source.e is not None: + dense[source.edge_index[0], source.edge_index[1]] = source.e + source = rebuild_atomic_with_edges( + source, + edge_index, + dense[edge_index[0], edge_index[1]], + ) + source.add_node_property("keep_mask", keep_mask) + return source, target diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/__init__.py b/geom/explicit_h/runtime/src/morph/dataset/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a62d4083497c96476cf11f482ad769736b7a8e6d --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/__init__.py @@ -0,0 +1,8 @@ +"""Shared dataset infrastructure used by every dataset and task. + +Holds the native geometric-graph containers (:mod:`atomic_data`, +:mod:`insertion_data`), the token :mod:`vocab`, the :mod:`representation` +projection layer, the flow-matching :mod:`flow_matching_wrapper`, the base +:mod:`lightning_datamodule`, :mod:`preprocessing` and property caching +(:mod:`properties`). +""" diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/atomic_data.py b/geom/explicit_h/runtime/src/morph/dataset/core/atomic_data.py new file mode 100644 index 0000000000000000000000000000000000000000..776461c5440a93e54ace90df1713bcdb505886db --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/atomic_data.py @@ -0,0 +1,33 @@ +"""Public Morph geometric-graph data API.""" + +from morph.dataset.core.batch import ( + COFRAMED_CONTEXT_KEYS, + ChemBatch, + batch_from_flat, + batch_remove_com, + batch_translate, + batch_unpack, + chembatch_from_graphs, + cumulative_node_offsets, + filter_nodes, +) +from morph.dataset.core.containers import ( + ChemAtomicData, + make_chem_atomic, + rdkit_to_token_payload, +) + +__all__ = [ + "COFRAMED_CONTEXT_KEYS", + "ChemAtomicData", + "ChemBatch", + "batch_from_flat", + "batch_remove_com", + "batch_translate", + "batch_unpack", + "chembatch_from_graphs", + "cumulative_node_offsets", + "filter_nodes", + "make_chem_atomic", + "rdkit_to_token_payload", +] diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/batch.py b/geom/explicit_h/runtime/src/morph/dataset/core/batch.py new file mode 100644 index 0000000000000000000000000000000000000000..74b1069b098f3fc9f52ed0f3efdfe1aaf64cf1ed --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/batch.py @@ -0,0 +1,681 @@ +"""Native flat-tensor batching for Morph geometric graphs.""" + +# The short alias properties and mapping protocol are documented on the class. +# ruff: noqa: D102, D105 + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from morph.dataset.core.containers import ChemAtomicData +from morph.utils.graph_math import unsorted_segment_sum + +if TYPE_CHECKING: + from collections.abc import Iterable + + +# Separate geometric context graphs whose coordinates share the molecule frame. +# They are not edited with the molecule, but every global recentering must move +# them by the same per-graph translation. +COFRAMED_CONTEXT_KEYS = ("start_mol", "pocket") + + +def batch_unpack(batch: ChemBatch): + """Return ``(x, a, c, e, edge_index, batch_idx)`` from a batch.""" + return batch.unpack() + + +def batch_remove_com( + batch: ChemBatch, x_mean: torch.Tensor | None = None +) -> torch.Tensor: + """Remove the per-graph coordinate mean, including co-framed context.""" + positions = batch.positions + if x_mean is None: + x_mean = unsorted_segment_sum(positions, batch.batch, batch.num_graphs) + x_mean = x_mean / batch.num_nodes_per_graph.clamp(min=1).unsqueeze(1) + else: + assert x_mean.shape == (batch.num_graphs, positions.shape[-1]), ( + "x_mean must have the same shape as the coordinates" + ) + batch.positions = positions - x_mean[batch.batch] + for key in COFRAMED_CONTEXT_KEYS: + context = getattr(batch, key, None) + if context is not None: + context.positions = context.positions - x_mean[context.batch] + return x_mean + + +def batch_translate(batch: ChemBatch, translation: torch.Tensor) -> None: + """Translate a batch and its geometric context by one vector per graph.""" + if translation.shape != (batch.num_graphs, batch.positions.shape[-1]): + raise ValueError( + "translation must have shape " + f"({batch.num_graphs}, {batch.positions.shape[-1]})" + ) + batch.positions.add_(translation[batch.batch]) + for key in COFRAMED_CONTEXT_KEYS: + context = getattr(batch, key, None) + if context is not None: + context.positions.add_(translation[context.batch]) + + +def _clone_value(value): + return value.clone() if hasattr(value, "clone") else value + + +class ChemBatch: + """A batch of Morph graphs stored as graph-contiguous flat tensors. + + ``data`` holds node-, edge-, and graph-level tensors, while ``keys`` records + their level. Connectivity is natively PyG-shaped ``[2, E]`` and all index + tensors are ``int64``, matching the model and scatter operations directly. + """ + + _ALIASES = { + "x": "positions", + "a": "atom_types", + "c": "charge_types", + "e": "edge_types", + } + _INTERNAL_ATTRS = frozenset( + { + "_data", + "_keys", + "_batch_idx", + "_node_counts", + "_edge_counts", + "_num_graphs", + "_max_seqlen_cache", + } + ) + + def __init__( + self, + *, + data: dict[str, torch.Tensor], + keys: dict[str, set[str]], + batch_idx: torch.Tensor, + node_counts: torch.Tensor, + edge_counts: torch.Tensor, + num_graphs: int, + max_seqlen: int | None = None, + ) -> None: + object.__setattr__(self, "_data", data) + object.__setattr__( + self, "_keys", {level: set(keys.get(level, ())) for level in keys} + ) + for level in ("node", "edge", "system"): + self._keys.setdefault(level, set()) + object.__setattr__(self, "_batch_idx", batch_idx.long()) + object.__setattr__(self, "_node_counts", node_counts.long()) + object.__setattr__(self, "_edge_counts", edge_counts.long()) + object.__setattr__(self, "_num_graphs", int(num_graphs)) + object.__setattr__(self, "_max_seqlen_cache", max_seqlen) + + def __getattr__(self, name: str): + data = self.__dict__.get("_data") + if data is not None and name in data: + return data[name] + raise AttributeError(name) + + def __setattr__(self, name: str, value) -> None: + if name == "edge_index": + self._data["edge_index"] = value.long().contiguous() + elif name == "neighbor_list": + self._data["edge_index"] = value.t().long().contiguous() + elif name == "batch": + object.__setattr__(self, "_batch_idx", value.long()) + elif (key := self._ALIASES.get(name)) is not None: + self._data[key] = value + elif name in self.__dict__.get("_data", {}): + self._data[name] = value + else: + object.__setattr__(self, name, value) + + def __contains__(self, key: str) -> bool: + if key == "neighbor_list": + key = "edge_index" + return key in self._data + + def __getitem__(self, key: str): + if key == "neighbor_list": + return self.neighbor_list + return self._data[key] + + def __setitem__(self, key: str, value) -> None: + if key == "neighbor_list": + self.neighbor_list = value + else: + self._data[key] = value + + def __len__(self) -> int: + return self.num_graphs + + @property + def keys(self) -> dict[str, set[str]]: + return self._keys + + @property + def device(self) -> torch.device: + return self.positions.device + + @property + def x(self) -> torch.Tensor: + return self.positions + + @property + def a(self) -> torch.Tensor: + return self.atom_types + + @property + def c(self) -> torch.Tensor | None: + return self._data.get("charge_types") + + @property + def e(self) -> torch.Tensor | None: + return self._data.get("edge_types") + + @property + def edge_index(self) -> torch.Tensor: + return self._data["edge_index"] + + @property + def neighbor_list(self) -> torch.Tensor: + """Return connectivity in the single-graph ``[E, 2]`` layout.""" + return self.edge_index.t() + + @property + def batch(self) -> torch.Tensor: + return self._batch_idx + + @property + def batch_idx(self) -> torch.Tensor: + return self._batch_idx + + @property + def batch_size(self) -> int: + return self._num_graphs + + @property + def num_graphs(self) -> int: + return self._num_graphs + + @property + def num_nodes(self) -> int: + return self.positions.shape[0] + + @property + def num_edges(self) -> int: + return self.edge_index.shape[1] + + @property + def num_nodes_per_graph(self) -> torch.Tensor: + return self._node_counts + + @property + def num_edges_per_graph(self) -> torch.Tensor: + return self._edge_counts + + @property + def max_num_nodes(self) -> int: + return self.max_seqlen + + @property + def max_seqlen(self) -> int: + cached = self._max_seqlen_cache + if cached is None: + cached = ( + int(self._node_counts.max().item()) + if self._node_counts.numel() > 0 + else 0 + ) + object.__setattr__(self, "_max_seqlen_cache", cached) + return cached + + def unpack(self): + """Return ``(x, a, c, e, edge_index, batch)``.""" + return self.x, self.a, self.c, self.e, self.edge_index, self.batch + + def remove_com(self, x_mean: torch.Tensor | None = None) -> torch.Tensor: + return batch_remove_com(self, x_mean) + + def _extra_attrs(self) -> dict: + return { + key: value + for key, value in self.__dict__.items() + if key not in self._INTERNAL_ATTRS + } + + def clone(self) -> ChemBatch: + """Deep-clone all tensor storage and tensor-like loose attributes.""" + out = type(self)( + data={key: value.clone() for key, value in self._data.items()}, + keys={level: set(names) for level, names in self._keys.items()}, + batch_idx=self._batch_idx.clone(), + node_counts=self._node_counts.clone(), + edge_counts=self._edge_counts.clone(), + num_graphs=self._num_graphs, + max_seqlen=self._max_seqlen_cache, + ) + for key, value in self._extra_attrs().items(): + object.__setattr__(out, key, _clone_value(value)) + return out + + @staticmethod + def _move_tensor( + value: torch.Tensor, + device: torch.device, + dtype: torch.dtype | None, + non_blocking: bool, + ) -> torch.Tensor: + target_dtype = ( + dtype if dtype is not None and value.is_floating_point() else None + ) + return value.to(device=device, dtype=target_dtype, non_blocking=non_blocking) + + def to( + self, + device, + dtype: torch.dtype | None = None, + non_blocking: bool = False, + ) -> ChemBatch: + """Return a moved copy, following ``torch.Tensor.to`` semantics.""" + out = self.clone() + return out.to_inplace(device, dtype=dtype, non_blocking=non_blocking) + + def to_inplace( + self, + device, + dtype: torch.dtype | None = None, + non_blocking: bool = True, + ) -> ChemBatch: + """Move this single-use batch without first copying it.""" + device = torch.device(device) + for key, value in self._data.items(): + self._data[key] = self._move_tensor(value, device, dtype, non_blocking) + object.__setattr__( + self, + "_batch_idx", + self._batch_idx.to(device=device, non_blocking=non_blocking), + ) + object.__setattr__( + self, + "_node_counts", + self._node_counts.to(device=device, non_blocking=non_blocking), + ) + object.__setattr__( + self, + "_edge_counts", + self._edge_counts.to(device=device, non_blocking=non_blocking), + ) + for key, value in self._extra_attrs().items(): + if isinstance(value, ChemBatch): + value.to_inplace(device, dtype=dtype, non_blocking=non_blocking) + elif isinstance(value, torch.Tensor): + object.__setattr__( + self, + key, + self._move_tensor(value, device, dtype, non_blocking), + ) + elif hasattr(value, "to"): + object.__setattr__(self, key, value.to(device)) + return self + + def cpu(self) -> ChemBatch: + """Return a CPU copy.""" + return self.to("cpu") + + def pin_memory(self) -> ChemBatch: + """Pin CPU tensor storage in place for asynchronous CUDA transfer.""" + for key, value in self._data.items(): + self._data[key] = value.pin_memory() + object.__setattr__(self, "_batch_idx", self._batch_idx.pin_memory()) + object.__setattr__(self, "_node_counts", self._node_counts.pin_memory()) + object.__setattr__(self, "_edge_counts", self._edge_counts.pin_memory()) + for key, value in self._extra_attrs().items(): + if isinstance(value, torch.Tensor) or hasattr(value, "pin_memory"): + object.__setattr__(self, key, value.pin_memory()) + return self + + def contiguous(self) -> ChemBatch: + """Make all stored tensors contiguous in place.""" + for key, value in self._data.items(): + self._data[key] = value.contiguous() + object.__setattr__(self, "_batch_idx", self._batch_idx.contiguous()) + return self + + def to_data_list(self) -> list[ChemAtomicData]: + """Split this batch into graph-local ``ChemAtomicData`` objects.""" + node_counts = self._node_counts.tolist() + edge_counts = self._edge_counts.tolist() + node_offsets = [0] + edge_offsets = [0] + for count in node_counts: + node_offsets.append(node_offsets[-1] + count) + for count in edge_counts: + edge_offsets.append(edge_offsets[-1] + count) + + graphs = [] + smiles = getattr(self, "smiles", None) + for graph_idx in range(self.num_graphs): + node_slice = slice(node_offsets[graph_idx], node_offsets[graph_idx + 1]) + edge_slice = slice(edge_offsets[graph_idx], edge_offsets[graph_idx + 1]) + edge_index = self.edge_index[:, edge_slice] - node_offsets[graph_idx] + + extra = {} + for key in self._keys["node"]: + if key not in { + "positions", + "atom_types", + "charge_types", + "is_auxiliary", + }: + extra[key] = self._data[key][node_slice] + for key in self._keys["edge"]: + if key not in {"edge_index", "edge_types"}: + extra[key] = self._data[key][edge_slice] + for key in self._keys["system"]: + extra[key] = self._data[key][graph_idx : graph_idx + 1] + + graph = ChemAtomicData( + positions=self.positions[node_slice], + atom_types=self.atom_types[node_slice], + charge_types=( + None + if "charge_types" not in self + else self.charge_types[node_slice] + ), + edge_types=( + None if "edge_types" not in self else self.edge_types[edge_slice] + ), + neighbor_list=edge_index.t().contiguous(), + is_auxiliary=( + None + if "is_auxiliary" not in self + else self.is_auxiliary[node_slice] + ), + extra=extra, + node_extra_keys=self._keys["node"] + - {"positions", "atom_types", "charge_types", "is_auxiliary"}, + edge_extra_keys=self._keys["edge"] - {"edge_index", "edge_types"}, + system_extra_keys=self._keys["system"], + smiles=( + smiles[graph_idx] if isinstance(smiles, (list, tuple)) else smiles + ), + ) + graphs.append(graph) + return graphs + + @classmethod + def from_data_list(cls, graphs: Iterable[ChemAtomicData]) -> ChemBatch: + """Batch an iterable of Morph graphs.""" + return chembatch_from_graphs(list(graphs), attach_smiles=True) + + +def filter_nodes( + data: ChemAtomicData | ChemBatch, mask: torch.Tensor +) -> ChemAtomicData | ChemBatch: + """Keep selected nodes, drop dangling edges, and preserve graph identities.""" + if isinstance(data, ChemAtomicData): + return data.filter_nodes(mask) + + mask = mask.to(torch.bool).view(-1) + batch_idx = data.batch + keep = mask.nonzero(as_tuple=True)[0] + mapping = torch.full((mask.numel(),), -1, dtype=torch.long, device=data.device) + mapping[keep] = torch.arange(keep.numel(), device=data.device) + + edge_index = data.edge_index + if edge_index.numel() > 0: + remapped = mapping[edge_index] + edge_keep = torch.where((remapped[0] >= 0) & (remapped[1] >= 0))[0] + new_edge_index = remapped[:, edge_keep] + edge_graph = batch_idx[edge_index[0, edge_keep]] + else: + edge_keep = torch.empty(0, dtype=torch.long, device=data.device) + new_edge_index = edge_index + edge_graph = torch.empty(0, dtype=torch.long, device=data.device) + + # Resolve each variable-length selection once. Repeating boolean indexing + # for every feature would synchronize the CPU with CUDA each time. + new_batch_idx = batch_idx[keep] + node_counts = torch.bincount(new_batch_idx, minlength=data.num_graphs) + edge_counts = torch.bincount(edge_graph, minlength=data.num_graphs) + node_data = {key: data[key][keep] for key in data.keys["node"]} + edge_data = { + key: data[key][edge_keep] for key in data.keys["edge"] if key != "edge_index" + } + system_data = {key: data[key] for key in data.keys["system"]} + return _chem_batch_from_level_data( + node_data=node_data, + edge_data=edge_data, + system_data=system_data, + edge_index=new_edge_index, + node_counts=node_counts, + edge_counts=edge_counts, + batch_idx=new_batch_idx, + num_graphs=data.num_graphs, + ) + + +def cumulative_node_offsets(data_list: list[ChemAtomicData]) -> list[int]: + """Return the exclusive prefix sum of per-graph node counts.""" + offsets = [] + total = 0 + for data in data_list: + offsets.append(total) + total += data.num_nodes + return offsets + + +def batch_from_flat( + positions: torch.Tensor, + atom_types: torch.Tensor, + charge_types: torch.Tensor | None, + edge_types: torch.Tensor | None, + edge_index: torch.Tensor | None, + batch_idx: torch.Tensor, + *, + num_graphs: int | None = None, + node_extras: dict[str, torch.Tensor] | None = None, + edge_extras: dict[str, torch.Tensor] | None = None, + system_extras: dict[str, torch.Tensor] | None = None, + _already_sorted: bool = False, + _node_counts: torch.Tensor | None = None, + _edge_counts: torch.Tensor | None = None, +) -> ChemBatch: + """Build a graph-contiguous batch from flat tensors. + + Generic inputs are stably grouped by graph. Internal collates can set + ``_already_sorted`` because they construct graph-contiguous tensors directly, + avoiding redundant sorting in the dataloader path. + """ + batch_idx = batch_idx.long() + if num_graphs is None: + num_graphs = int(batch_idx.max().item()) + 1 if batch_idx.numel() else 0 + node_extras = node_extras or {} + edge_extras = edge_extras or {} + system_extras = system_extras or {} + device = positions.device + + if _already_sorted: + sorted_batch = batch_idx + node_data = {"positions": positions, "atom_types": atom_types} + if charge_types is not None: + node_data["charge_types"] = charge_types + node_data.update(node_extras) + else: + order = torch.argsort(batch_idx, stable=True) + inverse = torch.empty_like(order) + inverse[order] = torch.arange(order.numel(), device=device) + sorted_batch = batch_idx[order] + node_data = { + "positions": positions[order], + "atom_types": atom_types[order], + } + if charge_types is not None: + node_data["charge_types"] = charge_types[order] + node_data.update({key: value[order] for key, value in node_extras.items()}) + node_counts = ( + _node_counts + if _node_counts is not None + else torch.bincount(sorted_batch, minlength=num_graphs) + ) + + if edge_index is None: + edge_index = torch.empty((2, 0), dtype=torch.long, device=device) + else: + edge_index = edge_index.long() + edge_data = {} + edge_counts = ( + _edge_counts + if _edge_counts is not None + else torch.zeros(num_graphs, dtype=torch.long, device=device) + ) + if edge_index.numel() > 0: + if not _already_sorted or _edge_counts is None: + edge_graph = batch_idx[edge_index[0]] + if _already_sorted: + if edge_types is not None: + edge_data["edge_types"] = edge_types + edge_data.update(edge_extras) + else: + edge_order = torch.argsort(edge_graph, stable=True) + edge_index = inverse[edge_index][:, edge_order].contiguous() + if edge_types is not None: + edge_data["edge_types"] = edge_types[edge_order] + edge_data.update( + {key: value[edge_order] for key, value in edge_extras.items()} + ) + if _edge_counts is None: + edge_counts = torch.bincount(edge_graph, minlength=num_graphs) + elif edge_types is not None: + edge_data["edge_types"] = edge_types + return _chem_batch_from_level_data( + node_data=node_data, + edge_data=edge_data, + system_data=dict(system_extras), + edge_index=edge_index, + node_counts=node_counts, + edge_counts=edge_counts, + batch_idx=sorted_batch, + num_graphs=num_graphs, + ) + + +def _common_extra_keys(graphs: list[ChemAtomicData], attr: str) -> set[str]: + if not graphs: + return set() + common = set(getattr(graphs[0], attr)) + for graph in graphs[1:]: + common.intersection_update(getattr(graph, attr)) + return common + + +def chembatch_from_graphs( + graphs: list[ChemAtomicData], + *, + node_extra_keys: tuple = (), + edge_extra_keys: tuple = (), + system_extra_keys: tuple = (), + attach_smiles: bool = False, +) -> ChemBatch: + """Batch Morph graphs and route their registered optional tensors.""" + graphs = list(graphs) + if not graphs: + raise ValueError("cannot batch an empty graph list") + node_keys = set(node_extra_keys) | _common_extra_keys(graphs, "node_extra_keys") + edge_keys = set(edge_extra_keys) | _common_extra_keys(graphs, "edge_extra_keys") + system_keys = set(system_extra_keys) | _common_extra_keys( + graphs, "system_extra_keys" + ) + + def _gather(keys: set[str]) -> dict[str, torch.Tensor]: + gathered = {} + for key in keys: + values = [graph.extra.get(key) for graph in graphs] + if all(isinstance(value, torch.Tensor) for value in values): + gathered[key] = torch.cat(values) + return gathered + + positions = torch.cat([graph.positions for graph in graphs]) + atom_types = torch.cat([graph.atom_types for graph in graphs]) + charge_types = ( + torch.cat([graph.charge_types for graph in graphs]) + if all(graph.charge_types is not None for graph in graphs) + else None + ) + edge_types = ( + torch.cat([graph.edge_types for graph in graphs]) + if all(graph.edge_types is not None for graph in graphs) + else None + ) + node_extras = _gather(node_keys) + if all(graph.is_auxiliary is not None for graph in graphs): + node_extras["is_auxiliary"] = torch.cat( + [graph.is_auxiliary for graph in graphs] + ) + + device = positions.device + edge_parts = [] + batch_parts = [] + offset = 0 + for graph_idx, graph in enumerate(graphs): + if graph.edge_index is not None and graph.edge_index.numel() > 0: + edge_parts.append(graph.edge_index + offset) + batch_parts.append( + torch.full((graph.num_nodes,), graph_idx, dtype=torch.long, device=device) + ) + offset += graph.num_nodes + edge_index = ( + torch.cat(edge_parts, dim=1) + if edge_parts + else torch.empty((2, 0), dtype=torch.long, device=device) + ) + batch = batch_from_flat( + positions, + atom_types, + charge_types, + edge_types, + edge_index, + torch.cat(batch_parts), + num_graphs=len(graphs), + node_extras=node_extras, + edge_extras=_gather(edge_keys), + system_extras=_gather(system_keys), + _already_sorted=True, + ) + object.__setattr__(batch, "_max_seqlen_cache", max(g.num_nodes for g in graphs)) + if attach_smiles: + smiles = [graph.smiles for graph in graphs] + if any(value is not None for value in smiles): + batch.smiles = smiles + return batch + + +def _chem_batch_from_level_data( + *, + node_data: dict[str, torch.Tensor], + edge_data: dict[str, torch.Tensor], + system_data: dict[str, torch.Tensor], + edge_index: torch.Tensor, + node_counts: torch.Tensor, + edge_counts: torch.Tensor, + batch_idx: torch.Tensor, + num_graphs: int, +) -> ChemBatch: + data = {**node_data, **edge_data, **system_data, "edge_index": edge_index} + return ChemBatch( + data=data, + keys={ + "node": set(node_data), + "edge": set(edge_data) | {"edge_index"}, + "system": set(system_data), + }, + batch_idx=batch_idx, + node_counts=node_counts, + edge_counts=edge_counts, + num_graphs=num_graphs, + ) diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/containers.py b/geom/explicit_h/runtime/src/morph/dataset/core/containers.py new file mode 100644 index 0000000000000000000000000000000000000000..a333168fd99072070f3f38e9d62ac0347cb73d42 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/containers.py @@ -0,0 +1,525 @@ +"""Native per-graph container and RDKit conversion helpers. + +``ChemAtomicData`` is Morph's single graph representation. It deliberately +contains only the tensors used by the generative model and lightweight metadata +that tells the batcher whether an optional tensor belongs to nodes, edges, or +the whole graph. Construction and topology edits do not invoke schema +validation; dataset adapters are responsible for validating external input. +""" + +# The short alias properties and mapping protocol are documented on the class. +# ruff: noqa: D102, D105 + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np +import torch +from rdkit import Chem +from torch_geometric.utils import to_dense_adj + +from morph.utils.rdkit_utils import BOND_IDX_MAP, IDX_BOND_MAP, tensors_to_rdkit_mol +from morph.utils.vocab_utils import index_to_token + +if TYPE_CHECKING: + from collections.abc import Iterable + + from morph.dataset.core.vocab import Vocab + + +def rdkit_to_token_payload(mol, vocab: Vocab, sanitize: bool = False) -> dict: + """Convert an RDKit molecule to Morph's token-index graph tensors.""" + if sanitize: + mol = Chem.Mol(mol) + Chem.SanitizeMol(mol) + if mol.GetNumConformers() == 0: + raise ValueError("from_rdkit requires a mol with a 3D conformer") + + atoms = list(mol.GetAtoms()) + atom_types = torch.tensor( + [vocab.atom_tokens.index(a.GetSymbol()) for a in atoms], dtype=torch.long + ) + charge_types = torch.tensor( + [vocab.charge_tokens.index(str(a.GetFormalCharge())) for a in atoms], + dtype=torch.long, + ) + positions = torch.tensor(mol.GetConformer().GetPositions(), dtype=torch.float32) + + rows, cols, edge_types = [], [], [] + for bond in mol.GetBonds(): + i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + edge_type = vocab.edge_tokens.index(str(BOND_IDX_MAP[bond.GetBondType()])) + rows += [i, j] + cols += [j, i] + edge_types += [edge_type, edge_type] + return { + "positions": positions, + "atom_types": atom_types, + "charge_types": charge_types, + "edge_types": torch.tensor(edge_types, dtype=torch.long), + "neighbor_list": torch.tensor([rows, cols], dtype=torch.long).t().contiguous(), + } + + +def _remap_neighbor_list( + mask: torch.Tensor, + neighbor_list: torch.Tensor | None, + num_nodes: int, + node_index_map: torch.Tensor | None = None, +) -> tuple[torch.Tensor | None, torch.Tensor | None, torch.Tensor | None]: + """Compact node indices under a keep mask and drop dangling edges.""" + if neighbor_list is None or neighbor_list.numel() == 0: + return neighbor_list, None, node_index_map + device = neighbor_list.device + if node_index_map is None: + keep = mask.nonzero(as_tuple=True)[0] + node_index_map = torch.full((num_nodes,), -1, dtype=torch.long, device=device) + node_index_map[keep] = torch.arange(keep.numel(), device=device) + remapped = node_index_map[neighbor_list] + edge_keep = (remapped[:, 0] >= 0) & (remapped[:, 1] >= 0) + return remapped[edge_keep], edge_keep, node_index_map + + +# Dataset/conditioning fields known by the shared collates. A graph can register +# arbitrary additional fields through add_{node,edge,system}_property as well. +_NODE_CONDITION_KEYS = ("keep_mask", "fragment_mask", "linker_mask", "anchors") +_SYSTEM_CONDITION_KEYS = ("y", "qed", "logp") +_STRING_CONDITION_KEYS = ("smiles",) +_CONDITION_KEYS = ( + *_NODE_CONDITION_KEYS, + *_SYSTEM_CONDITION_KEYS, + *_STRING_CONDITION_KEYS, +) + + +class ChemAtomicData: + """A token-index geometric graph used throughout Morph. + + Core tensors use descriptive storage names and expose the established Morph + aliases ``x``/``a``/``c``/``e``/``edge_index``. Connectivity is stored as an + ``[E, 2]`` neighbor list on single graphs because topology-edit operations + index both endpoints together. Optional tensors live in ``extra`` and are + explicitly registered at node, edge, or graph level for batching. + """ + + __slots__ = ( + "_edge_extra_keys", + "_node_extra_keys", + "_system_extra_keys", + "atom_types", + "charge_types", + "edge_types", + "extra", + "is_auxiliary", + "neighbor_list", + "positions", + "smiles", + ) + + _CORE_SLOTS = frozenset(__slots__) + _NODE_CORE_FIELDS = ("positions", "atom_types", "charge_types", "is_auxiliary") + + def __init__( + self, + positions: torch.Tensor, + atom_types: torch.Tensor, + charge_types: torch.Tensor | None = None, + edge_types: torch.Tensor | None = None, + neighbor_list: torch.Tensor | None = None, + is_auxiliary: torch.Tensor | None = None, + *, + extra: dict | None = None, + node_extra_keys: Iterable[str] = (), + edge_extra_keys: Iterable[str] = (), + system_extra_keys: Iterable[str] = (), + smiles: str | None = None, + ) -> None: + object.__setattr__(self, "positions", positions) + object.__setattr__(self, "atom_types", atom_types) + object.__setattr__(self, "charge_types", charge_types) + object.__setattr__(self, "edge_types", edge_types) + object.__setattr__(self, "neighbor_list", neighbor_list) + object.__setattr__(self, "is_auxiliary", is_auxiliary) + object.__setattr__(self, "extra", dict(extra) if extra is not None else {}) + object.__setattr__(self, "smiles", smiles) + object.__setattr__(self, "_node_extra_keys", set(node_extra_keys)) + object.__setattr__(self, "_edge_extra_keys", set(edge_extra_keys)) + object.__setattr__(self, "_system_extra_keys", set(system_extra_keys)) + + def __setattr__(self, name: str, value) -> None: + aliases = { + "x": "positions", + "a": "atom_types", + "c": "charge_types", + "e": "edge_types", + } + if name == "edge_index": + object.__setattr__( + self, + "neighbor_list", + None if value is None else value.t().contiguous().long(), + ) + elif (slot := aliases.get(name)) is not None: + object.__setattr__(self, slot, value) + elif name in self._CORE_SLOTS: + object.__setattr__(self, name, value) + else: + self.extra[name] = value + + def __getattr__(self, name: str): + if name.startswith("__") or name == "extra": + raise AttributeError(name) + try: + return self.extra[name] + except (KeyError, AttributeError): + raise AttributeError(name) from None + + def __delattr__(self, name: str) -> None: + if name in self._CORE_SLOTS: + object.__delattr__(self, name) + return + try: + del self.extra[name] + except KeyError: + raise AttributeError(name) from None + self._node_extra_keys.discard(name) + self._edge_extra_keys.discard(name) + self._system_extra_keys.discard(name) + + def __contains__(self, key: str) -> bool: + if key in self._CORE_SLOTS: + return getattr(self, key, None) is not None + return key in self.extra + + def __getitem__(self, key: str): + return getattr(self, key) + + @property + def num_nodes(self) -> int: + return self.positions.shape[0] + + @property + def num_edges(self) -> int: + return 0 if self.neighbor_list is None else self.neighbor_list.shape[0] + + @property + def device(self) -> torch.device: + return self.positions.device + + @property + def x(self) -> torch.Tensor: + return self.positions + + @property + def a(self) -> torch.Tensor: + return self.atom_types + + @property + def c(self) -> torch.Tensor | None: + return self.charge_types + + @property + def e(self) -> torch.Tensor | None: + return self.edge_types + + @property + def edge_index(self) -> torch.Tensor | None: + return None if self.neighbor_list is None else self.neighbor_list.t() + + @property + def node_extra_keys(self) -> frozenset[str]: + return frozenset(self._node_extra_keys) + + @property + def edge_extra_keys(self) -> frozenset[str]: + return frozenset(self._edge_extra_keys) + + @property + def system_extra_keys(self) -> frozenset[str]: + return frozenset(self._system_extra_keys) + + def add_node_property(self, key: str, value, node_dim: int = 0) -> None: + del node_dim # Node tensors are consistently node-major in Morph. + self.extra[key] = value + self._node_extra_keys.add(key) + self._edge_extra_keys.discard(key) + self._system_extra_keys.discard(key) + + def add_edge_property(self, key: str, value) -> None: + self.extra[key] = value + self._edge_extra_keys.add(key) + self._node_extra_keys.discard(key) + self._system_extra_keys.discard(key) + + def add_system_property(self, key: str, value) -> None: + self.extra[key] = value + self._system_extra_keys.add(key) + self._node_extra_keys.discard(key) + self._edge_extra_keys.discard(key) + + def unpack(self): + """Return ``(x, a, c, e, edge_index)``.""" + return self.x, self.a, self.c, self.e, self.edge_index + + def get_edge_types_triu(self, no_bond_idx: int = 0): + """Return upper-triangular edge types and their ``[2, E]`` indices.""" + dense = to_dense_adj( + self.edge_index, edge_attr=self.edge_types, max_num_nodes=self.num_nodes + ).squeeze() + if no_bond_idx != 0: + present = ( + to_dense_adj(self.edge_index, max_num_nodes=self.num_nodes) + .squeeze() + .bool() + ) + dense = dense.masked_fill(~present, no_bond_idx) + edge_index_triu = torch.triu_indices( + self.num_nodes, self.num_nodes, offset=1, device=self.device + ) + return dense[edge_index_triu[0], edge_index_triu[1]], edge_index_triu + + def to_rdkit_mol( + self, + atom_tokens: list[str], + edge_tokens: list[str], + charge_tokens: list[str], + sanitize: bool = True, + *, + implicit_hydrogens: bool | None = None, + ): + """Convert to RDKit; H-free vocabularies default to implicit hydrogens. + + This uses the vocabulary, never the atoms in an individual sample: a + missing H in an explicit-H model must still count as a chemistry error. + """ + if self.num_nodes == 0: + return None + + # Hydra can supply ListConfig vocabularies. Resolve each small table + # once instead of repeating config lookups for every atom and pair. + atom_tokens = tuple(atom_tokens) + edge_tokens = tuple(edge_tokens) + charge_tokens = tuple(charge_tokens) + + atom_indices = self.atom_types.detach().cpu().numpy() + positions = self.positions.detach().cpu().numpy() + charge_indices = self.charge_types.detach().cpu().numpy() + atom_names = [index_to_token(atom_tokens, int(index)) for index in atom_indices] + charges = [ + int(index_to_token(charge_tokens, int(index))) for index in charge_indices + ] + + bond_types = [] + bond_indices = [] + if self.num_nodes > 1: + edge_types_triu, edge_index_triu = self.get_edge_types_triu( + no_bond_idx=edge_tokens.index("") + ) + indices = ( + edge_types_triu.detach().cpu().numpy().astype(np.int64, copy=False) + ) + # Convert only chemical bonds to Python objects; dense graphs mostly + # contain pairs. Preserve upper-triangle insertion order. + is_bond = np.array([token != "" for token in edge_tokens])[indices] + bond_indices = edge_index_triu.detach().cpu().numpy()[:, is_bond].T.tolist() + bond_types = [ + IDX_BOND_MAP[int(edge_tokens[index])] for index in indices[is_bond] + ] + + return tensors_to_rdkit_mol( + atom_names, + positions, + charges, + bond_types, + bond_indices, + sanitize=sanitize, + implicit_hydrogens=( + "H" not in atom_tokens + if implicit_hydrogens is None + else implicit_hydrogens + ), + ) + + def to_rdkit(self, vocab: Vocab, sanitize: bool = True): + """Convert this graph to an RDKit molecule using ``vocab``.""" + return self.to_rdkit_mol( + vocab.atom_tokens, + vocab.edge_tokens, + vocab.charge_tokens, + sanitize=sanitize, + ) + + @classmethod + def from_rdkit(cls, mol, vocab: Vocab, sanitize: bool = False) -> ChemAtomicData: + """Build a graph from an RDKit molecule.""" + return cls(**rdkit_to_token_payload(mol, vocab, sanitize)) + + @classmethod + def from_atomic(cls, data: ChemAtomicData, clone: bool = True) -> ChemAtomicData: + """Copy ``data`` and ensure it carries an auxiliary-node mask.""" + + def _copy(value): + return value.clone() if clone and isinstance(value, torch.Tensor) else value + + is_auxiliary = data.is_auxiliary + if is_auxiliary is None: + is_auxiliary = torch.zeros( + (data.num_nodes, 1), dtype=torch.bool, device=data.device + ) + else: + is_auxiliary = _copy(is_auxiliary) + return cls( + positions=_copy(data.positions), + atom_types=_copy(data.atom_types), + charge_types=_copy(data.charge_types), + edge_types=_copy(data.edge_types), + neighbor_list=_copy(data.neighbor_list), + is_auxiliary=is_auxiliary, + extra={key: _copy(value) for key, value in data.extra.items()}, + node_extra_keys=data.node_extra_keys, + edge_extra_keys=data.edge_extra_keys, + system_extra_keys=data.system_extra_keys, + smiles=data.smiles, + ) + + def filter_nodes( + self, mask: torch.Tensor, node_index_map: torch.Tensor | None = None + ) -> ChemAtomicData: + """Return a graph containing only nodes selected by ``mask``.""" + mask = mask.to(torch.bool).view(-1) + neighbor_list, edge_keep, _ = _remap_neighbor_list( + mask, self.neighbor_list, self.num_nodes, node_index_map + ) + + extra = dict(self.extra) + for key in self._node_extra_keys: + value = extra.get(key) + if isinstance(value, torch.Tensor): + extra[key] = value[mask] + if edge_keep is not None: + for key in self._edge_extra_keys: + value = extra.get(key) + if isinstance(value, torch.Tensor): + extra[key] = value[edge_keep] + + return type(self)( + positions=self.positions[mask], + atom_types=self.atom_types[mask], + charge_types=( + None if self.charge_types is None else self.charge_types[mask] + ), + edge_types=( + self.edge_types + if self.edge_types is None or edge_keep is None + else self.edge_types[edge_keep] + ), + neighbor_list=neighbor_list, + is_auxiliary=( + None if self.is_auxiliary is None else self.is_auxiliary[mask] + ), + extra=extra, + node_extra_keys=self._node_extra_keys, + edge_extra_keys=self._edge_extra_keys, + system_extra_keys=self._system_extra_keys, + smiles=self.smiles, + ) + + def pad(self, num_auxiliary: int) -> ChemAtomicData: + """Append zero-initialized auxiliary nodes in place.""" + if num_auxiliary <= 0: + return self + old_num_nodes = self.num_nodes + if self.is_auxiliary is None: + self.is_auxiliary = torch.zeros( + (old_num_nodes, 1), dtype=torch.bool, device=self.device + ) + for name in self._NODE_CORE_FIELDS: + value = getattr(self, name) + if isinstance(value, torch.Tensor): + padding = torch.zeros( + (num_auxiliary, *value.shape[1:]), + dtype=value.dtype, + device=value.device, + ) + object.__setattr__(self, name, torch.cat([value, padding])) + for key in self._node_extra_keys: + value = self.extra.get(key) + if isinstance(value, torch.Tensor): + padding = torch.zeros( + (num_auxiliary, *value.shape[1:]), + dtype=value.dtype, + device=value.device, + ) + self.extra[key] = torch.cat([value, padding]) + self.is_auxiliary[old_num_nodes:] = True + return self + + def permute_nodes(self, indices: torch.Tensor | list[int]) -> ChemAtomicData: + """Reorder nodes and remap connectivity in place.""" + perm = torch.as_tensor(indices, device=self.device, dtype=torch.long) + if self.neighbor_list is not None and self.neighbor_list.numel() > 0: + mapping = torch.full( + (self.num_nodes,), -1, dtype=torch.long, device=self.device + ) + mapping[perm] = torch.arange(perm.numel(), device=self.device) + self.neighbor_list = mapping[self.neighbor_list] + for name in self._NODE_CORE_FIELDS: + value = getattr(self, name) + if isinstance(value, torch.Tensor): + object.__setattr__(self, name, value[perm]) + for key in self._node_extra_keys: + value = self.extra.get(key) + if isinstance(value, torch.Tensor): + self.extra[key] = value[perm] + return self + + def clone(self) -> ChemAtomicData: + """Deep-clone tensor fields while preserving non-tensor metadata.""" + + def _copy(value): + return value.clone() if isinstance(value, torch.Tensor) else value + + return type(self)( + positions=_copy(self.positions), + atom_types=_copy(self.atom_types), + charge_types=_copy(self.charge_types), + edge_types=_copy(self.edge_types), + neighbor_list=_copy(self.neighbor_list), + is_auxiliary=_copy(self.is_auxiliary), + extra={key: _copy(value) for key, value in self.extra.items()}, + node_extra_keys=self._node_extra_keys, + edge_extra_keys=self._edge_extra_keys, + system_extra_keys=self._system_extra_keys, + smiles=self.smiles, + ) + + +def make_chem_atomic( + x: torch.Tensor, + a: torch.Tensor, + c: torch.Tensor | None = None, + e: torch.Tensor | None = None, + edge_index: torch.Tensor | None = None, + **conditions, +) -> ChemAtomicData: + """Build a graph from Morph's canonical short-form tensor arguments.""" + graph = ChemAtomicData( + positions=x, + atom_types=a.long(), + charge_types=None if c is None else c.long(), + edge_types=None if e is None else e.long(), + neighbor_list=( + None if edge_index is None else edge_index.t().contiguous().long() + ), + ) + for key, value in conditions.items(): + if value is None: + continue + if key in _NODE_CONDITION_KEYS: + graph.add_node_property(key, value) + elif key in _SYSTEM_CONDITION_KEYS: + graph.add_system_property(key, value) + else: + setattr(graph, key, value) + return graph diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/conversion.py b/geom/explicit_h/runtime/src/morph/dataset/core/conversion.py new file mode 100644 index 0000000000000000000000000000000000000000..1b315bee33812bd1f7c5833ff9869a1505612274 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/conversion.py @@ -0,0 +1,53 @@ +"""Convert sanitized RDKit conformers to the shared molecular graph fields.""" + +import torch +from rdkit import Chem +from torch_geometric.data import Data + +from morph.utils.rdkit_utils import BOND_IDX_MAP, mol_is_valid, smiles_from_mol + + +def mol_to_data( + mol: Chem.Mol, + *, + hydrogen_mode: str = "explicit", + max_atoms: int | None = None, + allowed_atom_symbols: tuple[str, ...] | None = None, +) -> Data | None: + """Filter a sanitized conformer and encode its explicit or heavy-atom graph. + + Filters use the source atom count. RDKit removes H before extracting fields, + preserving heavy-atom order, coordinates, charges, and stereochemistry. + """ + if hydrogen_mode not in ("explicit", "implicit"): + raise ValueError("hydrogen_mode must be explicit or implicit") + if not mol_is_valid(mol, allow_charged=True): + return None + if max_atoms is not None and mol.GetNumAtoms() > max_atoms: + return None + if allowed_atom_symbols is not None and any( + atom.GetSymbol() not in allowed_atom_symbols for atom in mol.GetAtoms() + ): + return None + if hydrogen_mode == "implicit": + if all(atom.GetAtomicNum() == 1 for atom in mol.GetAtoms()): + raise ValueError("Cannot represent a molecule with no heavy atoms") + mol = Chem.RemoveAllHs(mol) + Chem.Kekulize(mol, clearAromaticFlags=True) + pairs, labels = [], [] + for bond in mol.GetBonds(): + i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + pairs.extend(((i, j), (j, i))) + labels.extend([BOND_IDX_MAP[bond.GetBondType()]] * 2) + edges = torch.tensor(pairs, dtype=torch.long).reshape(-1, 2).t() + order = (edges[0] * mol.GetNumAtoms() + edges[1]).argsort() + data = Data( + z=torch.tensor([atom.GetAtomicNum() for atom in mol.GetAtoms()]), + pos=torch.tensor(mol.GetConformer().GetPositions(), dtype=torch.float), + charges=torch.tensor([atom.GetFormalCharge() for atom in mol.GetAtoms()]), + edge_index=edges[:, order].contiguous(), + edge_attr=torch.tensor(labels, dtype=torch.long)[order], + smiles=smiles_from_mol(mol, canonical=True) or "", + ) + + return data if torch.isfinite(data.pos).all() else None diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/flow_matching_wrapper.py b/geom/explicit_h/runtime/src/morph/dataset/core/flow_matching_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..7fa8efe43e66c34041cdb98ba9e1201ea42047a2 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/flow_matching_wrapper.py @@ -0,0 +1,205 @@ +import numpy as np +import torch +from scipy.spatial.transform import Rotation +from torch.utils.data import Dataset + +from morph.dataset.core.atomic_data import chembatch_from_graphs +from morph.dataset.core.insertion_targets import collate_insertion_targets +from morph.dataset.core.representation import ( + Representation, + project_molecule_to_representation, + validate_representation, +) +from morph.dataset.core.vocab import Vocab +from morph.flow_matching.prior import sample_prior_graph + + +def uniform_n_atoms_range(distributions, padding: int = 5) -> tuple[int, int]: + """Return the uniform source-count bounds around the empirical support.""" + padding = int(padding) + if padding < 0: + raise ValueError(f"uniform n-atoms padding must be non-negative, got {padding}") + support = (distributions.n_atoms_distribution > 0).nonzero(as_tuple=True)[0] + return ( + max(int(support[0].item()) - padding, 3), + int(support[-1].item()) + padding, + ) + + +class FlowMatchingDatasetWrapper(Dataset): + """Create interpolated training items inside DataLoader workers. + + Training returns ``(mol_t, mol_1, insertion_targets, t)``; validation and + test return a sampled prior and target pair. Multiple augmentations share + the same interpolation and differ only by rotation. + """ + + def __init__( + self, + base_dataset, + distributions, + interpolator, + vocab: Vocab, + representation: str | Representation = Representation.MOLECULE, + n_atoms_strategy="uniform", + uniform_n_atoms_padding: int = 5, + time_dist=None, + stage="train", + rotate: bool = False, + n_augmentations: int = 1, + size_sampler=None, + ): + self.representation = Representation(representation) + caps = getattr(type(base_dataset), "CAPABILITIES", None) + if caps is None: + raise ValueError( + f"Dataset {type(base_dataset).__name__} must declare a " + f"CAPABILITIES attribute to be used with a representation." + ) + validate_representation(caps, self.representation) + + self.base_dataset = base_dataset + self.distributions = distributions + self.interpolator = interpolator + self.vocab = vocab + self.n_atoms_strategy = n_atoms_strategy + self.uniform_n_atoms_padding = int(uniform_n_atoms_padding) + # An optional learned sampler can travel with a checkpoint and be attached + # post-construction instead of re-deriving the starting-size rule here. + self.size_sampler = size_sampler + self.time_dist = time_dist + self.stage = stage + self.rotate = rotate + self.n_augmentations = max(1, int(n_augmentations)) + + if self.n_atoms_strategy == "uniform": + self._n_atoms_min, self._n_atoms_max = uniform_n_atoms_range( + self.distributions, self.uniform_n_atoms_padding + ) + + def _get_n_atoms(self, target): + if self.n_atoms_strategy == "approx": + n = target.num_nodes + int((torch.randn(1) * 2).round().item()) + return max(3, n) + if self.n_atoms_strategy == "uniform": + return int( + torch.randint(self._n_atoms_min, self._n_atoms_max + 1, (1,)).item() + ) + if self.n_atoms_strategy == "learned": + if self.size_sampler is None: + raise ValueError( + 'n_atoms_strategy="learned" requires a trained `size_sampler`' + ) + return int(self.size_sampler.sample(1).item()) + if self.n_atoms_strategy == "empirical": + return None + raise ValueError( + f"unknown n_atoms_strategy {self.n_atoms_strategy!r}; expected one " + "of 'uniform', 'approx', 'empirical', 'learned'" + ) + + def _rotate(self, *molecules): + """Apply one shared random rotation to a group of molecular states.""" + if self.rotate: + R = torch.from_numpy(Rotation.random(1).as_matrix()[0]).to( + dtype=molecules[0].positions.dtype + ) + for mol in molecules: + mol.positions = mol.positions @ R + + def _augment(self, mol_t, mol_1, ins_targets, t): + """Return one copy with a shared random rotation across all targets.""" + augmented = tuple(mol.clone() for mol in (mol_t, mol_1, ins_targets)) + self._rotate(*augmented) + return *augmented, t + + def _training_output(self, mol_t, mol_1, ins_targets, t): + """Apply configured rotations and return one or several training items.""" + if self.n_augmentations == 1: + self._rotate(mol_t, mol_1, ins_targets) + return mol_t, mol_1, ins_targets, t + return [ + self._augment(mol_t, mol_1, ins_targets, t) + for _ in range(self.n_augmentations) + ] + + def _interpolate_train_atomic( + self, + source_atomic, + target, + t, + scaffold_pairs=None, + ): + """Interpolate a training pair into native Morph graph objects. + + Insertion targets retain their structured bond records. The collator + offsets their current-node and inserted-node indices independently. + """ + interp, target_state, ins = self.interpolator.interpolate_single( + source_atomic, + target, + t.item(), + scaffold_pairs=scaffold_pairs, + ) + return interp, target_state, ins + + def __getitem__(self, index): + """Build one interpolated source/target training example.""" + target = self.base_dataset[index] + target = project_molecule_to_representation( + target, self.vocab, self.representation + ) + + n_atoms = self._get_n_atoms(target) + + if self.stage == "train": + t = self.time_dist.sample((1,)).squeeze(0) + t = torch.clamp(t, min=0.0, max=1 - 1e-8) + + mol_t, mol_1, ins_targets = self._interpolate_train_atomic( + sample_prior_graph(self.distributions, n_atoms=n_atoms), target, t + ) + + if hasattr(target, "y") and target.y is not None: + mol_t.add_system_property("y", target.y) + + return self._training_output(mol_t, mol_1, ins_targets, t) + + sample = sample_prior_graph(self.distributions, n_atoms=n_atoms) + return sample, target + + def __len__(self): + """Return the number of examples in the wrapped dataset.""" + return len(self.base_dataset) + + +def train_collate_fn(batch): + """Batch molecular states and structured insertion supervision.""" + samples = [ + sample + for item in batch + for sample in (item if isinstance(item, list) else [item]) + ] + mols_t, mols_1, insertions, times = zip(*samples, strict=True) + return ( + chembatch_from_graphs(list(mols_t)), + chembatch_from_graphs(list(mols_1)), + collate_insertion_targets(list(insertions), [mol.num_nodes for mol in mols_t]), + torch.stack(times), + ) + + +def eval_collate_fn(batch): + """Collate prior/target pairs into native ``ChemBatch`` objects.""" + samples, targets = zip(*batch, strict=True) + return ( + chembatch_from_graphs(list(samples)), + chembatch_from_graphs(list(targets)), + ) + + +def worker_init_fn(_worker_id): + """Seed numpy per worker so scipy/numpy randomness diverges across workers.""" + info = torch.utils.data.get_worker_info() + seed = info.seed % (2**32) + np.random.seed(seed) diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/insertion_data.py b/geom/explicit_h/runtime/src/morph/dataset/core/insertion_data.py new file mode 100644 index 0000000000000000000000000000000000000000..d8415a4e5bd055330292aab3a0f59dec913cc372 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/insertion_data.py @@ -0,0 +1,350 @@ +"""Atom insertion / deletion editing ops for molecule batches. + +Split out of :mod:`morph.dataset.core.atomic_data`: the point-cloud +insertion primitives -- bundling freshly inserted atoms +(:class:`InsertedAtoms`), pairing them within a graph, sampling new edge +types, and joining them into an existing :class:`ChemBatch` +with either sampled (:func:`join_with_atoms`) or model-predicted +(:func:`join_with_predicted_edges`) bonds. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import torch + +from morph.dataset.core.atomic_data import ChemBatch, batch_from_flat +from morph.utils.edge_alignment import EDGE_ALIGNER + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass +class InsertedAtoms: + """Flat features of freshly inserted atoms (the point-cloud analogue). + + Bundles the position / atom-type / charge-type tensors that were otherwise + threaded around as loose ``ins_x`` / ``ins_a`` / ``ins_c`` triplets, with an + optional per-atom ``batch`` assignment. Indexing gathers every field at once, + so ``atoms[idx]`` replaces three (or more) parallel gathers and is how callers + select an edge-aligned subset to feed the insertion-edge head. + """ + + x: torch.Tensor # [N, 3] positions + a: torch.Tensor # [N] atom-type tokens + c: torch.Tensor # [N] charge tokens + batch: torch.Tensor | None = None # [N] graph id per inserted atom + + def __getitem__(self, idx) -> InsertedAtoms: + """Select matching position, atom, charge, and batch entries.""" + return InsertedAtoms( + self.x[idx], + self.a[idx], + self.c[idx], + None if self.batch is None else self.batch[idx], + ) + + +def insertion_pair_indices( + ins_batch: torch.Tensor, + spawn_orig_indices: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Build inserted-atom pairs and both endpoints' spawn-node indices. + + ``ins_batch`` is the per-inserted-atom graph id and ``spawn_orig_indices`` the + per-inserted-atom original spawn-node index (both length ``N_ins``, aligned). + ``ins_batch`` must be graph-contiguous, as it is in a :class:`ChemBatch`. + Pairs are emitted per graph in ascending graph order. Returns four empty + long tensors when no graph holds at least two inserted atoms. + """ + if ins_batch.numel() == 0: + empty = ins_batch.new_zeros(0, dtype=torch.long) + return empty, empty.clone(), empty.clone(), empty.clone() + + counts = torch.bincount(ins_batch) + max_count = int(counts.max()) + if max_count < 2: + empty = ins_batch.new_zeros(0, dtype=torch.long) + return empty, empty.clone(), empty.clone(), empty.clone() + + # Build one local upper triangle, then retain the valid prefix for each + # graph. This avoids one GPU launch and Python iteration per graph while + # using O(num_graphs * max_insertions_per_graph**2) temporary storage rather + # than an O(total_insertions**2) cross-graph matrix. + local_src, local_dst = torch.triu_indices( + max_count, max_count, offset=1, device=ins_batch.device + ) + graph_idx, pair_idx = torch.where(local_dst.unsqueeze(0) < counts.unsqueeze(1)) + offsets = counts.cumsum(0) - counts + src = offsets[graph_idx] + local_src[pair_idx] + dst = offsets[graph_idx] + local_dst[pair_idx] + return src, dst, spawn_orig_indices[src], spawn_orig_indices[dst] + + +def sample_edge_types(edge_dist: torch.Tensor, num_edges: int) -> torch.Tensor: + """Draw ``num_edges`` edge-type token indices iid from a categorical prior. + + Helper for sampling new edge types *outside* the join, then passing the + resulting tensor into :func:`join_with_atoms`. + """ + return torch.multinomial(edge_dist, num_edges, replacement=True) + + +def _new_atom_edge_count(num_mol: int, num_atom: int) -> int: + """Number of new upper-triangular edges when ``num_atom`` atoms join a mol.""" + return num_mol * num_atom + num_atom * (num_atom - 1) // 2 + + +def count_new_atom_edges(mol_batch: ChemBatch, atoms_batch: ChemBatch) -> int: + """Total new edges :func:`join_with_atoms` will create across the batch. + + The caller uses this to know how many edge types to sample (e.g. via + :func:`sample_edge_types`) before calling :func:`join_with_atoms`. + """ + mol_n = mol_batch.num_nodes_per_graph.to(torch.long) + atom_n = atoms_batch.num_nodes_per_graph.to(torch.long) + return int((mol_n * atom_n + atom_n * (atom_n - 1) // 2).sum()) + + +def join_with_atoms( + mol_batch: ChemBatch, + atoms_batch: ChemBatch, + new_edge_types: torch.Tensor, +) -> ChemBatch: + """Join a batch of molecules with a batch of new atoms (per graph). + + Each new atom set is + fully connected to its molecule. Edge sampling is *not* done here: the caller + samples the new edge types beforehand (e.g. ``new_edge_types = + sample_edge_types(edge_dist, count_new_atom_edges(mol_batch, atoms_batch))``) + and passes the flat tensor in. It holds one type per new upper-triangular + edge, concatenated graph by graph in (mol->atom, then atom->atom) order. + + The graph is assembled in the concatenated ``[existing, inserted]`` node space + and re-batched via :func:`batch_from_flat` (which restores graph-contiguous + ordering and per-graph offsets) -- the same flat-tensor path as + :func:`join_with_predicted_edges`, so no per-graph ``ChemAtomicData`` objects + are constructed. The small per-graph loop only assembles edge *indices* (cheap + tensor ops) in the order ``new_edge_types`` is laid out. + """ + device = mol_batch["positions"].device + num_existing = mol_batch.num_nodes + num_graphs = mol_batch.num_graphs + mol_batch_idx = mol_batch.batch_idx.long() + atoms_batch_idx = atoms_batch.batch_idx.long() + + positions = torch.cat([mol_batch["positions"], atoms_batch["positions"]]) + atom_types = torch.cat([mol_batch["atom_types"], atoms_batch["atom_types"]]) + charge_types = None + if "charge_types" in mol_batch and "charge_types" in atoms_batch: + charge_types = torch.cat( + [mol_batch["charge_types"], atoms_batch["charge_types"]] + ) + new_batch = torch.cat([mol_batch_idx, atoms_batch_idx]) + + if mol_batch.edge_index.numel() > 0: + existing_edge_index = mol_batch.edge_index + existing_e = mol_batch["edge_types"] + else: + existing_edge_index = torch.empty((2, 0), dtype=torch.long, device=device) + existing_e = torch.empty((0,), dtype=torch.long, device=device) + + # New upper-tri edges per graph, in the combined node space ([mol nodes, + # then inserted atoms]) in count order (mol->atom block, + # then atom->atom) so ``new_edge_types`` is consumed graph by graph. + triu_list: list[torch.Tensor] = [] + e_list: list[torch.Tensor] = [] + start = 0 + for g in range(num_graphs): + mol_g = torch.where(mol_batch_idx == g)[0] + atom_g = torch.where(atoms_batch_idx == g)[0] + num_existing + num_mol = int(mol_g.numel()) + num_atom = int(atom_g.numel()) + count = _new_atom_edge_count(num_mol, num_atom) + if count == 0: + continue + blocks: list[torch.Tensor] = [] + if num_mol > 0 and num_atom > 0: + blocks.append(torch.cartesian_prod(mol_g, atom_g).t()) # mol -> atom + if num_atom > 1: + atom_atom = torch.cartesian_prod(atom_g, atom_g).t() + atom_atom = atom_atom[:, atom_atom[0] < atom_atom[1]] + if atom_atom.shape[1] > 0: + blocks.append(atom_atom) + triu_list.append(torch.cat(blocks, dim=1)) + e_list.append(new_edge_types[start : start + count].to(device, torch.long)) + start += count + + assert start == int(new_edge_types.shape[0]), ( + f"expected {start} edge types, got {new_edge_types.shape[0]}; " + "sample count_new_atom_edges(mol_batch, atoms_batch) of them" + ) + + if triu_list: + new_triu = torch.cat(triu_list, dim=1) + new_e_triu = torch.cat(e_list, dim=0) + new_edge_index, (new_e,) = EDGE_ALIGNER.symmetrize_edges( + new_triu, [new_e_triu], num_nodes=positions.shape[0] + ) + combined_edge_index = torch.cat([existing_edge_index, new_edge_index], dim=1) + combined_e = torch.cat([existing_e, new_e], dim=0) + else: + combined_edge_index, combined_e = existing_edge_index, existing_e + + # keep_mask extends with zeros for the new atoms; system properties + # graph-level properties are carried unchanged. + node_extras: dict[str, torch.Tensor] = {} + if "keep_mask" in mol_batch: + keep_mask = mol_batch["keep_mask"] + node_extras["keep_mask"] = torch.cat( + [ + keep_mask, + torch.zeros( + atoms_batch.num_nodes, dtype=keep_mask.dtype, device=device + ), + ] + ) + system_extras = {key: mol_batch[key] for key in mol_batch.keys["system"]} + + return batch_from_flat( + positions, + atom_types, + charge_types, + combined_e, + combined_edge_index, + new_batch, + num_graphs=num_graphs, + _node_counts=mol_batch.num_nodes_per_graph + atoms_batch.num_nodes_per_graph, + node_extras=node_extras, + system_extras=system_extras, + ) + + +def join_with_predicted_edges( + mol_batch: ChemBatch, + new_atoms_batch: ChemBatch, + e_ins: torch.Tensor, + spawn_node_idx: torch.Tensor, + target_node_idx: torch.Tensor, + *, + e_ins_to_ins: torch.Tensor | None = None, + ins_to_ins_src_idx: torch.Tensor | None = None, + ins_to_ins_dst_idx: torch.Tensor | None = None, + sample_fallback_edge_types: Callable[[int], torch.Tensor] | None = None, +) -> ChemBatch: + """Join molecules with newly inserted atoms using model-predicted edges. + + ``e_ins`` are + the predicted (new-atom -> existing) bond types with ``spawn_node_idx`` + (local new-atom ids) and ``target_node_idx`` (existing-node ids in + ``mol_batch``). New-atom -> new-atom bonds come from ``e_ins_to_ins`` / + ``ins_to_ins_src_idx`` / ``ins_to_ins_dst_idx`` when given; otherwise every + intra-graph pair is created and typed by ``sample_fallback_edge_types`` + (outsourced, like :func:`join_with_atoms`). The graph is assembled in the + concatenated [existing, inserted] space and re-batched via + :func:`batch_from_flat`, which restores graph-contiguous ordering. + """ + device = mol_batch["positions"].device + num_existing = mol_batch.num_nodes + new_atoms_batch_idx = new_atoms_batch.batch_idx + + positions = torch.cat([mol_batch["positions"], new_atoms_batch["positions"]]) + atom_types = torch.cat([mol_batch["atom_types"], new_atoms_batch["atom_types"]]) + charge_types = None + if "charge_types" in mol_batch and "charge_types" in new_atoms_batch: + charge_types = torch.cat( + [mol_batch["charge_types"], new_atoms_batch["charge_types"]] + ) + new_batch = torch.cat([mol_batch.batch_idx, new_atoms_batch_idx]) + + if mol_batch.edge_index.numel() > 0: + existing_edge_index = mol_batch.edge_index + existing_e = mol_batch["edge_types"] + else: + existing_edge_index = torch.empty((2, 0), dtype=torch.long, device=device) + existing_e = torch.empty((0,), dtype=torch.long, device=device) + + triu_list: list[torch.Tensor] = [] + e_list: list[torch.Tensor] = [] + + # inserted -> existing (upper triangle: target precedes the new atom) + if e_ins is not None and e_ins.numel() > 0: + global_new_atom_idx = spawn_node_idx + num_existing + triu_list.append(torch.stack([target_node_idx, global_new_atom_idx], dim=0)) + e_list.append(e_ins.to(device=device, dtype=torch.long)) + + # inserted -> inserted + use_predicted_ii = ( + e_ins_to_ins is not None + and e_ins_to_ins.numel() > 0 + and ins_to_ins_src_idx is not None + and ins_to_ins_dst_idx is not None + ) + if use_predicted_ii: + triu_list.append( + torch.stack( + [ins_to_ins_src_idx + num_existing, ins_to_ins_dst_idx + num_existing], + dim=0, + ) + ) + e_list.append(e_ins_to_ins.to(device=device, dtype=torch.long)) + else: + local_idx = torch.arange(new_atoms_batch.num_nodes, device=device) + src, dst, _, _ = insertion_pair_indices(new_atoms_batch_idx, local_idx) + if src.numel() > 0: + if sample_fallback_edge_types is None: + raise ValueError( + "sample_fallback_edge_types is required when predicted " + "inserted->inserted edges are not provided" + ) + triu_list.append( + torch.stack([src + num_existing, dst + num_existing], dim=0) + ) + e_list.append( + sample_fallback_edge_types(src.numel()).to( + device=device, dtype=torch.long + ) + ) + + if triu_list: + new_triu = torch.cat(triu_list, dim=1) + new_e_triu = torch.cat(e_list, dim=0) + new_edge_index, (new_e,) = EDGE_ALIGNER.symmetrize_edges( + new_triu, [new_e_triu], num_nodes=positions.shape[0] + ) + combined_edge_index = torch.cat([existing_edge_index, new_edge_index], dim=1) + combined_e = torch.cat([existing_e, new_e], dim=0) + else: + combined_edge_index, combined_e = existing_edge_index, existing_e + + # Conditioning: keep_mask extends with zeros for new atoms. + node_extras: dict[str, torch.Tensor] = {} + if "keep_mask" in mol_batch: + keep_mask = mol_batch["keep_mask"] + node_extras["keep_mask"] = torch.cat( + [ + keep_mask, + torch.zeros( + new_atoms_batch.num_nodes, + dtype=keep_mask.dtype, + device=device, + ), + ] + ) + system_extras = {key: mol_batch[key] for key in mol_batch.keys["system"]} + return batch_from_flat( + positions, + atom_types, + charge_types, + combined_e, + combined_edge_index, + new_batch, + num_graphs=mol_batch.num_graphs, + _node_counts=mol_batch.num_nodes_per_graph + + new_atoms_batch.num_nodes_per_graph, + node_extras=node_extras, + system_extras=system_extras, + ) diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/insertion_targets.py b/geom/explicit_h/runtime/src/morph/dataset/core/insertion_targets.py new file mode 100644 index 0000000000000000000000000000000000000000..e0d32eef34d675e0ae42db78aacee9e8ecf9dd40 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/insertion_targets.py @@ -0,0 +1,160 @@ +"""Structured insertion supervision and its two independent node index spaces.""" + +from __future__ import annotations + +from dataclasses import dataclass, fields, replace +from itertools import accumulate + +import torch + + +class _TensorRecord: + """Tensor operations shared by the nested insertion records.""" + + def clone(self): + """Copy every tensor, including the nested bond records.""" + return replace( + self, + **{field.name: getattr(self, field.name).clone() for field in fields(self)}, + ) + + def to_inplace(self, device, non_blocking: bool = True): + """Move all tensors while preserving integer index dtypes.""" + for field in fields(self): + value = getattr(self, field.name) + if isinstance(value, _TensorRecord): + value.to_inplace(device, non_blocking=non_blocking) + else: + setattr(self, field.name, value.to(device, non_blocking=non_blocking)) + return self + + def pin_memory(self): + """Pin every tensor for DataLoader's asynchronous device transfer.""" + for field in fields(self): + setattr(self, field.name, getattr(self, field.name).pin_memory()) + return self + + +@dataclass +class BondsToExisting(_TensorRecord): + """Target bonds connecting a newly inserted atom to an already-existing atom. + + One aligned entry per supervised bond. ``spawn_idx`` / ``existing_idx`` index + the interpolated graph (``mol_t``); ``inserted_idx`` indexes the insertion + atoms (:class:`InsertionTargets` nodes). + """ + + spawn_idx: torch.Tensor # existing node that spawned the inserted endpoint + existing_idx: torch.Tensor # the existing endpoint + inserted_idx: torch.Tensor # the inserted endpoint (local) + bond_type: torch.Tensor # target bond token + + +@dataclass +class BondsBetweenInserted(_TensorRecord): + """Target bonds between two newly inserted atoms (upper triangle, src < dst). + + ``spawn_*`` index the interpolated graph (``mol_t``); ``src_idx`` / ``dst_idx`` + index the insertion atoms. + """ + + spawn_src_idx: torch.Tensor # existing node that spawned the src inserted atom + spawn_dst_idx: torch.Tensor # existing node that spawned the dst inserted atom + src_idx: torch.Tensor # src inserted atom (local) + dst_idx: torch.Tensor # dst inserted atom (local) + bond_type: torch.Tensor + + +@dataclass +class InsertionTargets(_TensorRecord): + """Supervision for the atoms a model must insert, for one graph or a batch. + + ``positions`` / ``atom_types`` / ``charge_types`` are the future-inserted + atoms. ``spawn_node_idx`` maps each inserted atom to the existing node it is + spawned from (in ``mol_t``) -- a one-to-many ``insertion -> nearest node`` + map used by the GMM head and the insertion-edge supervision. The two bond + groups carry per-bond edge-type targets. + + ``nearest_node_idx`` / ``nearest_ins_idx`` are the *inverse*, one-to-one + ``node -> nearest insertion`` assignment used by the unimodal insertion head: + for each existing node that spawns at least one insertion, ``nearest_node_idx`` + holds the node (in ``mol_t``) and ``nearest_ins_idx`` the single nearest + pending insertion assigned to it (indexing these inserted atoms). Each node + appears at most once, so the unimodal head has exactly one target per node. + """ + + positions: torch.Tensor + atom_types: torch.Tensor + charge_types: torch.Tensor + spawn_node_idx: torch.Tensor + bonds_to_existing: BondsToExisting + bonds_between_inserted: BondsBetweenInserted + nearest_node_idx: torch.Tensor + nearest_ins_idx: torch.Tensor + + @property + def num_nodes(self) -> int: + """Number of future-inserted atoms.""" + return int(self.positions.shape[0]) + + @staticmethod + def empty(positions, atom_types, charge_types, device) -> InsertionTargets: + """An insertion target with inserted atoms but no supervised bonds.""" + e = torch.empty((0,), device=device, dtype=torch.long) + return InsertionTargets( + positions=positions, + atom_types=atom_types, + charge_types=charge_types, + spawn_node_idx=e, + bonds_to_existing=BondsToExisting(e, e, e, e), + bonds_between_inserted=BondsBetweenInserted(e, e, e, e, e), + nearest_node_idx=e, + nearest_ins_idx=e, + ) + + +def collate_insertion_targets( + targets: list[InsertionTargets], current_node_counts: list[int] +) -> InsertionTargets: + """Concatenate supervision without modifying the input samples. + + Spawn/existing indices receive offsets in the current molecular batch; + inserted indices receive offsets in the concatenated future atoms. Empty + insertion sets still contribute their current graph's node count. + """ + if not targets or len(targets) != len(current_node_counts): + raise ValueError("Expected one insertion target per current graph") + node_offsets = list(accumulate(current_node_counts, initial=0))[:-1] + ins_offsets = list(accumulate((t.num_nodes for t in targets), initial=0))[:-1] + + def cat(records, name, offsets=None): + values = [getattr(record, name) for record in records] + if offsets is not None: + values = [ + value + offset for value, offset in zip(values, offsets, strict=True) + ] + return torch.cat(values) + + existing = [t.bonds_to_existing for t in targets] + inserted = [t.bonds_between_inserted for t in targets] + return InsertionTargets( + positions=cat(targets, "positions"), + atom_types=cat(targets, "atom_types"), + charge_types=cat(targets, "charge_types"), + spawn_node_idx=cat(targets, "spawn_node_idx", node_offsets), + nearest_node_idx=cat(targets, "nearest_node_idx", node_offsets), + nearest_ins_idx=cat(targets, "nearest_ins_idx", ins_offsets), + bonds_to_existing=BondsToExisting( + spawn_idx=cat(existing, "spawn_idx", node_offsets), + existing_idx=cat(existing, "existing_idx", node_offsets), + inserted_idx=cat(existing, "inserted_idx", ins_offsets), + bond_type=cat(existing, "bond_type"), + ), + bonds_between_inserted=BondsBetweenInserted( + spawn_src_idx=cat(inserted, "spawn_src_idx", node_offsets), + spawn_dst_idx=cat(inserted, "spawn_dst_idx", node_offsets), + src_idx=cat(inserted, "src_idx", ins_offsets), + dst_idx=cat(inserted, "dst_idx", ins_offsets), + bond_type=cat(inserted, "bond_type"), + ), + ) diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/lightning_datamodule.py b/geom/explicit_h/runtime/src/morph/dataset/core/lightning_datamodule.py new file mode 100644 index 0000000000000000000000000000000000000000..339b1def0e98ea033858e2bf4ada4e338ce27ed7 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/lightning_datamodule.py @@ -0,0 +1,191 @@ +import hydra +import pytorch_lightning as pl +import torch +from omegaconf import DictConfig +from torch.utils.data import DataLoader + +from morph.dataset.core.flow_matching_wrapper import ( + FlowMatchingDatasetWrapper, + eval_collate_fn, + train_collate_fn, + worker_init_fn, +) +from morph.dataset.core.representation import Representation + + +class LightningDataModule(pl.LightningDataModule): + """Build flow-matching datasets and their Lightning data loaders.""" + + wrapper_cls = FlowMatchingDatasetWrapper + + def __init__( + self, + vocab: DictConfig, + distributions: DictConfig, + datasets: DictConfig, + interpolator: DictConfig, + num_workers: DictConfig, + batch_size: DictConfig, + representation: str | Representation = Representation.MOLECULE, + n_atoms_strategy: str = "uniform", + uniform_n_atoms_padding: int = 5, + time_dist: DictConfig = None, + rotate: bool = False, + n_augmentations: int = 1, + noise_distributions=None, + train_prefetch_factor: int = 4, + ): + super().__init__() + self.vocab = vocab + self.distributions = distributions + self.datasets = datasets + self.num_workers = num_workers + if train_prefetch_factor < 1: + raise ValueError("train_prefetch_factor must be positive") + self.train_prefetch_factor = train_prefetch_factor + self.batch_size = batch_size + self.representation = Representation(representation) + self.n_atoms_strategy = n_atoms_strategy + self.uniform_n_atoms_padding = int(uniform_n_atoms_padding) + self.rotate = rotate + self.n_augmentations = max(1, int(n_augmentations)) + + if ( + self.n_augmentations > 1 + and self.batch_size.train % self.n_augmentations != 0 + ): + raise ValueError( + f"batch_size.train ({self.batch_size.train}) must be divisible by " + f"n_augmentations ({self.n_augmentations})." + ) + + if time_dist is None: + time_dist = DictConfig( + { + "_target_": "torch.distributions.Uniform", + "low": 0.0, + "high": 1.0, + } + ) + self.time_dist = hydra.utils.instantiate(time_dist) + + self.train_dataset = None + self.val_datasets = None + self.test_datasets = None + self.interpolator = hydra.utils.instantiate( + interpolator, + distributions=self.distributions, + noise_distributions=noise_distributions, + ) + + def _wrapper_kwargs(self, stage: str) -> dict: + is_train = stage == "train" + return { + "distributions": self.distributions, + "interpolator": self.interpolator, + "vocab": self.vocab, + "representation": self.representation, + "n_atoms_strategy": self.n_atoms_strategy, + "uniform_n_atoms_padding": self.uniform_n_atoms_padding, + "time_dist": self.time_dist, + "stage": stage, + "rotate": self.rotate if is_train else False, + "n_augmentations": self.n_augmentations if is_train else 1, + } + + def _wrap_dataset(self, dataset_cfg, stage: str): + base_dataset = hydra.utils.instantiate( + dataset_cfg.copy(), + vocab=self.vocab, + distributions=self.distributions, + split=stage, + ) + return self.wrapper_cls( + base_dataset=base_dataset, + **self._wrapper_kwargs(stage), + ) + + def setup(self, stage=None): + """Construct only the datasets required by *stage*, once.""" + if self.vocab is None: + raise ValueError("vocab is required before setup()") + + if stage in (None, "fit") and self.train_dataset is None: + self.train_dataset = self._wrap_dataset(self.datasets.train, "train") + if stage in (None, "fit", "validate") and self.val_datasets is None: + self.val_datasets = [ + self._wrap_dataset(cfg, "val") for cfg in self.datasets.val + ] + if stage in (None, "test", "predict") and self.test_datasets is None: + self.test_datasets = [ + self._wrap_dataset(cfg, "test") for cfg in self.datasets.test + ] + + def transfer_batch_to_device(self, batch, device, dataloader_idx): + """Move batch to *device* with ``non_blocking=True``. + + Lightning's default tensor traversal does not recurse into Morph or PyG + graph containers. Handle both explicitly so their tensors benefit from + the asynchronous transfer enabled by ``pin_memory=True``. + """ + + def _move(item): + if hasattr(item, "to_inplace"): + return item.to_inplace(device) + if isinstance(item, torch.Tensor) or hasattr(item, "stores"): + return item.to(device, non_blocking=True) + return item + + if isinstance(batch, (tuple, list)): + return type(batch)(_move(item) for item in batch) + return _move(batch) + + def train_dataloader(self): + """Return the shuffled training loader.""" + nw = self.num_workers.train + # Each __getitem__ produces n_augmentations training items, so we + # request batch_size.train // n_augmentations unique molecules per + # batch and the wrapper expands them to the configured effective size. + per_load_batch_size = self.batch_size.train // self.n_augmentations + return DataLoader( + self.train_dataset, + shuffle=True, + batch_size=per_load_batch_size, + num_workers=nw, + persistent_workers=nw > 0, + pin_memory=True, + drop_last=True, + collate_fn=train_collate_fn, + worker_init_fn=worker_init_fn, + prefetch_factor=self.train_prefetch_factor if nw > 0 else None, + ) + + def _eval_dataloaders(self, datasets, stage: str, drop_last: bool): + nw = self.num_workers[stage] + return [ + DataLoader( + dataset, + shuffle=False, + batch_size=self.batch_size[stage], + num_workers=nw, + persistent_workers=nw > 0, + pin_memory=True, + drop_last=drop_last, + collate_fn=eval_collate_fn, + worker_init_fn=worker_init_fn, + prefetch_factor=2 if nw > 0 else None, + ) + for dataset in datasets + ] + + def val_dataloader(self): + """Return validation loaders.""" + return self._eval_dataloaders(self.val_datasets, "val", drop_last=True) + + def test_dataloader(self): + """Return test loaders.""" + return self._eval_dataloaders(self.test_datasets, "test", drop_last=False) + + def predict_dataloader(self) -> list[DataLoader]: + """Reuse test loaders for prediction.""" + return self.test_dataloader() diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/lmdb.py b/geom/explicit_h/runtime/src/morph/dataset/core/lmdb.py new file mode 100644 index 0000000000000000000000000000000000000000..1f9781bdc4f26c81083c9110ff8620c94541ea4f --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/lmdb.py @@ -0,0 +1,270 @@ +"""Molecular LMDB storage, streaming preprocessing, and process-local readers.""" + +from __future__ import annotations + +import json +import os +import pickle +import tempfile +from contextlib import nullcontext +from functools import partial +from multiprocessing import get_context +from pathlib import Path +from typing import TYPE_CHECKING + +import lmdb +from torch.utils.data import Dataset +from torch_geometric.data import Data +from tqdm import tqdm + +if TYPE_CHECKING: + from collections.abc import Iterable + + from morph.dataset.core.vocab import Distributions, Vocab + +SPLITS = ("train", "val", "test") +HYDROGEN_MODES = ("explicit", "implicit") +RECORD_FIELDS = ("z", "pos", "charges", "edge_index", "edge_attr", "smiles") +LEN_KEY = b"__len__" # Cannot collide with 8-byte integer record keys. +LMDB_MAP_SIZE = 128 * 1024**3 # Virtual address space; disk usage grows with data. + + +def _int_key(index: int) -> bytes: + return index.to_bytes(8, "big") + + +def mol_to_bytes(data: Data) -> bytes: + """Store raw fields without PyG object or collated tensor-storage overhead.""" + return pickle.dumps({key: getattr(data, key) for key in RECORD_FIELDS}, protocol=4) + + +def mol_from_bytes(raw: bytes) -> Data: + """Decode one stored molecular graph.""" + return Data(**pickle.loads(raw)) + + +def open_read_env(path: str) -> lmdb.Environment: + """Open immutable data for concurrent random access by worker processes.""" + return lmdb.open( + path, + readonly=True, + lock=False, + readahead=False, + meminit=False, + subdir=True, + max_readers=512, + ) + + +def read_lmdb_length(path: str) -> int: + """Read the record count, with support for caches predating the length key.""" + with open_read_env(path) as env, env.begin() as txn: + length = txn.get(LEN_KEY) + return int(length) if length is not None else txn.stat()["entries"] + + +def _convert_to_bytes(record, converter) -> bytes | None: + data = converter(record) + return None if data is None else mol_to_bytes(data) + + +def write_bytes_to_lmdb( + bytes_iter: Iterable[bytes | None], + lmdb_path: str, + total: int | None = None, + map_size: int = LMDB_MAP_SIZE, + commit_every: int = 10_000, + desc: str = "Writing LMDB", +) -> tuple[int, int]: + """Stream serialized molecule bytes into a new LMDB environment. + + ``None`` entries in the iterator are counted as failures and skipped. + """ + os.makedirs(os.path.dirname(lmdb_path) or ".", exist_ok=True) + env = lmdb.open( + lmdb_path, + map_size=map_size, + subdir=True, + meminit=False, + writemap=False, + max_readers=512, + ) + n_written = 0 + n_failed = 0 + try: + txn = env.begin(write=True) + for out in tqdm(bytes_iter, total=total, desc=desc): + if out is None: + n_failed += 1 + continue + txn.put(_int_key(n_written), out) + n_written += 1 + if n_written % commit_every == 0: + txn.commit() + txn = env.begin(write=True) + txn.put(LEN_KEY, str(n_written).encode()) + txn.commit() + finally: + env.close() + return n_written, n_failed + + +class MolecularLMDBDataset(Dataset): + """Shared dataset options and storage; subclasses implement source loading.""" + + def __init__( + self, + root: str, + split: str = "train", + vocab: Vocab | None = None, + distributions: Distributions | None = None, + preprocess_workers: int | None = 0, + preprocess_chunk_size: int = 128, + hydrogen_mode: str = "explicit", + max_atoms: int | None = None, + allowed_atom_symbols: tuple[str, ...] | None = None, + ): + if split not in SPLITS or hydrogen_mode not in HYDROGEN_MODES: + raise ValueError(f"Expected split in {SPLITS} and mode in {HYDROGEN_MODES}") + if (preprocess_workers or 0) < 0 or preprocess_chunk_size < 1: + raise ValueError("Workers must be non-negative and chunk size positive") + if max_atoms is not None and max_atoms < 1: + raise ValueError("max_atoms must be positive or None") + self.root, self.split = os.fspath(root), split + self.vocab, self.distributions = vocab, distributions + self.preprocess_workers = preprocess_workers or 0 + self.preprocess_chunk_size = preprocess_chunk_size + self.hydrogen_mode = hydrogen_mode + filters = { + "max_atoms": max_atoms, + "allowed_atom_symbols": ( + list(allowed_atom_symbols) if allowed_atom_symbols is not None else None + ), + } + self.processing_options = {**filters, "hydrogen_mode": hydrogen_mode} + self.raw_dir = os.path.join(self.root, "raw") + self.processed_dir = os.path.join(self.root, "processed") + self.lmdb_path = os.path.join(self.processed_dir, f"{split}_data.lmdb") + self._env = self._txn = self._pid = None + + processed = Path(self.processed_dir) + manifest = processed / "preprocessing_summary.json" + metadata = json.loads(manifest.read_text()) if manifest.is_file() else {} + if any(processed.glob("*_data.lmdb")): + # Older caches have only includes_hydrogens, or no metadata (explicit). + cached_mode = metadata.get( + "hydrogen_mode", + "implicit" + if metadata.get("includes_hydrogens") is False + else "explicit", + ) + if cached_mode != hydrogen_mode: + raise ValueError( + f"Requested {hydrogen_mode} H but cache is {cached_mode}: {root}" + ) + cached_filters = metadata.get("processing_options") + if cached_filters is not None and cached_filters != filters: + raise ValueError(f"Cache uses different preprocessing options: {root}") + processed.mkdir(parents=True, exist_ok=True) + if not Path(self.lmdb_path).exists(): + self.prepare() + metadata.update( + hydrogen_mode=hydrogen_mode, + includes_hydrogens=hydrogen_mode == "explicit", + processing_options=filters, + ) + with tempfile.NamedTemporaryFile( + mode="w", dir=processed, delete=False + ) as stream: + json.dump(metadata, stream, indent=2) + os.replace(stream.name, manifest) + self._length = read_lmdb_length(self.lmdb_path) + if not (processed / f"{split}_smiles.txt").is_file(): + self._write_smiles_sidecar() + + def prepare(self) -> None: + """Load and convert a missing source split in dataset subclasses.""" + raise FileNotFoundError(self.lmdb_path) + + def _write_records(self, records, converter, total: int | None = None) -> None: + """Convert in source order and publish a split only after a complete write.""" + convert = partial(_convert_to_bytes, converter=converter) + pool_context = ( + get_context("spawn").Pool(self.preprocess_workers) + if self.preprocess_workers > 1 + else nullcontext() + ) + with ( + tempfile.TemporaryDirectory(dir=self.processed_dir) as staging, + pool_context as pool, + ): + path = str(Path(staging) / "data.lmdb") + outputs = ( + pool.imap(convert, records, chunksize=self.preprocess_chunk_size) + if pool is not None + else map(convert, records) + ) + written, failed = write_bytes_to_lmdb(outputs, path, total=total) + if written == 0: + raise ValueError(f"No valid molecules in {self.split}") + os.rename(path, self.lmdb_path) + print(f"{self.split}: wrote {written} molecules; skipped {failed}") + + def _ensure_txn(self): + """Reuse one transaction per process; reopen handles inherited by fork.""" + if self._env is None or self._pid != os.getpid(): + self.close() + self._env = open_read_env(self.lmdb_path) + self._txn = self._env.begin(buffers=True) + self._pid = os.getpid() + return self._txn + + def close(self) -> None: + """Release this process's transaction and memory mapping.""" + if self._txn is not None: + self._txn.abort() + if self._env is not None: + self._env.close() + self._env = self._txn = self._pid = None + + def __getstate__(self): + """Omit live handles when sending a dataset to spawned DataLoader workers.""" + return {**self.__dict__, "_env": None, "_txn": None, "_pid": None} + + def _write_smiles_sidecar(self) -> None: + """Persist sorted unique SMILES for novelty metrics.""" + with open_read_env(self.lmdb_path) as env, env.begin() as txn: + smiles = { + pickle.loads(txn.get(_int_key(i)))["smiles"] + for i in range(self._length) + } + path = Path(self.processed_dir) / f"{self.split}_smiles.txt" + path.write_text("\n".join(sorted(smiles))) + + def get_all_smiles(self) -> list[str]: + """Read the unique SMILES for this split.""" + return ( + (Path(self.processed_dir) / f"{self.split}_smiles.txt") + .read_text() + .splitlines() + ) + + def iter_bytes(self) -> Iterable[bytes]: + """Read serialized records in source order.""" + txn = self._ensure_txn() + for index in range(self._length): + yield bytes(txn.get(_int_key(index))) + + def __len__(self): + """Return the number of records in this split.""" + return self._length + + def __getitem__(self, index): + """Decode a raw molecular record.""" + if index < 0 or index >= self._length: + raise IndexError(index) + return mol_from_bytes(bytes(self._ensure_txn().get(_int_key(index)))) + + def get(self, index): + """Read raw fields for preprocessing, without tokenized decoding.""" + return self.__getitem__(index) diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/preprocessing.py b/geom/explicit_h/runtime/src/morph/dataset/core/preprocessing.py new file mode 100644 index 0000000000000000000000000000000000000000..76a2c1651718564ca4d93ff0cd6a37207dfac378 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/preprocessing.py @@ -0,0 +1,495 @@ +"""Preprocessing class for calculating token distributions from datasets.""" + +import importlib +import os +import tempfile +from multiprocessing import get_context + +import torch +from torch.utils.data import Dataset +from tqdm import tqdm + +from morph.dataset.core.representation import neutral_charge_index +from morph.dataset.core.vocab import Distributions, Vocab +from morph.utils.metrics.tensor import ( + DIST_N_BINS, + RG_N_BINS, + accumulate_pairwise_distance_hist, + accumulate_rog_hist, + dist_edges, + rg_edges, +) +from morph.utils.vocab_utils import z_to_atom_types + +_PREPROCESSING_WORKER = None + + +def _init_preprocessing_worker(dataset, vocab): + global _PREPROCESSING_WORKER # noqa: PLW0603 (process-local pool state) + torch.set_num_threads(1) + _PREPROCESSING_WORKER = _DatasetReduction(dataset, vocab) + + +def _preprocessing_chunk(task): + method, indices = task + return getattr(_PREPROCESSING_WORKER, method)(indices) + + +class _DatasetReduction: + """Read raw records and return mergeable vocabulary/statistics reductions. + + This reader has no cache initialization or file-writing behavior, so pool + workers can construct it directly without bypassing Preprocessing.__init__. + """ + + def __init__(self, dataset: Dataset, vocab: Vocab | None): + self.train_dataset = dataset + self.vocab = vocab + + def _collect_tokens(self, indices): + """Collect vocabulary sets from a range of raw records.""" + + # Extract all atom types and edge types from the dataset in one loop + all_atom_types = set() + all_edge_type_indices = set() + all_charge_tokens = set() + + for i in indices: + data = self.train_dataset.get(i) + + # Extract atom types + atom_types = z_to_atom_types(data.z.tolist()) + all_atom_types.update(atom_types) + + # Extract edge types — only when the source provides bonds. + # Datasets without topology (e.g. TMQM) won't have `edge_attr` at all, + # in which case the canonical edge vocab falls back to [""]. + if ( + hasattr(data, "edge_attr") + and data.edge_attr is not None + and data.edge_attr.numel() > 0 + ): + all_edge_type_indices.update(data.edge_attr.tolist()) + + if hasattr(data, "charges") and data.charges is not None: + all_charge_tokens.update(data.charges.tolist()) + + return all_atom_types, all_edge_type_indices, all_charge_tokens + + def _collect_distribution_counts(self, indices): + """Accumulate mergeable counts and coordinate moments from raw records.""" + # Compute distributions from the provided training dataset only. + dataset = self.train_dataset + + # Streaming counts and coordinate moments avoid retaining every atom and + # an O(N^2) dense adjacency for each molecule. The coordinate recurrence + # is Chan's parallel-variance formula, mathematically identical to the + # sample standard deviation of the old concatenated coordinate tensor. + n_atoms_counts: dict[int, int] = {} + atom_counts = torch.zeros(len(self.vocab.atom_tokens), dtype=torch.long) + edge_counts = torch.zeros(len(self.vocab.edge_tokens), dtype=torch.long) + charge_counts = torch.zeros(len(self.vocab.charge_tokens), dtype=torch.long) + saw_topology = False + saw_charges = False + coord_count = 0 + coord_mean = torch.tensor(0.0, dtype=torch.float64) + coord_m2 = torch.tensor(0.0, dtype=torch.float64) + + atom_to_idx = {token: i for i, token in enumerate(self.vocab.atom_tokens)} + edge_to_idx = {token: i for i, token in enumerate(self.vocab.edge_tokens)} + charge_to_idx = {token: i for i, token in enumerate(self.vocab.charge_tokens)} + + # Pointcloud-mode evaluation targets — computed canonically so the same + # cache serves any representation. + num_atom_types = len(self.vocab.atom_tokens) + pair_hist = torch.zeros( + num_atom_types, num_atom_types, DIST_N_BINS, dtype=torch.float64 + ) + rog_hist = torch.zeros(RG_N_BINS, dtype=torch.float64) + d_edges = dist_edges() + r_edges = rg_edges() + + for i in indices: + data = dataset.get(i) + num_atoms = data.num_nodes + + atom_types = z_to_atom_types(data.z.tolist()) + atom_type_indices_t = torch.tensor( + [atom_to_idx[token] for token in atom_types], dtype=torch.long + ) + atom_counts += atom_type_indices_t.bincount(minlength=atom_counts.numel()) + + # Edge histogram: built only if the dataset provides bonds. Sources + # without topology contribute nothing here (vocab is the singleton + # canonical [NO_BOND] for them anyway). + if ( + getattr(data, "edge_attr", None) is not None + and getattr(data, "edge_index", None) is not None + ): + saw_topology = True + upper = data.edge_index[0] < data.edge_index[1] + upper_types = data.edge_attr[upper].view(-1) + # The source datasets store unique symmetric bonds. Their dense + # upper triangle therefore consists of these bonds plus exactly + # the remaining pairs. + n_pairs = num_atoms * (num_atoms - 1) // 2 + edge_counts[0] += n_pairs - upper_types.numel() + mapped_edges = torch.tensor( + [edge_to_idx[str(int(t))] for t in upper_types.tolist()], + dtype=torch.long, + ) + edge_counts += mapped_edges.bincount(minlength=edge_counts.numel()) + + coord = data.pos - data.pos.mean(dim=0) + flat_coord = coord.double().reshape(-1) + batch_count = flat_coord.numel() + batch_mean = flat_coord.mean() + batch_m2 = ((flat_coord - batch_mean) ** 2).sum() + if coord_count == 0: + coord_mean = batch_mean + coord_m2 = batch_m2 + coord_count = batch_count + else: + delta = batch_mean - coord_mean + total_count = coord_count + batch_count + coord_m2 += ( + batch_m2 + delta.square() * coord_count * batch_count / total_count + ) + coord_mean += delta * batch_count / total_count + coord_count = total_count + + accumulate_pairwise_distance_hist( + pair_hist, coord, atom_type_indices_t, d_edges + ) + accumulate_rog_hist(rog_hist, coord, r_edges) + + if hasattr(data, "charges") and data.charges is not None: + saw_charges = True + charge_type_indices = torch.tensor( + [charge_to_idx[str(token)] for token in data.charges.tolist()], + dtype=torch.long, + ) + charge_counts += charge_type_indices.bincount( + minlength=charge_counts.numel() + ) + + n_atoms_counts[num_atoms] = n_atoms_counts.get(num_atoms, 0) + 1 + + return { + "n_atoms_counts": n_atoms_counts, + "atom_counts": atom_counts, + "edge_counts": edge_counts, + "charge_counts": charge_counts, + "saw_topology": saw_topology, + "saw_charges": saw_charges, + "coord_count": coord_count, + "coord_mean": coord_mean, + "coord_m2": coord_m2, + "pair_hist": pair_hist, + "rog_hist": rog_hist, + } + + +class Preprocessing: + """Load or compute a training vocabulary and reusable dataset statistics. + + A dataset config is instantiated only when an artifact is missing. Numerical + reductions run in _DatasetReduction so workers never initialize cache files. + """ + + NO_BOND_TOKEN = "" + + def __init__( + self, + root: str, + train_dataset: Dataset | dict | None = None, + atom_tokens_path: str = None, + edge_tokens_path: str = None, + charge_tokens_path: str = None, + distributions_path: str = None, + num_workers: int = 0, + chunk_size: int = 2048, + ): + """Use root-level artifact paths unless explicit paths are supplied.""" + if num_workers < 0: + raise ValueError("num_workers must be non-negative") + if chunk_size < 1: + raise ValueError("chunk_size must be positive") + self.num_workers = num_workers + self.chunk_size = chunk_size + self.root = root + self._train_dataset_cfg = train_dataset + self.train_dataset = None + + self.atom_tokens_path = ( + os.path.join(root, "atom_tokens.txt") + if atom_tokens_path is None + else atom_tokens_path + ) + self.edge_tokens_path = ( + os.path.join(root, "edge_tokens.txt") + if edge_tokens_path is None + else edge_tokens_path + ) + self.charge_tokens_path = ( + os.path.join(root, "charge_tokens.txt") + if charge_tokens_path is None + else charge_tokens_path + ) + self.distributions_path = ( + os.path.join(root, "distributions.pt") + if distributions_path is None + else distributions_path + ) + + has_all_files = ( + os.path.exists(self.atom_tokens_path) + and os.path.exists(self.edge_tokens_path) + and os.path.exists(self.charge_tokens_path) + and os.path.exists(self.distributions_path) + ) + + self._artifacts_were_complete = has_all_files + if not has_all_files: + self._ensure_train_dataset() + + self.vocab = None + self.vocab = self._load_or_compute_tokens() + self.distributions = self._load_or_compute_distributions( + force=not self._artifacts_were_complete + ) + + # Release raw records after producing the small artifacts. + self.train_dataset = None + + def _ensure_train_dataset(self): + """Instantiate train dataset lazily only when preprocessing needs it.""" + if self.train_dataset is not None: + return + + if self._train_dataset_cfg is None: + raise ValueError( + "train_dataset is required when preprocessing artifacts are missing." + ) + + if isinstance(self._train_dataset_cfg, Dataset): + self.train_dataset = self._train_dataset_cfg + return + + try: + hydra = importlib.import_module("hydra") + except ImportError as e: + raise ImportError( + "Hydra is required to instantiate train_dataset config lazily." + ) from e + + self.train_dataset = hydra.utils.instantiate(self._train_dataset_cfg) + + def _load_or_compute_tokens(self) -> Vocab: + """Load complete token files, or discover and publish all three together.""" + atom_tokens_exist = os.path.exists(self.atom_tokens_path) + edge_tokens_exist = os.path.exists(self.edge_tokens_path) + charge_tokens_exist = os.path.exists(self.charge_tokens_path) + + if atom_tokens_exist and edge_tokens_exist and charge_tokens_exist: + atom_tokens = self._load_tokens(self.atom_tokens_path) + edge_tokens = self._load_tokens(self.edge_tokens_path) + charge_tokens = self._load_tokens(self.charge_tokens_path) + else: + atom_tokens, edge_tokens, charge_tokens = self._compute_tokens_from_data() + + # Publish the token set together. If a previous preprocessing run + # stopped between files, retaining any old subset can produce a + # vocabulary/distribution mismatch. + self._save_tokens(self.atom_tokens_path, atom_tokens) + self._save_tokens(self.edge_tokens_path, edge_tokens) + self._save_tokens(self.charge_tokens_path, charge_tokens) + + return Vocab( + atom_tokens=atom_tokens, + edge_tokens=edge_tokens, + charge_tokens=charge_tokens, + ) + + def _compute_tokens_from_data(self) -> tuple[list[str], list[str], list[str]]: + """Discover sorted atom, bond, and charge labels from training records.""" + all_atom_types, all_edge_type_indices, all_charge_tokens = set(), set(), set() + for atoms, edges, charges in self._map_chunks("_collect_tokens"): + all_atom_types.update(atoms) + all_edge_type_indices.update(edges) + all_charge_tokens.update(charges) + + tokens = sorted(all_atom_types) + edge_tokens = [self.NO_BOND_TOKEN] + [ + str(int(idx)) for idx in sorted(all_edge_type_indices) + ] + charge_tokens = [str(int(idx)) for idx in sorted(all_charge_tokens)] or ["0"] + return tokens, edge_tokens, charge_tokens + + def _map_chunks(self, method): + """Read disjoint ranges in workers, returning only small reductions.""" + length = len(self.train_dataset) + workers = getattr(self, "num_workers", 0) + if workers <= 1 or length < 2: + reader = _DatasetReduction(self.train_dataset, self.vocab) + yield getattr(reader, method)(range(length)) + return + workers = min(workers, length) + chunk_size = min( + getattr(self, "chunk_size", 2048), + max(1, (length + workers - 1) // workers), + ) + tasks = ( + (method, range(start, min(start + chunk_size, length))) + for start in range(0, length, chunk_size) + ) + with get_context("spawn").Pool( + workers, + initializer=_init_preprocessing_worker, + initargs=(self.train_dataset, self.vocab), + ) as pool: + yield from tqdm( + pool.imap(_preprocessing_chunk, tasks), + total=(length + chunk_size - 1) // chunk_size, + desc=method.removeprefix("_collect_"), + unit="chunk", + ) + + def _save_tokens(self, path: str, tokens: list[str]): + """Publish a token file atomically.""" + parent = os.path.dirname(path) + os.makedirs(parent, exist_ok=True) + tmp_path = None + try: + with tempfile.NamedTemporaryFile(mode="w", dir=parent, delete=False) as f: + tmp_path = f.name + for token in tokens: + f.write(f"{token}\n") + os.replace(tmp_path, path) + finally: + if tmp_path is not None and os.path.exists(tmp_path): + os.unlink(tmp_path) + + def _load_tokens(self, path: str) -> list[str]: + """Read one token per line.""" + with open(path, "r") as f: + tokens = [line.strip() for line in f.readlines()] + return tokens + + def _load_or_compute_distributions(self, force: bool = False) -> Distributions: + """Load statistics unless absent or invalidated by a changed vocabulary.""" + if os.path.exists(self.distributions_path) and not force: + distributions = self._load_distributions() + else: + distributions = self._compute_distributions() + + self._save_distributions(distributions) + + distributions = Distributions(**distributions) + return distributions + + def _compute_distributions(self) -> dict[str, torch.Tensor | float]: + """Reduce training statistics in deterministic chunk order.""" + merged = None + for part in self._map_chunks("_collect_distribution_counts"): + if merged is None: + merged = part + continue + for key in ( + "atom_counts", + "edge_counts", + "charge_counts", + "pair_hist", + "rog_hist", + ): + merged[key] += part[key] + for n_atoms, count in part["n_atoms_counts"].items(): + counts = merged["n_atoms_counts"] + counts[n_atoms] = counts.get(n_atoms, 0) + count + merged["saw_topology"] |= part["saw_topology"] + merged["saw_charges"] |= part["saw_charges"] + left, right = merged["coord_count"], part["coord_count"] + if right: + delta = part["coord_mean"] - merged["coord_mean"] + total = left + right + merged["coord_m2"] += ( + part["coord_m2"] + delta.square() * left * right / total + ) + merged["coord_mean"] += delta * right / total + merged["coord_count"] = total + + n_atoms_counts = merged["n_atoms_counts"] + atom_counts = merged["atom_counts"] + edge_counts = merged["edge_counts"] + charge_counts = merged["charge_counts"] + saw_topology = merged["saw_topology"] + saw_charges = merged["saw_charges"] + coord_count = merged["coord_count"] + coord_m2 = merged["coord_m2"] + pair_hist = merged["pair_hist"].float() + rog_hist = merged["rog_hist"].float() + + if not n_atoms_counts or coord_count < 2: + raise ValueError("Cannot compute distributions from an empty dataset") + + n_atoms_distribution = torch.zeros(max(n_atoms_counts) + 1, dtype=torch.float32) + for n_atoms, count in n_atoms_counts.items(): + n_atoms_distribution[n_atoms] = count + n_atoms_distribution = n_atoms_distribution / n_atoms_distribution.sum() + + atom_type_distribution = atom_counts.float() + atom_type_distribution = atom_type_distribution / atom_type_distribution.sum() + + # Canonical edge distribution. Sources without bonds emit no edge entries; + # we fall back to a one-hot at NO_BOND (token 0) so the singleton-canonical + # vocab maps to a proper one-hot prior. + if saw_topology and edge_counts.sum() > 0: + edge_type_distribution = edge_counts.float() + edge_type_distribution = ( + edge_type_distribution / edge_type_distribution.sum().clamp(min=1.0) + ) + else: + edge_type_distribution = torch.zeros(len(self.vocab.edge_tokens)) + edge_type_distribution[0] = 1.0 + + # Canonical charge distribution. Sources without charges emit no entries; + # fall back to one-hot at the canonical "0" index when available. + if saw_charges: + charge_type_distribution = charge_counts.float() + charge_type_distribution = ( + charge_type_distribution / charge_type_distribution.sum() + ) + else: + charge_type_distribution = torch.zeros(len(self.vocab.charge_tokens)) + charge_type_distribution[neutral_charge_index(self.vocab)] = 1.0 + + coordinate_std = torch.sqrt(coord_m2 / (coord_count - 1)).float().item() + + return { + "atom_type_distribution": atom_type_distribution, + "edge_type_distribution": edge_type_distribution, + "charge_type_distribution": charge_type_distribution, + "n_atoms_distribution": n_atoms_distribution, + "coordinate_std": coordinate_std, + "pairwise_distance_histogram": pair_hist, + "radius_of_gyration_histogram": rog_hist, + } + + def _save_distributions( + self, distributions: dict[str, torch.Tensor | float] + ) -> None: + """Save distributions to file.""" + parent = os.path.dirname(self.distributions_path) + os.makedirs(parent, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=parent, delete=False) as f: + tmp_path = f.name + try: + torch.save(distributions, tmp_path) + os.replace(tmp_path, self.distributions_path) + finally: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + + def _load_distributions(self) -> dict[str, torch.Tensor | float]: + """Load distributions from file.""" + return torch.load(self.distributions_path, weights_only=False) diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/properties.py b/geom/explicit_h/runtime/src/morph/dataset/core/properties.py new file mode 100644 index 0000000000000000000000000000000000000000..c606bbda163ca46a357d71817de2515485dcb480 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/properties.py @@ -0,0 +1,62 @@ +"""Precomputed per-molecule scalar properties (QED, logP, …) for GEOM/QM9. + +Each property is precomputed once into a float32 ``.npy`` array positionally aligned +to the split's LMDB index and stored at + + /properties//.npy + +so ``__getitem__`` reads a single array element. Property arrays are optional +and supplied separately; ``compute_properties`` defaults to false. Each array +must contain one value per processed molecule in the split's dataset order. +``PROPERTY_FNS`` provides RDKit functions for offline array construction. +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import numpy as np +from rdkit.Chem import Crippen +from rdkit.Chem.QED import qed + +if TYPE_CHECKING: + from collections.abc import Callable + + from rdkit import Chem + +PROPERTIES_SUBDIR = "properties" + + +def _qed(rdmol) -> float: + return float(qed(rdmol)) + + +def _logp(rdmol) -> float: + return float(Crippen.MolLogP(rdmol)) + + +# property name -> callable(rdmol) -> float +PROPERTY_FNS: dict[str, Callable[[Chem.Mol], float]] = { + "qed": _qed, + "logp": _logp, +} + + +def property_path(processed_dir: str, property_name: str, split: str) -> str: + """Path of the precomputed ``.npy`` array for one property/split.""" + return os.path.join(processed_dir, PROPERTIES_SUBDIR, property_name, f"{split}.npy") + + +def load_property_array( + processed_dir: str, property_name: str, split: str +) -> np.ndarray: + """Load a precomputed property array (raises with build instructions if absent).""" + path = property_path(processed_dir, property_name, split) + if not os.path.exists(path): + raise FileNotFoundError( + f"Missing precomputed property '{property_name}' for split '{split}' " + f"at {path}. Supply a float32 .npy array with one value per processed " + "molecule in split order, or set compute_properties=False." + ) + return np.load(path) diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/representation.py b/geom/explicit_h/runtime/src/morph/dataset/core/representation.py new file mode 100644 index 0000000000000000000000000000000000000000..ccfde2cf7c47e9e77338016fa8f8a4269ff8b4a4 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/representation.py @@ -0,0 +1,170 @@ +"""Representation mode for morph training runs. + +Declares what subset of (x, a, c, edge_index, e) a run should train on: +- POINTCLOUD: x, a only (c and edges projected to dummies) +- CHARGED_POINTCLOUD: x, a, c (edges projected to dummies) +- GEOMETRIC_GRAPH: x, a, edge_index, e (charges projected to neutral dummies) +- MOLECULE: full chemistry (no projection) + +A dataset's Capabilities declares what fields it provides. ``validate_representation`` +checks the requested mode is achievable by the dataset. + +The cache and vocab are **canonical** in all modes (one preprocessing per dataset). +The projection helpers transform canonical artifacts into the model-facing view: +- ``project_molecule_to_representation``: canonical ChemAtomicData → model-facing. +- ``project_distributions_to_representation``: canonical priors → projected priors + (used by sample_prior_graph / interpolator / integrator so sampled tokens match + projected targets). +- ``make_uniform_type_prior``: the shared uniform categorical source used by + training and inference, followed by the representation projection above. +""" + +from dataclasses import dataclass, replace +from enum import Enum + +import torch +import torch.nn.functional as F + +from morph.dataset.core.atomic_data import ChemAtomicData +from morph.dataset.core.vocab import Distributions, Vocab +from morph.utils.graph_math import build_fully_connected_edge_index + + +class Representation(str, Enum): # noqa: UP042 -- preserve enum string semantics + """Chemical channels exposed to the model after dataset projection.""" + + POINTCLOUD = "pointcloud" + CHARGED_POINTCLOUD = "charged_pointcloud" + GEOMETRIC_GRAPH = "geometric_graph" + MOLECULE = "molecule" + + @property + def requires_charges(self) -> bool: + """Whether charge prediction and supervision are enabled.""" + return self in { + Representation.CHARGED_POINTCLOUD, + Representation.MOLECULE, + } + + @property + def requires_topology(self) -> bool: + """Whether runtime edges represent chemical bonds.""" + return self in { + Representation.GEOMETRIC_GRAPH, + Representation.MOLECULE, + } + + +@dataclass(frozen=True) +class Capabilities: + """Chemical channels available in a dataset's canonical records.""" + + provides_charges: bool + provides_topology: bool + + +def validate_representation(caps: Capabilities, mode: Representation) -> None: + """Raise if the dataset cannot satisfy the requested representation.""" + if mode.requires_charges and not caps.provides_charges: + raise ValueError( + f"Representation {mode} requires charges but dataset does not provide them" + ) + if mode.requires_topology and not caps.provides_topology: + raise ValueError( + f"Representation {mode} requires topology but dataset does not provide it" + ) + + +def neutral_charge_index(vocab: Vocab) -> int: + """Index of the neutral ``"0"`` charge token in the canonical vocab; falls back + to literal 0 when ``"0"`` isn't a discovered token (e.g. TMQM without charges). + The fallback is just a constant the embedding absorbs into bias. + """ + try: + return vocab.charge_tokens.index("0") + except (ValueError, AttributeError): + return 0 + + +def project_molecule_to_representation( + mol: ChemAtomicData, vocab: Vocab, mode: Representation +) -> ChemAtomicData: + """Project canonical ``ChemAtomicData`` onto the run's representation. + + Mutates and returns ``mol`` so any extra attributes (e.g. ``y``) are preserved. + + - non-topology modes: ``neighbor_list = fully_connected(N)``, ``edge_types = + zeros(E)`` (token 0 of canonical edge vocab is ```` by construction). + - non-charge modes: ``charge_types = neutral_idx(N)`` (canonical index of ``"0"``). + - molecule: unchanged. + """ + n = mol.positions.shape[0] + device = mol.positions.device + + if not mode.requires_topology: + edge_index = build_fully_connected_edge_index(n, device=device) + mol.neighbor_list = edge_index.t().contiguous() + mol.edge_types = torch.zeros( + mol.neighbor_list.shape[0], dtype=torch.long, device=device + ) + + if not mode.requires_charges: + mol.charge_types = torch.full( + (n,), neutral_charge_index(vocab), dtype=torch.long, device=device + ) + + return mol + + +def project_distributions_to_representation( + distributions: Distributions, vocab: Vocab, mode: Representation +) -> Distributions: + """Project canonical (or uniform) distributions onto the run's representation. + + Mirrors ``project_molecule_to_representation`` for the priors that + ``sample_prior_graph``, the interpolator's internal Categoricals, and the + integrator's insertion sampler draw from. Pins ``edge_type_distribution`` to + one-hot at the canonical ```` token (index 0) in non-topology modes, + and ``charge_type_distribution`` to one-hot at the neutral charge index in + non-charge modes. Other fields pass through untouched. + """ + new = replace(distributions) + if not mode.requires_topology: + new.edge_type_distribution = F.one_hot( + torch.tensor(0), len(vocab.edge_tokens) + ).float() + if not mode.requires_charges: + new.charge_type_distribution = F.one_hot( + torch.tensor(neutral_charge_index(vocab)), len(vocab.charge_tokens) + ).float() + return new + + +def make_uniform_type_prior( + distributions: Distributions, + vocab: Vocab, + mode: Representation, +) -> Distributions: + """Build the representation-aware uniform categorical flow source. + + Atom, edge, and charge tokens have equal probability before projection. + Channels absent from the selected representation are then pinned to their + inert token. Other statistics, including atom counts and coordinate scale, + remain shared with the canonical training statistics. + """ + prior = replace( + distributions, + atom_type_distribution=torch.full_like( + distributions.atom_type_distribution, + 1.0 / distributions.atom_type_distribution.numel(), + ), + edge_type_distribution=torch.full_like( + distributions.edge_type_distribution, + 1.0 / distributions.edge_type_distribution.numel(), + ), + charge_type_distribution=torch.full_like( + distributions.charge_type_distribution, + 1.0 / distributions.charge_type_distribution.numel(), + ), + ) + return project_distributions_to_representation(prior, vocab, mode) diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/tokenized_decode.py b/geom/explicit_h/runtime/src/morph/dataset/core/tokenized_decode.py new file mode 100644 index 0000000000000000000000000000000000000000..a1f41f0e658742aa8c5d19d1d22df2904b4937b4 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/tokenized_decode.py @@ -0,0 +1,158 @@ +"""Decode stored molecular conformers into Morph's token representation.""" + +from __future__ import annotations + +import torch + +from morph.dataset.core.atomic_data import make_chem_atomic +from morph.dataset.core.properties import load_property_array +from morph.dataset.core.representation import Capabilities +from morph.utils.rdkit_utils import IDX_BOND_MAP +from morph.utils.vocab_utils import z_to_atom_types + + +def _lookup_tokens( + labels: torch.Tensor, table: torch.Tensor, kind: str +) -> torch.Tensor: + """Reject unrecognized source labels instead of treating them as categories.""" + if ((labels < 0) | (labels >= table.numel())).any(): + raise ValueError(f"Source {kind} labels are absent from the vocabulary") + indices = table[labels] + if (indices < 0).any(): + raise ValueError(f"Source {kind} labels are absent from the vocabulary") + return indices + + +class TokenizedDecodeMixin: + """Stored PyG ``Data`` -> token-index ``ChemAtomicData`` decode, shared by + molecular dataset adapters. + """ + + def _build_token_maps(self, vocab) -> None: + """Build tensor lookup tables for atom, bond, and charge labels.""" + atom_to_idx = {token: i for i, token in enumerate(vocab.atom_tokens)} + z_table = torch.full((119,), -1, dtype=torch.long) + for z, symbol in enumerate(z_to_atom_types(list(range(1, 119))), start=1): + if symbol in atom_to_idx: + z_table[z] = atom_to_idx[symbol] + self._z_to_atom_idx = z_table + + # Stored labels describe bond orders; vocabulary indices describe model + # categories. They differ when a bond order is absent from training or + # the vocabulary has been reordered. + bond_labels = { + token: label + for label, bond in IDX_BOND_MAP.items() + if label != 0 + for token in (str(label), str(bond)) + } + bond_table = torch.full((max(IDX_BOND_MAP) + 1,), -1, dtype=torch.long) + for index, token in enumerate(vocab.edge_tokens): + label = 0 if token == "" else bond_labels[token] + bond_table[label] = index + self._bond_to_idx = bond_table + + charges = torch.tensor([int(token) for token in vocab.charge_tokens]) + self._charge_min = int(charges.min()) + self._charge_to_idx = torch.full( + (int(charges.max()) - self._charge_min + 1,), -1, dtype=torch.long + ) + self._charge_to_idx[charges - self._charge_min] = torch.arange(len(charges)) + + def _load_property_arrays( + self, compute_properties: bool, property_names, split: str + ) -> None: + """Load precomputed per-graph scalar properties (``qed`` / ``logp`` / ...). + + Read by index from ``/properties//.npy`` -- + never per-sample RDKit (which leaks host RAM across forked workers). Off + by default; supply arrays in the processed split's dataset order. + """ + self.compute_properties = compute_properties + self.property_names = tuple(property_names) + self._property_arrays: dict = {} + if self.compute_properties: + self._property_arrays = { + name: load_property_array(self.processed_dir, name, split) + for name in self.property_names + } + + def _build_conditions(self, data, index: int) -> dict: + """Loose conditioning to attach to the graph. Default: the precomputed + scalar properties when ``compute_properties`` is on, else nothing. + Subclasses override to carry extra dataset-specific fields. + """ + if self.compute_properties: + return self._molecular_properties(index, getattr(data, "smiles", "") or "") + return {} + + def _molecular_properties(self, index: int, smiles: str) -> dict: + """Per-graph scalar properties for molecule ``index`` from the precomputed + arrays, as ``[1]`` float tensors (so they batch as system properties), + plus the canonical ``smiles``. Missing SMILES fall back to ``""``. + """ + out: dict = {"smiles": smiles or ""} + for name, arr in self._property_arrays.items(): + out[name] = torch.tensor([float(arr[index])], dtype=torch.float) + return out + + def _decode_to_lean(self, data, index: int): + """Decode one stored, centered conformer into token indices.""" + coord = data.pos - data.pos.mean(dim=0) + if self.distributions.coordinate_std is not None: + coord = coord / self.distributions.coordinate_std + + return make_chem_atomic( + x=coord, + a=_lookup_tokens(data.z.long(), self._z_to_atom_idx, "atom"), + e=_lookup_tokens(data.edge_attr.long(), self._bond_to_idx, "bond"), + c=_lookup_tokens( + data.charges.long() - self._charge_min, self._charge_to_idx, "charge" + ), + edge_index=data.edge_index, + **self._build_conditions(data, index), + ) + + +class TokenizedMolecularDatasetMixin(TokenizedDecodeMixin): + """Adapt a raw molecular reader to flow-matching targets. + + Place this mixin before the reader in the inheritance list. ``get`` always + returns raw fields for preprocessing; indexing requires vocabulary and + coordinate statistics. Source-specific options pass through to the reader. + """ + + CAPABILITIES = Capabilities(provides_charges=True, provides_topology=True) + + def __init__( + self, + root, + vocab=None, + distributions=None, + split="train", + compute_properties: bool = False, + property_names: tuple[str, ...] = ("qed", "logp"), + **source_options, + ): + super().__init__( + root, + split=split, + vocab=vocab, + distributions=distributions, + **source_options, + ) + if vocab is not None: + self._build_token_maps(vocab) + self._load_property_arrays(compute_properties, property_names, split) + + def __getitem__(self, index): + """Decode token labels and normalize the heavy or explicit-H geometry.""" + if self.vocab is None or self.distributions is None: + raise RuntimeError( + "A vocab and distributions are required; use .get() for raw data" + ) + return self._decode_to_lean(super().__getitem__(index), index) + + def get(self, index): + """Read raw fields without dispatching through tokenized indexing.""" + return super().__getitem__(index) diff --git a/geom/explicit_h/runtime/src/morph/dataset/core/vocab.py b/geom/explicit_h/runtime/src/morph/dataset/core/vocab.py new file mode 100644 index 0000000000000000000000000000000000000000..af28186c1c928a0a56b35e4858f1ddaa525eeb8b --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/core/vocab.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass + +import torch + +from morph.utils.vocab_utils import compute_token_weights + + +@dataclass +class Vocab: + """Ordered atom, bond, and charge tokens used by integer labels.""" + + atom_tokens: list[str] + edge_tokens: list[str] + charge_tokens: list[str] + + +@dataclass +class Distributions: + """Empirical token frequencies, size priors, and coordinate statistics.""" + + atom_type_distribution: torch.Tensor + edge_type_distribution: torch.Tensor + charge_type_distribution: torch.Tensor + n_atoms_distribution: torch.Tensor + coordinate_std: float + # Pointcloud-mode evaluation targets in Angstroms (computed on centered, + # un-normalized training coords). Shape (A, A, B) with the (lo, hi) + # atom-type-index convention; None if not computed. + pairwise_distance_histogram: torch.Tensor | None = None + # Shape (B,). None if not computed. + radius_of_gyration_histogram: torch.Tensor | None = None + + +def setup_token_weights( + vocab: Vocab, + distributions: Distributions, + weight_alpha: float, + type_loss_token_weights: str, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Compute atom, edge, and charge token weights from training distributions. + + Returns: + (atom_type_weights, edge_token_weights, charge_token_weights) + """ + atom_type_weights = compute_token_weights( + token_list=vocab.atom_tokens, + distribution=distributions.atom_type_distribution, + special_token_names=[], + weight_alpha=weight_alpha, + type_loss_token_weights=type_loss_token_weights, + ) + edge_token_weights = compute_token_weights( + token_list=vocab.edge_tokens, + distribution=distributions.edge_type_distribution, + special_token_names=[""], + weight_alpha=weight_alpha, + type_loss_token_weights=type_loss_token_weights, + ) + charge_token_weights = compute_token_weights( + token_list=vocab.charge_tokens, + distribution=distributions.charge_type_distribution, + special_token_names=[], + weight_alpha=weight_alpha, + type_loss_token_weights=type_loss_token_weights, + ) + return atom_type_weights, edge_token_weights, charge_token_weights diff --git a/geom/explicit_h/runtime/src/morph/dataset/unconditional/__init__.py b/geom/explicit_h/runtime/src/morph/dataset/unconditional/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6bcfd2cf5eb8e220756c471312279d00685a1f32 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/unconditional/__init__.py @@ -0,0 +1,8 @@ +"""Unconditional dataset sources. + +Each module ports a raw dataset (download / preprocess into LMDB or an +in-memory PyG dataset) and exposes a ``FlowMatching*`` subclass that +tokenises samples into :class:`~morph.dataset.core.atomic_data.ChemAtomicData`: +GEOM-Drugs (:mod:`geom`), revised QM9 (:mod:`qm9`), materials / MP-20 +(:mod:`materials`), tmQM (:mod:`tmqm`) and Vaska's space (:mod:`vaskas`). +""" diff --git a/geom/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py b/geom/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py new file mode 100644 index 0000000000000000000000000000000000000000..ca3c828c4e642c177dc90d6bd526adc42c5010c4 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py @@ -0,0 +1,239 @@ +"""Convert LoQI's processed ChEMBL3D release to Morph's LMDB format. + +Source: https://github.com/isayevlab/LoQI (Release 2, Figshare 31441570). +The supplied train/val/test partitions are retained. Atom labels in ``x`` +are decoded using LoQI's FULL_ATOM_ENCODER; auxiliary stereo edges 5--8 +are excluded from chemical topology. Hydrogens, charges and coordinates +in Angstroms are retained, with no GEOM-specific size or element filter. +""" + +from __future__ import annotations + +import hashlib +import os +import tempfile +from functools import partial +from pathlib import Path +from urllib.request import urlopen + +import torch +from rdkit import Chem +from torch_geometric.data import Data +from torch_geometric.data.separate import separate +from tqdm import tqdm + +from morph.dataset.core.conversion import mol_to_data +from morph.dataset.core.lmdb import ( + HYDROGEN_MODES, + MolecularLMDBDataset, +) +from morph.dataset.core.tokenized_decode import TokenizedMolecularDatasetMixin +from morph.utils.rdkit_utils import ( + IDX_BOND_MAP, + sanitize_mol_correctly, +) + +# Pinned to the processed release linked by LoQI, version 1. Only graph +# splits are needed; checkpoints and LoQI's statistics are not downloaded. +RELEASE_FILES = { + "train": (62280814, 6761964617, "391ddd92375ed908527ccfe2e12b83a0"), + "val": (62280805, 845871535, "dc2bd573245520a2bcbc709c252b72cb"), + "test": (62280802, 845962054, "7eacc9247a19b6c0865609d4b0e28364"), +} +ATOM_NUMBERS = torch.tensor( + [1, 5, 6, 7, 8, 9, 13, 14, 15, 16, 17, 33, 35, 53, 80, 83, 34], + dtype=torch.long, +) +PHYSICAL_EDGE_LABELS = tuple(label for label in IDX_BOND_MAP if label != 0) +STEREO_EDGE_LABELS = (5, 6, 7, 8) # LoQI auxiliary stereo constraints. +GRAPH_FIELDS = ("x", "pos", "charges", "edge_index", "edge_attr") + + +def _download_split(split: str, destination: Path) -> None: + """Download and verify a split before publishing it in the raw cache.""" + file_id, size, checksum = RELEASE_FILES[split] + destination.parent.mkdir(parents=True, exist_ok=True) + url = f"https://ndownloader.figshare.com/files/{file_id}" + digest = hashlib.md5() # Release-provided integrity checksum. + with tempfile.TemporaryDirectory(dir=destination.parent) as staging: + temporary = Path(staging) / destination.name + with ( + urlopen(url, timeout=120) as response, + temporary.open("wb") as output, + tqdm(total=size, unit="B", unit_scale=True, desc=destination.name) as bar, + ): + while chunk := response.read(1024 * 1024): + output.write(chunk) + digest.update(chunk) + bar.update(len(chunk)) + if temporary.stat().st_size != size or digest.hexdigest() != checksum: + raise ValueError(f"Size/checksum mismatch downloading {destination.name}") + os.replace(temporary, destination) + + +def _load_split(path: Path) -> tuple[Data, dict | None, int]: + """Load collated PyG tensors without the per-record InMemoryDataset cache. + + Tensor storage is memory mapped. PyTorch still unpickles the source's + Python metadata (including RDKit molecules); discard it immediately. + Only trusted LoQI release files should be passed to this loader. + """ + payload = torch.load(path, map_location="cpu", weights_only=False, mmap=True) + if not isinstance(payload, tuple) or len(payload) not in (2, 3): + raise ValueError(f"Expected a collated PyG dataset in {path}") + source, slices = payload[:2] + fields = {key: source[key] for key in GRAPH_FIELDS} + data = Data(**fields) + if slices is None: + return data, None, 1 + slices = {key: slices[key] for key in GRAPH_FIELDS} + length = len(slices["x"]) - 1 + if any(len(value) != length + 1 for value in slices.values()): + raise ValueError(f"Inconsistent graph slices in {path}") + return data, slices, length + + +def convert_record( + record: Data, + *, + atom_numbers: torch.Tensor = ATOM_NUMBERS, + stereo_edge_labels: tuple[int, ...] = STEREO_EDGE_LABELS, + hydrogen_mode: str = "explicit", + max_atoms: int | None = None, + allowed_atom_symbols: tuple[str, ...] | None = None, +) -> Data | None: + """Decode a LoQI graph, retaining only valid physical molecular topology. + + Malformed encodings raise an error; invalid chemistry/non-finite geometry + is skipped. Rebuilding from the physical graph avoids dependence on the + optional pickled RDKit molecule and its conformer/stereochemistry state. + """ + if hydrogen_mode not in HYDROGEN_MODES: + raise ValueError(f"hydrogen_mode must be one of {HYDROGEN_MODES}") + atom_types = record.x.long() + n_atoms = atom_types.numel() + if atom_types.shape != (n_atoms,) or n_atoms == 0: + raise ValueError("Expected a nonempty one-dimensional LoQI atom label array") + if (atom_types < 0).any() or (atom_types >= len(atom_numbers)).any(): + raise ValueError("Unknown LoQI atom label") + if record.pos.shape != (n_atoms, 3) or record.charges.shape != (n_atoms,): + raise ValueError("Coordinates/charges do not match the atom count") + if not torch.isfinite(record.pos).all(): + return None + edges = record.edge_index.long() + labels = record.edge_attr.long() + if edges.shape != (2, labels.numel()) or labels.ndim != 1: + raise ValueError("Expected edge_index [2, E] and edge_attr [E]") + physical = torch.isin(labels, labels.new_tensor(PHYSICAL_EDGE_LABELS)) + stereo = torch.isin(labels, labels.new_tensor(stereo_edge_labels)) + if not (physical | stereo).all(): + raise ValueError("Unknown LoQI edge label") + if (edges < 0).any() or (edges >= n_atoms).any(): + raise ValueError("Edge index outside the molecule") + + edges, labels = edges[:, physical], labels[physical] + topology = dict(zip(map(tuple, edges.t().tolist()), labels.tolist(), strict=True)) + if len(topology) != labels.numel() or any( + i == j or topology.get((j, i)) != label for (i, j), label in topology.items() + ): + raise ValueError( + "Physical bonds must be unique, symmetric and without self edges" + ) + + z = atom_numbers[atom_types] + charges = record.charges.long().clone() + # A slice shares storage with the entire split. Clone even when dtype is + # unchanged, or pickle would serialize that whole storage per molecule. + pos = record.pos.float().clone() + editable = Chem.RWMol() + for atomic_number, charge in zip(z.tolist(), charges.tolist(), strict=True): + atom = Chem.Atom(atomic_number) + atom.SetFormalCharge(charge) + atom.SetNoImplicit(True) + editable.AddAtom(atom) + for (i, j), label in topology.items(): + if i < j: + editable.AddBond(i, j, IDX_BOND_MAP[label]) + mol = editable.GetMol() + mol = sanitize_mol_correctly(mol) + if mol is None: + return None + try: + Chem.Kekulize(mol, clearAromaticFlags=True) + conformer = Chem.Conformer(n_atoms) + conformer.SetPositions(pos.double().numpy()) + mol.AddConformer(conformer) + Chem.AssignStereochemistryFrom3D(mol, replaceExistingTags=True) + except (ValueError, RuntimeError): + return None + return mol_to_data( + mol, + hydrogen_mode=hydrogen_mode, + max_atoms=max_atoms, + allowed_atom_symbols=allowed_atom_symbols, + ) + + +def _iter_records(data: Data, slices: dict | None, length: int): + for index in range(length): + if slices is None: + yield data.clone() + else: + # Cloning also keeps multiprocessing from sharing the entire split + # tensor storage when a single graph is sent to a worker. + yield separate(Data, data, index, slices, decrement=False).clone() + + +class ChEMBL3D(MolecularLMDBDataset): + """Read local LoQI release splits or download missing files into raw/.""" + + def __init__(self, root, source_dir: str | None = None, **options): + self.source_dir = source_dir + super().__init__(root, **options) + + def prepare(self) -> None: + """Locate or download the requested release split, then convert it.""" + self.download() + self.process() + + def _source_path(self) -> Path | None: + base = Path(self.source_dir) if self.source_dir is not None else Path(self.root) + for directory in ( + base, + base / "raw", + base / "processed", + base / "chembl3d_stereo" / "processed", + ): + path = directory / f"{self.split}_h.pt" + if path.is_file(): + return path + return None + + def download(self) -> None: + """Reuse supplied files, otherwise download the requested release split.""" + if self._source_path() is not None: + return + if self.source_dir is not None: + raise FileNotFoundError( + f"{self.split}_h.pt not found under source_dir={self.source_dir!r}" + ) + _download_split(self.split, Path(self.raw_dir) / f"{self.split}_h.pt") + + def process(self) -> None: + """Stream the selected split into a new LMDB; never modify source files.""" + source = self._source_path() + if source is None: + raise FileNotFoundError(f"Missing source for {self.split}") + if os.path.exists(self.lmdb_path): + raise FileExistsError(self.lmdb_path) + print(f"Loading {source} (tensor mmap; Python metadata is loaded into RAM)") + data, slices, length = _load_split(source) + self._write_records( + _iter_records(data, slices, length), + partial(convert_record, **self.processing_options), + total=length, + ) + + +class FlowMatchingChEMBL3DDataset(TokenizedMolecularDatasetMixin, ChEMBL3D): + """Decode ChEMBL3D records into tokenized flow-matching targets.""" diff --git a/geom/explicit_h/runtime/src/morph/dataset/unconditional/geom.py b/geom/explicit_h/runtime/src/morph/dataset/unconditional/geom.py new file mode 100644 index 0000000000000000000000000000000000000000..6ae3c25506028c6bc451551d4ef6e1fbf4e32b4d --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/unconditional/geom.py @@ -0,0 +1,110 @@ +import os +import pickle +from functools import partial + +from rdkit import Chem +from torch_geometric.data import Data, download_url + +from morph._vendor.geom_drugs_preprocessing import process_geom_drugs +from morph.dataset.core.conversion import mol_to_data +from morph.dataset.core.lmdb import MolecularLMDBDataset +from morph.dataset.core.tokenized_decode import TokenizedMolecularDatasetMixin +from morph.utils.rdkit_utils import ( + sanitize_mol_correctly, +) + +# Source of the raw GEOM-Drugs pickle files used by the GEOM-Drugs Revisited +# paper (Nikitin et al., 2025, arXiv:2505.00169). Three files live here: +# train_data.pickle, val_data.pickle, test_data.pickle. +GEOM_RAW_URL = "https://bits.csb.pitt.edu/files/geom_raw" + +RAW_FILENAMES = { + "train": "train_data.pickle", + "val": "val_data.pickle", + "test": "test_data.pickle", +} + + +def process_one_conformer(mol: Chem.Mol, **options) -> Data | None: + """Sanitize a source conformer, then apply the shared processing settings.""" + if mol is None: + return None + mol = sanitize_mol_correctly(Chem.Mol(mol)) + return mol_to_data(mol, **options) if mol is not None else None + + +class GEOM(MolecularLMDBDataset): + """GEOM-Drugs source processing with shared molecular LMDB storage.""" + + def __init__(self, root, source_dir: str | None = None, **options): + self.source_dir = source_dir + self.filtered_dir = ( + os.fspath(source_dir) + if source_dir is not None + else os.path.join(root, "raw_filtered") + ) + super().__init__(root, **options) + + def prepare(self) -> None: + """Download, prefilter and convert a missing GEOM split.""" + if self.source_dir is None: + self.download() + self.prefilter() + self.process() + + def download(self) -> None: + """Download missing raw split files.""" + os.makedirs(self.raw_dir, exist_ok=True) + for fname in RAW_FILENAMES.values(): + target = os.path.join(self.raw_dir, fname) + if os.path.exists(target): + continue + print(f"Downloading {fname} from {GEOM_RAW_URL} ...") + download_url(f"{GEOM_RAW_URL}/{fname}", self.raw_dir) + + def prefilter(self) -> None: + """Run the upstream GEOM prefilter once per source directory.""" + all_present = all( + os.path.exists(os.path.join(self.filtered_dir, fname)) + for fname in RAW_FILENAMES.values() + ) + if all_present: + return + + print("Applying GEOM-Drugs Revisited pre-filter to all splits ...") + process_geom_drugs(self.raw_dir, self.filtered_dir) + + # Keep empty download sentinels once the filtered copies exist. + for fname in RAW_FILENAMES.values(): + raw_path = os.path.join(self.raw_dir, fname) + filtered_path = os.path.join(self.filtered_dir, fname) + if ( + os.path.exists(filtered_path) + and os.path.exists(raw_path) + and os.path.getsize(raw_path) > 0 + ): + with open(raw_path, "wb"): + pass + print(f" Truncated raw {fname} (filtered copy in {self.filtered_dir})") + + def process(self): + """Process pre-filtered pickle files and write them into LMDB.""" + raw_file = os.path.join(self.filtered_dir, f"{self.split}_data.pickle") + + if not os.path.exists(raw_file): + raise FileNotFoundError(f"Filtered data file not found: {raw_file}") + + print(f"Loading filtered data from {raw_file}...") + with open(raw_file, "rb") as f: + raw_data = pickle.load(f) + + conformers = (mol for _, group in raw_data for mol in group) + self._write_records( + conformers, + partial(process_one_conformer, **self.processing_options), + total=sum(len(group) for _, group in raw_data), + ) + + +class FlowMatchingGEOMDataset(TokenizedMolecularDatasetMixin, GEOM): + """Decode GEOM records into tokenized flow-matching targets.""" diff --git a/geom/explicit_h/runtime/src/morph/dataset/unconditional/qm9.py b/geom/explicit_h/runtime/src/morph/dataset/unconditional/qm9.py new file mode 100644 index 0000000000000000000000000000000000000000..0ffb06411d0e56c4409d84802389c3a8479f63b7 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/dataset/unconditional/qm9.py @@ -0,0 +1,262 @@ +import os + +import torch +from rdkit import Chem, RDLogger +from rdkit.Chem.rdchem import HybridizationType +from torch_geometric.data import Data, InMemoryDataset, download_url, extract_zip +from torch_geometric.datasets.qm9 import conversion +from torch_geometric.utils import one_hot, scatter +from tqdm import tqdm + +from morph.dataset.core.representation import Capabilities +from morph.dataset.core.tokenized_decode import TokenizedDecodeMixin +from morph.dataset.core.vocab import Distributions, Vocab +from morph.utils.rdkit_utils import ( + BOND_IDX_MAP, + mol_is_valid, + sanitize_mol_correctly, + smiles_from_mol, +) + +RDLogger.DisableLog("rdApp.*") # type: ignore[attr-defined] + + +class RevisedQM9(InMemoryDataset): + """QM9 with corrected structures from PropMolFlow.""" + + mols_url = "https://zenodo.org/records/15700961/files/all_fixed_gdb9.zip" + props_url = "https://deepchemdata.s3-us-west-1.amazonaws.com/datasets/molnet_publish/qm9.zip" + split_to_index = {"train": 0, "val": 1, "test": 2} + + @property + def raw_file_names(self) -> list[str]: + """Source structure and property files required for preprocessing.""" + return ["all_fixed_gdb9.sdf", "gdb9.sdf.csv"] + + @property + def processed_file_names(self) -> list[str]: + """Serialized records for the train, validation, and test splits.""" + return ["train.pt", "val.pt", "test.pt"] + + def load(self, split: str) -> None: + """Load one named split from the processed cache.""" + if split not in self.split_to_index: + raise ValueError( + f"Invalid split '{split}'. " + f"Expected one of {tuple(self.split_to_index)}." + ) + self._split = split + super().load(self.processed_paths[self.split_to_index[split]]) + + def download(self) -> None: + """Download corrected structures and QM9 regression properties.""" + file_path = download_url(self.mols_url, self.raw_dir) + extract_zip(file_path, self.raw_dir) + os.unlink(file_path) + + file_path = download_url(self.props_url, self.raw_dir) + extract_zip(file_path, self.raw_dir) + + os.unlink(file_path) + # also delete the extra data file in the zip + os.unlink(os.path.join(self.raw_dir, "gdb9.sdf")) + + def process(self) -> None: + """Validate molecules, encode raw graphs, and write dataset splits.""" + types = {"H": 0, "C": 1, "N": 2, "O": 3, "F": 4} + bonds = BOND_IDX_MAP + + with open(self.raw_paths[1]) as f: + target = [ + [float(x) for x in line.split(",")[1:20]] + for line in f.read().split("\n")[1:-1] + ] + y = torch.tensor(target, dtype=torch.float) + y = torch.cat([y[:, 3:], y[:, :3]], dim=-1) + y = y * conversion.view(1, -1) + + suppl = Chem.SDMolSupplier(self.raw_paths[0], removeHs=False, sanitize=False) + + errors = 0 + skipped = 0 + + data_list = [] + for i, mol in enumerate(tqdm(suppl)): + # if i in skip: + # continue + if mol is None or not mol_is_valid(mol): + errors += 1 + continue + + mol = sanitize_mol_correctly(mol) + if mol is None: + errors += 1 + continue + + N = mol.GetNumAtoms() + + conf = mol.GetConformer() + pos = conf.GetPositions() + pos = torch.tensor(pos, dtype=torch.float) + + charges = [] + + type_idx = [] + atomic_number = [] + aromatic = [] + sp = [] + sp2 = [] + sp3 = [] + num_hs = [] + for atom in mol.GetAtoms(): + type_idx.append(types[atom.GetSymbol()]) + atomic_number.append(atom.GetAtomicNum()) + aromatic.append(1 if atom.GetIsAromatic() else 0) + hybridization = atom.GetHybridization() + sp.append(1 if hybridization == HybridizationType.SP else 0) + sp2.append(1 if hybridization == HybridizationType.SP2 else 0) + sp3.append(1 if hybridization == HybridizationType.SP3 else 0) + charges.append(atom.GetFormalCharge()) + + z = torch.tensor(atomic_number, dtype=torch.long) + + rows, cols, edge_types = [], [], [] + for bond in mol.GetBonds(): + start, end = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + rows += [start, end] + cols += [end, start] + edge_types += 2 * [bonds[bond.GetBondType()]] + + edge_index = torch.tensor([rows, cols], dtype=torch.long) + edge_type = torch.tensor(edge_types, dtype=torch.long) + # edge_attr = one_hot(edge_type, num_classes=len(bonds)) + + perm = (edge_index[0] * N + edge_index[1]).argsort() + edge_index = edge_index[:, perm] + edge_type = edge_type[perm] + # edge_attr = edge_attr[perm] + + row, col = edge_index + hs = (z == 1).to(torch.float) + num_hs = scatter(hs[row], col, dim_size=N, reduce="sum").tolist() + + x1 = one_hot(torch.tensor(type_idx), num_classes=len(types)) + x2 = ( + torch.tensor( + [atomic_number, aromatic, sp, sp2, sp3, num_hs], dtype=torch.float + ) + .t() + .contiguous() + ) + x = torch.cat([x1, x2], dim=-1) + + charges = torch.tensor(charges, dtype=torch.int64) + + name = mol.GetProp("_Name") + smiles = smiles_from_mol(mol, canonical=True) or "" + + # TODO exchange with our own MolData object for consistency + data = Data( + x=x, + z=z, + pos=pos, + charges=charges, + edge_index=edge_index, + smiles=smiles, + edge_attr=edge_type, + y=y[i].unsqueeze(0), + name=name, + idx=i, + ) + + if self.pre_filter is not None and not self.pre_filter(data): + continue + if self.pre_transform is not None: + data = self.pre_transform(data) + + data_list.append(data) + + n_train = 100_000 + n_val = 20_000 + + self.save(data_list[:n_train], self.processed_paths[0]) + self.save(data_list[n_train : n_train + n_val], self.processed_paths[1]) + self.save(data_list[n_train + n_val :], self.processed_paths[2]) + + for split_name, split_data in [ + ("train", data_list[:n_train]), + ("val", data_list[n_train : n_train + n_val]), + ("test", data_list[n_train + n_val :]), + ]: + smiles_path = os.path.join(self.processed_dir, f"{split_name}_smiles.txt") + unique_smiles = sorted({d.smiles for d in split_data}) + with open(smiles_path, "w") as f: + f.write("\n".join(unique_smiles)) + + print(f"Errors: {errors}") + print(f"Skipped: {skipped}") + + def get_all_smiles(self) -> list[str]: + """Read SMILES in the loaded split's record order.""" + smiles_path = os.path.join(self.processed_dir, f"{self._split}_smiles.txt") + with open(smiles_path) as f: + return f.read().splitlines() + + +class FlowMatchingQM9Dataset(TokenizedDecodeMixin, RevisedQM9): + """Decode corrected QM9 records into tokenized training targets.""" + + CAPABILITIES = Capabilities(provides_charges=True, provides_topology=True) + + def __init__( + self, + root, + vocab: Vocab, + distributions: Distributions, + transform=None, + pre_transform=None, + split="train", + compute_properties: bool = False, + property_names: tuple[str, ...] = ("qed", "logp"), + ): + super().__init__(root, transform, pre_transform) + self.load(split) + + self.vocab = vocab + self.distributions = distributions + # Token/z-index maps + precomputed property arrays (shared decode adapter). + if vocab is not None: + self._build_token_maps(vocab) + self._load_property_arrays(compute_properties, property_names, self._split) + + def __getitem__(self, index): + """Return a tokenized molecule with its QM9 regression targets.""" + if self.vocab is None: + raise RuntimeError( + "A vocab is required for tokenized QM9 samples; use .get() for raw data" + ) + data = super().__getitem__(index) + mol = self._decode_to_lean(data, index) + + # Carry through the QM9 regression targets if present (2D so per-graph + # batching concatenates along dim 0). + if hasattr(data, "y") and data.y is not None: + mol.add_system_property( + "y", data.y if data.y.dim() == 2 else data.y.unsqueeze(0) + ) + + return mol + + def _build_conditions(self, data, index: int) -> dict: + conditions = super()._build_conditions(data, index) + # When not computing properties, still keep the cheap ground-truth SMILES + # string for downstream eval. + if not self.compute_properties and hasattr(data, "smiles"): + conditions["smiles"] = data.smiles + return conditions + + +if __name__ == "__main__": + _root = os.path.join(os.path.dirname(__file__), "../../../../data/qm9") + qm9 = FlowMatchingQM9Dataset(root=_root) + print(qm9[0]) diff --git a/geom/explicit_h/runtime/src/morph/flow_matching/__init__.py b/geom/explicit_h/runtime/src/morph/flow_matching/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d9b707d8cad88bfd9db1a9318b10da933375a2a --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/flow_matching/__init__.py @@ -0,0 +1 @@ +"""Flow-matching core: graph priors, interpolation paths, and integration.""" diff --git a/geom/explicit_h/runtime/src/morph/flow_matching/assignment.py b/geom/explicit_h/runtime/src/morph/flow_matching/assignment.py new file mode 100644 index 0000000000000000000000000000000000000000..64be2a6d5728675c8922c37057ccdaaf3792c679 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/flow_matching/assignment.py @@ -0,0 +1,459 @@ +"""Partial-optimal-transport alignment for flow matching. + +Two layers in one module: + * the data-structure-agnostic assignment maths (cost matrices, Hungarian + solving and MCS constraints) in pure ``numpy`` / ``scipy``; + * thin wrappers operating on + :class:`~morph.dataset.core.atomic_data.ChemAtomicData`. +""" + +from __future__ import annotations + +import numpy as np +import torch +from scipy.optimize import linear_sum_assignment + +from morph.dataset.core.atomic_data import ChemAtomicData +from morph.utils.graph_math import rigid_alignment + + +def _pairwise_cost(x0, x1, squared=False): + """Pairwise ground cost between two point sets. + + Euclidean distance (W1 ground cost) by default; squared Euclidean (W2 ground + cost) when ``squared=True``. The W2 cost is consistent with both the + flow-matching straight-line interpolant and the Kabsch (RMSD) alignment, so + the permutation and the rotation then descend the same energy. + """ + diff = x0[:, np.newaxis] - x1[np.newaxis, :] + if squared: + return np.einsum("ijd,ijd->ij", diff, diff) + return np.linalg.norm(diff, axis=2) + + +def distance_based_assignment(x0, x1, squared=False): + """ + Assign targets from x1 to x0 using the Hungarian algorithm. + + Args: + x0 (np.ndarray): Shape (N_x0, D) + x1 (np.ndarray): Shape (N_x1, D) + squared (bool): Use squared-Euclidean (W2) ground cost instead of + Euclidean (W1). + + Returns: + tuple: A tuple of two arrays: + - row_ind (np.ndarray): Shape (N_x0,) + - col_ind (np.ndarray): Shape (N_x1,) + """ + # Calculate the cost matrix *only* for valid items + # (N_valid, 1, D) - (1, M_valid, D) => (N_valid, M_valid, D) + cost_matrix_b = _pairwise_cost(x0, x1, squared=squared) + # cost_matrix_b shape is (N_x0, N_x1) + + # Perform the assignment + # row_ind and col_ind are indices *into* x0 and x1 + row_ind, col_ind = linear_sum_assignment(cost_matrix_b) + # K_b = len(row_ind) + + return row_ind, col_ind + + +def pre_align_positions(x0, x1, num_iterations=5, squared=False): + """Pre-align positions by iterating assignment and Kabsch alignment. + + This is the equivariant-OT inner loop (Klein et al. 2023): alternating the + assignment and the rigid (Kabsch) alignment jointly optimizes permutation + *and* rotation. With ``squared=True`` both steps minimize the same energy + (sum of matched squared distances), so the alternation is well-posed. + """ + for _ in range(num_iterations): + row_ind, col_ind = distance_based_assignment(x0, x1, squared=squared) + x0_tensor = torch.from_numpy(x0) + x1_tensor = torch.from_numpy(x1) + R, t = rigid_alignment(x0_tensor[row_ind], x1_tensor[col_ind]) + x0 = (x0_tensor @ R.T + t).numpy() + + return x0, x1 + + +def distance_and_class_based_assignment( + x0, + x1, + a0, + a1, + c_move=1.0, + c_sub=10.0, + c_ins=5.0, + c_del=10.0, + squared=False, +): + """ + Assign targets from x1 to x0 for a single graph pair using the Hungarian + algorithm on an augmented (move / substitute / insert / delete) cost matrix. + + Special cases: + 1) Distance-based assignment + c_move=const, c_sub=0, c_ins>>c_move + + 2) Class-based assignment. + Note: This will not be useful for our application! + c_move=0, c_sub=const, c_ins>>c_sub + + 3) Pure insertion-deletion assignment - no move. + c_move>>c_ins, c_sub=const, c_ins=const + + Args: + x0 (np.ndarray): Shape (N_x0, D) + x1 (np.ndarray): Shape (N_x1, D) + a0 (np.ndarray): Shape (N_x0, C) + a1 (np.ndarray): Shape (N_x1, C) + c_move (float): Distance cost weight + c_sub (float): Class cost weight + c_ins (float): Insertion cost weight + c_del (float): Deletion cost weight + squared (bool): If True, use squared-Euclidean (W2) move costs. + + Returns: + tuple: A tuple of two arrays: + - row_ind (np.ndarray): Shape (N_x0,) + - col_ind (np.ndarray): Shape (N_x1,) + """ + N = x0.shape[0] + M = x1.shape[0] + + if N == 0 or M == 0: + return np.array([], dtype=int), np.array([], dtype=int) + + # 1. Calculate Real Costs. With ``squared`` the move term is squared-Euclidean + # (W2); c_ins/c_del are then in squared-distance units (so c_ins + c_del is + # the squared move-distance horizon beyond which del+ins beats a move). + dist_matrix = _pairwise_cost(x0, x1, squared=squared) + cost_real = c_move * dist_matrix + if c_sub: + class_diff = (a0[:, np.newaxis] != a1[np.newaxis, :]).astype(float) + cost_real = cost_real + c_sub * class_diff + + # 2. Construct Augmented Cost Matrix (N+M, N+M) + total_size = N + M + aug_cost = np.zeros((total_size, total_size)) + + # [Top-Left] Real matches (N x M) + aug_cost[:N, :M] = cost_real + + # Real atoms may only use their own insertion/deletion dummy. The remaining + # dummy-to-dummy block stays at the zero initialization above. + aug_cost[:N, M:] = 1e8 + np.fill_diagonal(aug_cost[:N, M:], c_del) + aug_cost[N:, :M] = 1e8 + np.fill_diagonal(aug_cost[N:, :M], c_ins) + + # 3. Solve + row_ind, col_ind = linear_sum_assignment(aug_cost) + + return row_ind, col_ind + + +def mcs_constrained_assignment( + x0, + x1, + a0, + a1, + mcs_pairs: list[tuple[int, int]], + c_move=1.0, + c_sub=10.0, + c_ins=5.0, + c_del=10.0, + squared=False, +): + """Run OT on non-MCS atoms and merge with fixed MCS pairs. + + This enforces backbone matches from ``mcs_pairs`` and computes all + remaining assignments with the same partial OT routine used elsewhere. + """ + N = x0.shape[0] + M = x1.shape[0] + + # Keep only valid, one-to-one MCS pairs. + valid_pairs = [] + used_src = set() + used_tgt = set() + for i_s, i_t in mcs_pairs: + if not (0 <= i_s < N and 0 <= i_t < M): + continue + if i_s in used_src or i_t in used_tgt: + continue + valid_pairs.append((i_s, i_t)) + used_src.add(i_s) + used_tgt.add(i_t) + + unmatched_src = [i for i in range(N) if i not in used_src] + unmatched_tgt = [j for j in range(M) if j not in used_tgt] + n_u = len(unmatched_src) + m_u = len(unmatched_tgt) + + # Pair list in the augmented global index spaces: + # source indices in [0, N+M), target indices in [0, M+N) + global_pairs: list[tuple[int, int]] = [] + + # 1) Force MCS real-real pairs. + for i_s, i_t in valid_pairs: + global_pairs.append((i_s, i_t)) + + # 2) Pair corresponding MCS dummies with each other. + # source dummy for target i_t is N + i_t + # target dummy for source i_s is M + i_s + for i_s, i_t in valid_pairs: + global_pairs.append((N + i_t, M + i_s)) + + # 3) Solve the reduced OT for non-MCS atoms only. + if n_u > 0 and m_u > 0: + row_u, col_u = distance_and_class_based_assignment( + x0[np.array(unmatched_src)], + x1[np.array(unmatched_tgt)], + a0[np.array(unmatched_src)], + a1[np.array(unmatched_tgt)], + c_move=c_move, + c_sub=c_sub, + c_ins=c_ins, + c_del=c_del, + squared=squared, + ) + + for r_loc, c_loc in zip(row_u.tolist(), col_u.tolist(), strict=True): + # Map reduced augmented indices to full augmented indices. + if r_loc < n_u: + r_glob = unmatched_src[r_loc] + else: + r_glob = N + unmatched_tgt[r_loc - n_u] + + if c_loc < m_u: + c_glob = unmatched_tgt[c_loc] + else: + c_glob = M + unmatched_src[c_loc - m_u] + + global_pairs.append((r_glob, c_glob)) + elif n_u == 0 and m_u > 0: + # Only insertions remain (all unmatched targets). + global_pairs.extend((N + j, j) for j in unmatched_tgt) + elif n_u > 0 and m_u == 0: + # Only deletions remain (all unmatched sources). + global_pairs.extend((i, M + i) for i in unmatched_src) + + if len(global_pairs) != N + M: + raise ValueError( + "MCS-constrained assignment did not produce a complete matching " + f"(got {len(global_pairs)}, expected {N + M})." + ) + + row_ind = np.array([p[0] for p in global_pairs], dtype=int) + col_ind = np.array([p[1] for p in global_pairs], dtype=int) + + # Safety checks: augmented assignment must be a permutation on both sides. + if len(np.unique(row_ind)) != (N + M) or len(np.unique(col_ind)) != (N + M): + raise ValueError("MCS-constrained assignment produced duplicate indices.") + + return row_ind, col_ind + + +# ---------------------------------------------------------------------- +# ChemAtomicData-coupled wrappers +# ---------------------------------------------------------------------- +def _align_graph( + data: ChemAtomicData, + permutation: torch.Tensor, + *, + positions: torch.Tensor | None = None, +) -> ChemAtomicData: + """Align real nodes with zero-valued auxiliaries in one allocation.""" + num_nodes = data.num_nodes + is_real = permutation < num_nodes + source_idx = permutation[is_real] + output_idx = is_real.nonzero(as_tuple=True)[0] + + def _align(value): + if value is None: + return None + aligned = value.new_zeros((permutation.numel(), *value.shape[1:])) + aligned[output_idx] = value[source_idx] + return aligned + + node_map = torch.full((num_nodes,), -1, dtype=torch.long, device=data.device) + node_map[source_idx] = output_idx + neighbor_list = None if data.neighbor_list is None else node_map[data.neighbor_list] + + extra = dict(data.extra) + for key in data.node_extra_keys: + if isinstance(extra.get(key), torch.Tensor): + extra[key] = _align(extra[key]) + if neighbor_list is not None and neighbor_list.numel() > 0: + for key in data.edge_extra_keys: + if isinstance(extra.get(key), torch.Tensor): + extra[key] = extra[key].clone() + + is_auxiliary = _align(data.is_auxiliary) + if is_auxiliary is None: + is_auxiliary = torch.zeros( + (permutation.numel(), 1), dtype=torch.bool, device=data.device + ) + is_auxiliary[~is_real] = True + + return ChemAtomicData( + positions=_align(data.positions) if positions is None else positions, + atom_types=_align(data.atom_types), + charge_types=_align(data.charge_types), + edge_types=( + data.edge_types.clone() + if data.edge_types is not None and neighbor_list is not None + else data.edge_types + ), + neighbor_list=neighbor_list, + is_auxiliary=is_auxiliary, + extra=extra, + node_extra_keys=data.node_extra_keys, + edge_extra_keys=data.edge_extra_keys, + system_extra_keys=data.system_extra_keys, + smiles=data.smiles, + ) + + +def _align_and_filter( + sample: ChemAtomicData, + target: ChemAtomicData, + row_ind, + col_ind, + optimal_transport: str, +) -> tuple[ChemAtomicData, ChemAtomicData]: + """Shared tail of every alignment routine. + + Align each side under the augmented assignment and drop dummy-dummy pairs. + """ + n, m = sample.num_nodes, target.num_nodes + row_ind = torch.as_tensor(row_ind, dtype=torch.long, device=sample.device) + col_ind = torch.as_tensor(col_ind, dtype=torch.long, device=target.device) + + sample_is_real = row_ind < n + target_is_real = col_ind < m + sample_is_aux = torch.ones(row_ind.numel(), dtype=torch.bool, device=sample.device) + target_is_aux = torch.ones(col_ind.numel(), dtype=torch.bool, device=target.device) + if sample.is_auxiliary is None: + sample_is_aux[sample_is_real] = False + else: + sample_is_aux[sample_is_real] = sample.is_auxiliary[ + row_ind[sample_is_real] + ].squeeze(-1) + if target.is_auxiliary is None: + target_is_aux[target_is_real] = False + else: + target_is_aux[target_is_real] = target.is_auxiliary[ + col_ind[target_is_real] + ].squeeze(-1) + + valid = ~(sample_is_aux & target_is_aux) + sample_positions = sample.positions.new_zeros( + (row_ind.numel(), *sample.positions.shape[1:]) + ) + sample_positions[sample_is_real] = sample.positions[row_ind[sample_is_real]] + + if optimal_transport == "equivariant": + match_mask = ~sample_is_aux & ~target_is_aux + if match_mask.sum() > 0: + target_matches = target.positions[col_ind[match_mask]] + rot, trans = rigid_alignment(sample_positions[match_mask], target_matches) + # Preserve the established arithmetic order: transform the padded + # matrix before dropping dummy-dummy rows. GEMM shape can otherwise + # change the final float32 bit at machine precision. + sample_positions = sample_positions @ rot.T + trans + + sample = _align_graph(sample, row_ind[valid], positions=sample_positions[valid]) + target = _align_graph(target, col_ind[valid]) + return sample, target + + +def partial_optimal_transport_single( + sample_data: ChemAtomicData, + target_data: ChemAtomicData, + c_move: float = 1.0, + c_sub: float = 10.0, + c_ins: float = 5.0, + c_del: float = 10.0, + optimal_transport: str = "equivariant", + pre_align: bool = False, + squared: bool = False, + pre_align_iters: int = 2, +) -> tuple[ChemAtomicData, ChemAtomicData]: + """Single-pair partial optimal transport alignment. + + ``squared`` selects the W2 (squared-Euclidean) ground cost. ``pre_align_iters`` + > 0 runs the equivariant-OT inner loop (alternating Hungarian + Kabsch) for + that many iterations before the final class-aware assignment; it supersedes + the legacy ``pre_align`` boolean (which maps to 3 iterations). + """ + x0 = sample_data.positions.detach().cpu().numpy() + x1 = target_data.positions.detach().cpu().numpy() + a0 = sample_data.atom_types.detach().cpu().numpy() + a1 = target_data.atom_types.detach().cpu().numpy() + + iters = pre_align_iters if pre_align_iters > 0 else (3 if pre_align else 0) + if iters > 0: + x0, x1 = pre_align_positions(x0, x1, num_iterations=iters, squared=squared) + + row_ind, col_ind = distance_and_class_based_assignment( + x0, x1, a0, a1, c_move, c_sub, c_ins, c_del, squared=squared + ) + return _align_and_filter( + sample_data, target_data, row_ind, col_ind, optimal_transport + ) + + +def scaffold_based_assignment_single( + sample_data: ChemAtomicData, + target_data: ChemAtomicData, + scaffold_pairs: list[tuple[int, int]], + c_move: float = 1.0, + c_sub: float = 10.0, + c_ins: float = 5.0, + c_del: float = 10.0, + optimal_transport: str = "equivariant", + squared: bool = False, +) -> tuple[ChemAtomicData, ChemAtomicData]: + """Scaffold-aware partial OT alignment. + + Locks ``scaffold_pairs`` and runs MCS-constrained OT on the remaining atoms. + """ + x0 = sample_data.positions.detach().cpu().numpy() + x1 = target_data.positions.detach().cpu().numpy() + a0 = sample_data.atom_types.detach().cpu().numpy() + a1 = target_data.atom_types.detach().cpu().numpy() + + # Kabsch pre-alignment using the scaffold anchors. + if len(scaffold_pairs) >= 3: + s_idxs = [p[0] for p in scaffold_pairs] + t_idxs = [p[1] for p in scaffold_pairs] + rot, trans = rigid_alignment( + torch.tensor(x0[s_idxs], dtype=torch.float32), + torch.tensor(x1[t_idxs], dtype=torch.float32), + ) + x0 = (torch.tensor(x0, dtype=torch.float32) @ rot.T + trans).numpy() + sample_data = sample_data.clone() + sample_data.positions = torch.tensor( + x0, + dtype=sample_data.positions.dtype, + device=sample_data.positions.device, + ) + + row_ind, col_ind = mcs_constrained_assignment( + x0, + x1, + a0, + a1, + scaffold_pairs, + c_move, + c_sub, + c_ins, + c_del, + squared=squared, + ) + return _align_and_filter( + sample_data, target_data, row_ind, col_ind, optimal_transport + ) diff --git a/geom/explicit_h/runtime/src/morph/flow_matching/integration.py b/geom/explicit_h/runtime/src/morph/flow_matching/integration.py new file mode 100644 index 0000000000000000000000000000000000000000..11062d840a5279bf09009980345cfe52305a1c17 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/flow_matching/integration.py @@ -0,0 +1,806 @@ +"""Molecular flow integration with coordinate, substitution, deletion, and insertion + steps. + +States are native ``ChemBatch`` objects. Insertion heads consume flat tensors; +trace records expose the exact decisions made by the production integrator. +""" + +from __future__ import annotations + +import itertools +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import torch +import torch.nn.functional as F +from scipy.stats import beta as scipy_beta + +from morph.dataset.core.atomic_data import ( + COFRAMED_CONTEXT_KEYS, + ChemBatch, + batch_from_flat, + batch_unpack, + filter_nodes, +) +from morph.dataset.core.insertion_data import ( + InsertedAtoms, + count_new_atom_edges, + insertion_pair_indices, + join_with_atoms, + join_with_predicted_edges, + sample_edge_types, +) +from morph.dataset.core.insertion_targets import ( + BondsBetweenInserted, + BondsToExisting, +) +from morph.flow_matching.sampling_step import ( + InsertionStepTrace, + IntegrationStepTrace, + ScheduleStepMasses, + node_edit_probabilities, +) +from morph.flow_matching.schedules import ( + FastPowerSchedule, + KappaSchedule, + LinearSchedule, +) +from morph.model.layers.gmm import sample_from_typed_gmm +from morph.utils.graph_math import batched_triu_edge_layout + +if TYPE_CHECKING: + from collections.abc import Callable + + from morph.dataset.core.vocab import Distributions, Vocab + from morph.model.layers.heads import InsertionEdgeHead + + +@dataclass(frozen=True) +class IntegrationPredictions: + """Clean endpoint and edit predictions, all aligned with the current batch.""" + + mol_1_pred: ChemBatch + do_sub_a_probs: torch.Tensor + do_sub_e_probs: torch.Tensor + do_del_probs: torch.Tensor + num_ins_pred: torch.Tensor + insertion_preds: dict[str, torch.Tensor] + h_latent: torch.Tensor | None = None + + +@dataclass(frozen=True) +class _InsertionContext: + """Original node space and its mapping into the post-deletion batch. + + Bond prediction uses the original positions/features but the substituted + atom types. Counts and batch indices always come from ``mol_t``. + """ + + mol_t: ChemBatch + atom_types: torch.Tensor + spawn_indices: torch.Tensor + time_next: torch.Tensor + predictions: IntegrationPredictions + edge_head: InsertionEdgeHead | None + original_to_postdel: torch.Tensor | None + + +@dataclass(frozen=True) +class _PredictedBondsToExisting: + bonds: BondsToExisting + postdel_existing_idx: torch.Tensor + + +@dataclass(frozen=True) +class _EdgeSubstitution: + edge_types: torch.Tensor + edge_index: torch.Tensor + targets: torch.Tensor + selected: torch.Tensor + + +class RateIntegrator: + """Rate integrator for flow matching on ``ChemBatch`` states.""" + + def __init__( + self, + vocab: Vocab, + distributions: Distributions, + insertion_head_mode: str, + insertion_dimension: int = 3, + insertion_num_components: int = 1, + num_integration_steps=100, + time_strategy="log", + time_beta_alpha: float = 2.0, + time_beta_beta: float = 1.0, + ins_noise_scale=0.25, + renoise_insertions: bool = True, # match unconditional training-time noising + exact_schedule_integration: bool = True, + del_schedule: KappaSchedule | None = None, + ins_schedule: KappaSchedule | None = None, + sub_schedule: KappaSchedule | None = None, + sub_e_schedule: KappaSchedule | None = None, + device="cuda" if torch.cuda.is_available() else "cpu", # noqa: B008 + ): + self.vocab = vocab + self.distributions = distributions + if insertion_head_mode not in {"unimodal", "gmm"}: + raise ValueError( + "insertion_head_mode must be 'unimodal' or 'gmm', got " + f"{insertion_head_mode!r}" + ) + if insertion_num_components < 1: + raise ValueError("insertion_num_components must be positive") + if insertion_head_mode == "unimodal" and insertion_num_components != 1: + raise ValueError("the unimodal insertion head must have one component") + self.insertion_head_mode = insertion_head_mode + self.insertion_dimension = int(insertion_dimension) + self.insertion_num_components = int(insertion_num_components) + cstd = getattr(distributions, "coordinate_std", None) + cstd = float(cstd) if cstd is not None else 1.0 + # Match the interpolator: configuration is in physical Angstroms while + # integration operates in normalized coordinate units. + self.ins_noise_scale = float(ins_noise_scale) / cstd + self.renoise_insertions = renoise_insertions + self.exact_schedule_integration = bool(exact_schedule_integration) + self.time_strategy = time_strategy + self.time_beta_alpha = time_beta_alpha + self.time_beta_beta = time_beta_beta + self.num_integration_steps = num_integration_steps + self.device = device + + self.del_schedule = del_schedule or FastPowerSchedule(beta=2.5) + self.ins_schedule = ins_schedule or FastPowerSchedule(beta=2.5) + self.sub_schedule = sub_schedule or LinearSchedule() + self.sub_e_schedule = sub_e_schedule or self.sub_schedule + + n_atoms_distribution = self.distributions.n_atoms_distribution + self.max_atoms = len(n_atoms_distribution) + 10 # buffer for ins / del + + self._cat_atom = torch.distributions.Categorical( + probs=distributions.atom_type_distribution.to(device) + ) + self._cat_edge = torch.distributions.Categorical( + probs=distributions.edge_type_distribution.to(device) + ) + self._cat_device = ( + torch.device(device) if not isinstance(device, torch.device) else device + ) + + def schedule_step_masses( + self, t: torch.Tensor, dt: float | torch.Tensor, *, is_final: bool = False + ) -> ScheduleStepMasses: + """Parameterise all categorical edit probabilities for a time interval. + + Keeping the time discretization here ensures every sampling path uses + the same exact or Euler schedule convention. Exact mode converts the + integrated schedule hazard into the conditional probability that a + still-pending endpoint transition occurs during this interval. + + The sampler's final interval always completes the discrete bridge: + its schedule masses are one, leaving the learned edit + probabilities and masks in control. In particular, stopping the log + grid at 0.999 must not leave predicted endpoint edits pending. + ``is_final`` is step context supplied by the sampling loop, not a + configuration switch; it avoids guessing from rounded time values. + """ + if is_final: + mass = torch.ones_like(t) + return ScheduleStepMasses(mass, mass, mass, mass) + + def step(schedule: KappaSchedule) -> torch.Tensor: + if self.exact_schedule_integration: + return -torch.expm1(-schedule.integrated_rate(t, dt)) + return schedule.rate(t) * dt + + return ScheduleStepMasses( + substitution=step(self.sub_schedule), + edge_substitution=step(self.sub_e_schedule), + insertion=step(self.ins_schedule), + deletion=step(self.del_schedule), + ) + + def _distr_to_device(self, device: torch.device): + device = ( + torch.device(device) if not isinstance(device, torch.device) else device + ) + if getattr(self, "_cat_device", None) == device: + return + self._cat_atom = torch.distributions.Categorical( + probs=self._cat_atom.probs.to(device) + ) + self._cat_edge = torch.distributions.Categorical( + probs=self._cat_edge.probs.to(device) + ) + self._cat_device = device + + def get_time_steps(self, num_steps: int | None = None) -> list[float]: + """Return the per-step time deltas for the configured time strategy.""" + if num_steps is None: + num_steps = self.num_integration_steps + + if self.time_strategy == "linear": + time_points = torch.linspace(0, 1, num_steps + 1).tolist() + elif self.time_strategy == "log": + # ``num_steps + 1`` grid points produce exactly ``num_steps`` + # integration deltas. Stop at 0.999 so rates containing 1 / (1-t) + # are never evaluated at their singular endpoint. + time_points = (1 - torch.logspace(0, -3, num_steps + 1)).tolist() + elif self.time_strategy == "beta": + # Place points via the Beta(alpha, beta) inverse CDF of a uniform + # grid, so the step density follows the same Beta law used to sample + # training time (the ``beta_sched`` in the model config). + u = torch.linspace(0, 1, num_steps + 1).numpy() + ppf = scipy_beta.ppf(u, self.time_beta_alpha, self.time_beta_beta) + time_points = torch.from_numpy(ppf).clamp(0.0, 1.0).tolist() + else: + raise ValueError(f"Invalid time strategy: {self.time_strategy}") + + return [t1 - t0 for t0, t1 in itertools.pairwise(time_points)] + + def sample_insertions(self, insertion_pred: dict, t: torch.Tensor) -> InsertedAtoms: + """Sample one new atom per provided head output (works with batch ids).""" + dimension = self.insertion_dimension + + if self.insertion_head_mode == "unimodal": + # Deterministic mean position; the integrator's insertion renoising + # supplies the spatial spread (mirrors the GMM path's renoising). + mu = insertion_pred["mu"] + sampled_a = torch.multinomial( + torch.softmax(insertion_pred["a_logits"].float(), dim=-1), 1 + ).squeeze(1) + sampled_c = torch.multinomial( + torch.softmax(insertion_pred["c_logits"].float(), dim=-1), 1 + ).squeeze(1) + return InsertedAtoms( + x=mu.view(-1, dimension), + a=sampled_a.view(-1), + c=sampled_c.view(-1), + ) + + num_ins = 1 + num_components = self.insertion_num_components + A = len(self.vocab.atom_tokens) + C = len(self.vocab.charge_tokens) + + sampled_x, sampled_a, sampled_c = sample_from_typed_gmm( + insertion_pred, num_ins, num_components, dimension, A, C + ) + return InsertedAtoms( + x=sampled_x.view(-1, dimension), + a=sampled_a.view(-1), + c=sampled_c.view(-1), + ) + + def _renoise_insertions( + self, new_atoms: InsertedAtoms, t_ins: torch.Tensor + ) -> InsertedAtoms: + """Push freshly-inserted nodes to the t+dt marginal (matches training).""" + if t_ins.numel() == 0: + return new_atoms + + t_ins = t_ins.to(new_atoms.x.device).clamp(0.0, 1.0) + + noise_x = torch.randn_like(new_atoms.x) + scale_x = ((1.0 - t_ins) * self.ins_noise_scale).unsqueeze(-1) + new_atoms.x = new_atoms.x + scale_x * noise_x + + # Atom types follow the same categorical bridge as training. Charge is + # only a clean prediction target, not an interpolated model state. + kappa = self.sub_schedule.kappa_t(t_ins).clamp(0.0, 1.0).unsqueeze(-1) + new_atoms.a = self._kappa_mix_sample(new_atoms.a, self._cat_atom, kappa) + return new_atoms + + def _kappa_mix_sample(self, clean_idx, prior_cat, kappa): + """Resample class indices as a kappa-mix of the clean one-hot and a prior. + + Pushes a freshly predicted (clean) categorical sample toward the t+dt + marginal: mix the clean one-hot with ``prior_cat``'s probabilities, + weighted per-row by ``kappa`` (shape ``[N, 1]``), renormalize in fp32 to + stay on the simplex, and resample. Shared by the atom / charge / edge + renoising paths. + """ + num_classes = prior_cat.probs.shape[-1] + prior = prior_cat.probs.unsqueeze(0).expand(clean_idx.shape[0], -1) + one_hot = F.one_hot(clean_idx, num_classes=num_classes).to(prior.dtype) + # Clamp so the mix is always a convex combination: a schedule that returns + # kappa marginally above 1 (fp32 cancellation near t=1) would otherwise make + # ``prior * (1 - kappa)`` negative, which aborts the process inside + # ``torch.multinomial`` rather than raising. + kappa = ( + kappa.clamp(0.0, 1.0) + if torch.is_tensor(kappa) + else min(max(kappa, 0.0), 1.0) + ) + mix = prior * (1.0 - kappa) + one_hot * kappa + mix = mix.float() + mix = mix / mix.sum(dim=-1, keepdim=True).clamp_min(1e-12) + # torch.multinomial is exactly what Categorical(probs=).sample() calls + # under the hood (same RNG draw) without building the distribution object. + return torch.multinomial(mix, 1).squeeze(1) + + def _renoise_edge_types(self, clean_idx, t_per_edge): + """Push clean inserted-edge types to the t+dt marginal (no-op if disabled).""" + if not self.renoise_insertions: + return clean_idx + kappa = self.sub_e_schedule.kappa_t(t_per_edge).unsqueeze(1) + return self._kappa_mix_sample(clean_idx, self._cat_edge, kappa) + + def _select_spawning_nodes(self, mol_t, num_ins_pred, ins_step_node): + """Sample insertion events and enforce the per-graph atom limit.""" + batch_id = mol_t.batch + # ``num_ins_pred`` is the expected number of pending endpoint + # insertions. The schedule mass is the per-endpoint transition + # probability for this interval, not a hazard to exponentiate jointly + # with the model prediction. At most one insertion per spawning node is + # supported in a numerical step, matching the historical sampler. + p_ins = (num_ins_pred * ins_step_node).clamp(0.0, 1.0) + do_ins = torch.rand_like(p_ins) < p_ins + + spawn_orig_indices = torch.where(do_ins)[0] + has_insertions = spawn_orig_indices.numel() > 0 + if has_insertions: + n_atoms_per_graph = mol_t.num_nodes_per_graph + n_ins_per_graph = torch.zeros_like(n_atoms_per_graph).scatter_add_( + 0, batch_id, do_ins.long() + ) + overflow = (n_atoms_per_graph + n_ins_per_graph - self.max_atoms).clamp( + min=0 + ) + overflow_graphs = torch.nonzero(overflow, as_tuple=False).flatten().tolist() + for g in overflow_graphs: + n_to_remove = int(overflow[g].item()) + removed_indices = torch.where((batch_id == g) & do_ins)[0] + removed_indices = removed_indices[ + torch.randperm(removed_indices.shape[0], device=do_ins.device) + ] + do_ins[removed_indices[:n_to_remove]] = False + if overflow_graphs: + spawn_orig_indices = torch.where(do_ins)[0] + + return do_ins, spawn_orig_indices + + @staticmethod + def _sample_node_edits(predictions, sub_step_node, del_step_node): + """Sample mutually exclusive substitution and deletion events.""" + # The sigmoid heads estimate mutually-exclusive endpoint-edit + # posteriors. Multiply each posterior by its schedule transition mass, + # then add the resulting numerical-step probabilities. + sub_step_mass = predictions.do_sub_a_probs.view(-1) * sub_step_node + del_step_mass = predictions.do_del_probs.view(-1) * del_step_node + node_edit = node_edit_probabilities(sub_step_mass, del_step_mass) + do_edit = torch.rand_like(node_edit.any_edit) < node_edit.any_edit + is_deletion_type = ( + torch.rand_like(node_edit.deletion_given_edit) + < node_edit.deletion_given_edit + ) + do_del = do_edit & is_deletion_type + do_sub_a = do_edit & (~is_deletion_type) + return do_sub_a, do_del + + @staticmethod + def _substitute_edges(mol_t, predictions, step_masses): + """Sample each undirected bond once and restore directed edge order.""" + edge_index, batch_id = mol_t.edge_index, mol_t.batch + e_t, e_1 = mol_t.e, predictions.mol_1_pred.e + # ``mol_1_pred`` is built on ``mol_t.edge_index`` by the sampling loop, + # so all edge tensors already share an order. Avoid sorting them twice. + triu_positions, full_to_triu = batched_triu_edge_layout( + edge_index, batch_id, mol_t.num_nodes_per_graph + ) + edge_index_triu = edge_index[:, triu_positions] + e_triu = e_t[triu_positions] + do_sub_e_probs_triu = predictions.do_sub_e_probs[triu_positions] + e_1_triu = e_1[triu_positions] + + q_sub_e = do_sub_e_probs_triu.view(-1) + batch_id_edge = batch_id[edge_index_triu[0]] + sub_e_step_triu = step_masses.edge_substitution[batch_id_edge] + p_mod_e = (q_sub_e * sub_e_step_triu).view(-1) + do_sub_e = torch.rand_like(p_mod_e) < p_mod_e + e_triu = torch.where(do_sub_e, e_1_triu, e_triu) + + # Mirror the changed per-pair values into the existing directed order. + # The layout mapping remains valid after insertion/deletion reorders the + # edge storage, avoiding a sort without assuming lexicographic order. + e_t = e_triu[full_to_triu] + + return _EdgeSubstitution(e_t, edge_index_triu, e_1_triu, do_sub_e) + + @staticmethod + def _constrain_deletions(mol_t, do_del): + """Restore enough deleted nodes to retain at least two per graph. + + Update ``do_del`` in place so traces describe the edits actually applied. + """ + batch_id = mol_t.batch + node_keep = ~do_del + + # Fail-safe: never delete a whole sample (keep >= 2 nodes per graph). + n_kept_per_graph = torch.zeros_like(mol_t.num_nodes_per_graph).scatter_add_( + 0, batch_id, node_keep.long() + ) + under = (2 - n_kept_per_graph).clamp(min=0) + for g in torch.nonzero(under, as_tuple=False).flatten().tolist(): + n_to_restore = int(under[g].item()) + deleted_in_graph_idx = torch.where((batch_id == g) & do_del)[0] + n_restore = min(n_to_restore, deleted_in_graph_idx.shape[0]) + if n_restore > 0: + restored = deleted_in_graph_idx[:n_restore] + node_keep[restored] = True + do_del[restored] = False + + return node_keep + + def integrate_step_gnn( + self, + mol_t: ChemBatch, + predictions: IntegrationPredictions, + t: torch.Tensor, + dt: float | torch.Tensor, + *, + eps: float = 1e-6, + ins_edge_head: InsertionEdgeHead | None = None, + force_charge_idx: int | None = None, + return_step_trace: bool = False, + position_update: Callable[ + [torch.Tensor, torch.Tensor, torch.Tensor, float | torch.Tensor], + torch.Tensor, + ] + | None = None, + is_final: bool = False, + ) -> ChemBatch | tuple[ChemBatch, IntegrationStepTrace]: + """Integrate one step, optionally returning its realized edit trace. + + By default positions use the standard Euler update. ``position_update`` + optionally supplies an alternative coordinate kernel while retaining the + same topology transition. The sampling loop supplies ``is_final`` to + complete discrete transitions on its final iteration. + """ + active_device = mol_t["positions"].device + self._distr_to_device(active_device) + + x_t, a_t, _, _, edge_index, batch_id = batch_unpack(mol_t) + x_1, a_1, c_1, _, _, _ = batch_unpack(predictions.mol_1_pred) + num_graphs = t.shape[0] + + # A scalar keeps the historical fixed-grid behavior. A (B,) tensor + # allows each graph to advance by a different amount without changing + # any of the node/edge kernels below. + if torch.is_tensor(dt): + dt_graph = dt.to(device=active_device, dtype=t.dtype).reshape(-1) + if dt_graph.numel() != num_graphs: + raise ValueError( + f"tensor dt must have one value per graph ({num_graphs}), " + f"got shape {tuple(dt.shape)}" + ) + dt_position = dt_graph[batch_id].unsqueeze(-1) + else: + dt_graph = dt + dt_position = dt + + conditioning_keep_mask = ( + mol_t["keep_mask"] if "keep_mask" in mol_t else None # noqa: SIM401 + ) + coframed_context = tuple( + (key, context) + for key in COFRAMED_CONTEXT_KEYS + if (context := getattr(mol_t, key, None)) is not None + ) + + _carry_force_uncond = getattr(mol_t, "force_uncond", None) + # Per-graph de-novo flag; drives the null-token-only masking in + # ``_prepend_mode_token``. Carried so the mask persists past step 0 — + # it is (B,) over graphs, which insertion/deletion never changes. + _carry_cond_mask = getattr(mol_t, "cond_mask", None) + + move_rate = 1 / (1 - t).clamp(min=eps, max=1.0 - eps) + move_rate_node = move_rate[batch_id] + + step_masses = self.schedule_step_masses(t, dt_graph, is_final=is_final) + sub_step_node = step_masses.substitution[batch_id] + ins_step_node = step_masses.insertion[batch_id] + del_step_node = step_masses.deletion[batch_id] + + # 1. Positions (deterministic Euler update). ``move_noise_scale`` in the + # interpolator is training-time denoising jitter, not an SDE diffusion + # coefficient, so no new Gaussian movement noise is injected here. + x_t_original = x_t + if position_update is None: + velocity = (x_1 - x_t) * move_rate_node.view(-1, 1) + x_t = x_t + velocity * dt_position + else: + x_t = position_update(x_t, x_1, t[batch_id], dt_position) + if x_t.shape != x_t_original.shape: + raise ValueError( + "position_update must preserve the position tensor shape; " + f"expected {tuple(x_t_original.shape)}, got {tuple(x_t.shape)}" + ) + + do_ins, spawn_orig_indices = self._select_spawning_nodes( + mol_t, predictions.num_ins_pred, ins_step_node + ) + has_insertions = spawn_orig_indices.numel() > 0 + + do_sub_a, do_del = self._sample_node_edits( + predictions, sub_step_node, del_step_node + ) + a_t = torch.where(do_sub_a, a_1, a_t) + + edge_edit = self._substitute_edges(mol_t, predictions, step_masses) + + mol = batch_from_flat( + x_t, + a_t, + c_1, + edge_edit.edge_types, + edge_index, + batch_id, + num_graphs=num_graphs, + node_extras=( + {"keep_mask": conditioning_keep_mask} + if conditioning_keep_mask is not None + else None + ), + system_extras={key: mol_t[key] for key in mol_t.keys["system"]}, + _already_sorted=True, + _node_counts=mol_t.num_nodes_per_graph, + _edge_counts=mol_t.num_edges_per_graph, + ) + + node_keep = self._constrain_deletions(mol_t, do_del) + + num_original = mol.num_nodes + has_deletions = bool(do_del.any()) + if has_deletions: + mol = filter_nodes(mol, node_keep) + + insertion_trace = None + if has_insertions: + original_to_postdel = None + if ins_edge_head is not None and predictions.h_latent is not None: + if has_deletions: + original_to_postdel = torch.full( + (num_original,), + -1, + dtype=torch.long, + device=active_device, + ) + kept_indices = torch.where(node_keep)[0] + original_to_postdel[kept_indices] = torch.arange( + kept_indices.numel(), device=active_device + ) + else: + original_to_postdel = torch.arange( + num_original, device=active_device + ) + mol, insertion_trace = self._apply_insertions( + mol=mol, + context=_InsertionContext( + mol_t=mol_t, + atom_types=a_t, + spawn_indices=spawn_orig_indices, + time_next=t + dt_graph, + predictions=predictions, + edge_head=ins_edge_head, + original_to_postdel=original_to_postdel, + ), + force_charge_idx=force_charge_idx, + return_step_trace=return_step_trace, + ) + + if _carry_force_uncond is not None: + object.__setattr__(mol, "force_uncond", _carry_force_uncond) + if _carry_cond_mask is not None: + object.__setattr__(mol, "cond_mask", _carry_cond_mask) + for key, context in coframed_context: + object.__setattr__(mol, key, context) + if not return_step_trace: + return mol + return mol, IntegrationStepTrace( + positions=x_t, + atom_type_targets=a_1, + charge_targets=c_1, + node_substitutions=do_sub_a, + node_deletions=do_del, + edge_index=edge_edit.edge_index, + edge_type_targets=edge_edit.targets, + edge_substitutions=edge_edit.selected, + insertions=do_ins, + insertion=insertion_trace, + ) + + def _apply_insertions( + self, + mol: ChemBatch, + context: _InsertionContext, + *, + force_charge_idx: int | None = None, + return_step_trace: bool = False, + ) -> tuple[ChemBatch, InsertionStepTrace | None]: + """Sample inserted atoms and join them (predicted edges if a head is given).""" + if self.insertion_head_mode == "unimodal": + ins_head_keys = ("mu", "a_logits", "c_logits") + else: + ins_head_keys = ("mu", "sigma", "pi", "a_probs", "c_probs") + active_insertion_pred = { + key: context.predictions.insertion_preds[key][context.spawn_indices] + for key in ins_head_keys + } + ins_batch_id = context.mol_t.batch[context.spawn_indices] + t_ins = context.time_next[ins_batch_id] + clean_atoms = self.sample_insertions(active_insertion_pred, t_ins) + if force_charge_idx is not None: + clean_atoms.c = torch.full_like(clean_atoms.c, force_charge_idx) + clean_atoms.batch = ins_batch_id + + # Predict clean bonds from the clean insertion sample, but insert a copy at the + # current noise level so the next model step sees the expected state. + new_atoms = InsertedAtoms( + clean_atoms.x.clone(), + clean_atoms.a.clone(), + clean_atoms.c.clone(), + clean_atoms.batch, + ) + if self.renoise_insertions: + new_atoms = self._renoise_insertions(new_atoms, t_ins) + + edge_dist = self._cat_edge.probs + + new_atoms_batch = batch_from_flat( + new_atoms.x, + new_atoms.a, + new_atoms.c, + None, + None, + new_atoms.batch, + num_graphs=context.mol_t.num_graphs, + _already_sorted=True, + ) + + bonds_to_existing = None + bonds_between_inserted = None + + if context.edge_head is not None and context.predictions.h_latent is not None: + bonds_to_existing = self._predict_inserted_to_existing_edges( + context, clean_atoms + ) + bonds_between_inserted = self._predict_inserted_to_inserted_edges( + context, clean_atoms + ) + + use_predicted_edges = bonds_to_existing is not None + if use_predicted_edges: + result = join_with_predicted_edges( + mol, + new_atoms_batch, + e_ins=bonds_to_existing.bonds.bond_type, + spawn_node_idx=bonds_to_existing.bonds.inserted_idx, + target_node_idx=bonds_to_existing.postdel_existing_idx, + e_ins_to_ins=( + bonds_between_inserted.bond_type + if bonds_between_inserted is not None + else None + ), + ins_to_ins_src_idx=( + bonds_between_inserted.src_idx + if bonds_between_inserted is not None + else None + ), + ins_to_ins_dst_idx=( + bonds_between_inserted.dst_idx + if bonds_between_inserted is not None + else None + ), + sample_fallback_edge_types=lambda n: sample_edge_types(edge_dist, n), + ) + else: + # Fall back to random edge sampling (outsourced). + n_edges = count_new_atom_edges(mol, new_atoms_batch) + result = join_with_atoms( + mol, new_atoms_batch, sample_edge_types(edge_dist, n_edges) + ) + + if not return_step_trace: + return result, None + return result, InsertionStepTrace( + spawn_node_idx=context.spawn_indices, + clean_atoms=clean_atoms, + bonds_to_existing=( + bonds_to_existing.bonds if bonds_to_existing is not None else None + ), + bonds_between_inserted=( + bonds_between_inserted if use_predicted_edges else None + ), + ) + + def _predict_inserted_to_existing_edges( + self, + context: _InsertionContext, + new_atoms: InsertedAtoms, + ) -> _PredictedBondsToExisting | None: + """Predict (inserted -> existing) bonds -> (new_atom_idx, target_idx, types).""" + mol_t = context.mol_t + orig_spawn_idx, orig_existing_idx, ins_edge_logits = ( + context.edge_head.predict_inserted_to_existing( + h=context.predictions.h_latent, + x=mol_t.x, + node_atom_types=context.atom_types, + batch=mol_t.batch, + insertion_indices=context.spawn_indices, + node_counts=mol_t.num_nodes_per_graph, + ins=InsertedAtoms(new_atoms.x, new_atoms.a, new_atoms.c), + ) + ) + + if ins_edge_logits is None or ins_edge_logits.numel() == 0: + return None + + # Promote logits before normalization to avoid bf16 probability loss. + ins_edge_probs = F.softmax(ins_edge_logits.float(), dim=-1) + e1_ins_idx = torch.multinomial(ins_edge_probs, 1).squeeze(1) + spawn_batch_id = mol_t.batch[orig_spawn_idx] + e_t_ins = self._renoise_edge_types( + e1_ins_idx, + context.time_next[spawn_batch_id], + ) + + orig_to_new_atom = torch.full( + (mol_t.num_nodes,), -1, dtype=torch.long, device=mol_t.x.device + ) + orig_to_new_atom[context.spawn_indices] = torch.arange( + context.spawn_indices.numel(), device=mol_t.x.device + ) + ins_edge_new_atom_idx = orig_to_new_atom[orig_spawn_idx] + ins_edge_target_idx = context.original_to_postdel[orig_existing_idx] + # The deletion fail-safe leaves every insertion's graph with at least + # one existing atom, so at least one endpoint is always valid. + valid_edges = torch.where(ins_edge_target_idx >= 0)[0] + + return _PredictedBondsToExisting( + bonds=BondsToExisting( + spawn_idx=orig_spawn_idx[valid_edges], + existing_idx=orig_existing_idx[valid_edges], + inserted_idx=ins_edge_new_atom_idx[valid_edges], + bond_type=e_t_ins[valid_edges], + ), + postdel_existing_idx=ins_edge_target_idx[valid_edges], + ) + + def _predict_inserted_to_inserted_edges( + self, + context: _InsertionContext, + new_atoms: InsertedAtoms, + ) -> BondsBetweenInserted | None: + """Predict (inserted -> inserted) bonds for intra-graph upper-tri pairs.""" + ii_src, ii_dst, ii_spawn_src, ii_spawn_dst = insertion_pair_indices( + new_atoms.batch, context.spawn_indices + ) + if ii_src.numel() == 0: + return None + + new_feats = InsertedAtoms(new_atoms.x, new_atoms.a, new_atoms.c) + ii_logits = context.edge_head.forward_inserted_to_inserted( + h=context.predictions.h_latent, + x=context.mol_t.x, + spawn_src_idx=ii_spawn_src, + spawn_dst_idx=ii_spawn_dst, + src=new_feats[ii_src], + dst=new_feats[ii_dst], + ) + # ins->ins passes logits directly (avoids a bf16 softmax precision loss). + e1_ii_idx = torch.multinomial( + torch.softmax(ii_logits.float(), dim=-1), 1 + ).squeeze(1) + ii_batch_id = context.mol_t.batch[ii_spawn_src] + e_t_ins_ii = self._renoise_edge_types( + e1_ii_idx, + context.time_next[ii_batch_id], + ) + return BondsBetweenInserted( + spawn_src_idx=ii_spawn_src, + spawn_dst_idx=ii_spawn_dst, + src_idx=ii_src, + dst_idx=ii_dst, + bond_type=e_t_ins_ii, + ) diff --git a/geom/explicit_h/runtime/src/morph/flow_matching/interpolation.py b/geom/explicit_h/runtime/src/morph/flow_matching/interpolation.py new file mode 100644 index 0000000000000000000000000000000000000000..8cb3a45f05e451e2b1f81390928597a7ec672358 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/flow_matching/interpolation.py @@ -0,0 +1,509 @@ +"""Flow-matching interpolation for Morph geometric graphs. + +Rate targets are registered as node-, edge-, or graph-level extras on +``ChemAtomicData``. Ragged insertion supervision lives in the dedicated +``InsertionTargets`` dataclass. + +The interpolation maths follows the original implementation, except that charge +is intentionally a clean prediction target rather than a dynamic state. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from morph.dataset.core.atomic_data import ChemAtomicData +from morph.dataset.core.insertion_targets import ( + BondsBetweenInserted, + BondsToExisting, + InsertionTargets, +) +from morph.flow_matching.assignment import ( + partial_optimal_transport_single, + scaffold_based_assignment_single, +) +from morph.flow_matching.schedules import ( + FastPowerSchedule, + KappaSchedule, + LinearSchedule, +) +from morph.utils.graph_math import complete_graph_layout + +if TYPE_CHECKING: + from morph.dataset.core.vocab import Distributions + + +def existing_graph_layout(mask_exists, full_edge_index): + """Return the node map, retained complete edges, and source-edge selection.""" + keep = mask_exists.nonzero(as_tuple=True)[0] + node_map = torch.full( + (mask_exists.numel(),), -1, dtype=torch.long, device=mask_exists.device + ) + node_map[keep] = torch.arange(keep.numel(), device=mask_exists.device) + remapped = node_map[full_edge_index] + selected = (remapped[0] >= 0) & (remapped[1] >= 0) + return node_map, remapped[:, selected].t().contiguous(), selected + + +def _nearest_insertion_per_node(spawn_node_idx, spawn_dist, num_nodes): + """Invert the one-to-many ``insertion -> nearest node`` map into a one-to-one + ``node -> nearest insertion`` assignment. + + For every existing node that is the nearest node of at least one insertion, + return ``(node_idx, ins_idx)`` where ``ins_idx`` is that node's *single + closest* assigned insertion. Each node id appears at most once. + + Args: + spawn_node_idx: ``[N_ins]`` nearest existing node per inserted atom. + spawn_dist: ``[N_ins]`` distance of each insertion to its nearest node. + num_nodes: number of existing nodes (size of the scatter target). + """ + if spawn_node_idx.numel() == 0: + empty = spawn_node_idx.new_empty((0,)) + return empty, empty.clone() + + # Order insertions by ascending distance; the earliest occurrence of a node + # id in that order is its nearest insertion. A stable sort makes ties (which + # are measure-zero for continuous coordinates anyway) deterministic. + order = torch.argsort(spawn_dist, stable=True) + nodes_in_order = spawn_node_idx[order] + ranks = torch.arange(nodes_in_order.numel(), device=spawn_node_idx.device) + + sentinel = nodes_in_order.numel() + earliest = torch.full( + (num_nodes,), sentinel, dtype=torch.long, device=spawn_node_idx.device + ) + earliest.scatter_reduce_(0, nodes_in_order, ranks, reduce="amin", include_self=True) + + has_ins = earliest < sentinel + nearest_node_idx = torch.nonzero(has_ins, as_tuple=False).squeeze(1) + nearest_ins_idx = order[earliest[nearest_node_idx]] + return nearest_node_idx, nearest_ins_idx + + +class Interpolator: + """Flow-matching interpolator operating on :class:`ChemAtomicData`.""" + + def __init__( + self, + distributions: Distributions, + optimal_transport="equivariant", + move_noise_scale=0.2, + ins_noise_scale=0.25, + ins_schedule: KappaSchedule | None = None, + del_schedule: KappaSchedule | None = None, + sub_schedule: KappaSchedule | None = None, + sub_e_schedule: KappaSchedule | None = None, + c_move=1.0, + c_sub=0.0, + c_ins=1e6, + c_del=1e6, + pre_align_iters: int = 2, + atom_type_noise_prob: float = 0.0, + noise_distributions: Distributions | None = None, + ): + self.optimal_transport = optimal_transport + self.atom_type_noise_prob = float(atom_type_noise_prob) + if not 0.0 <= self.atom_type_noise_prob <= 1.0: + raise ValueError("atom_type_noise_prob must be between zero and one") + # Corruption uses empirical frequencies, independently of the uniform + # source prior. Standalone callers may use their supplied distributions. + noise_distributions = noise_distributions or distributions + self._noise_atom_probs = noise_distributions.atom_type_distribution + self._noise_edge_probs = noise_distributions.edge_type_distribution + self._noise_has_edges = bool(distributions.edge_type_distribution[1:].any()) + self.del_schedule = del_schedule or FastPowerSchedule(beta=2.5) + self.ins_schedule = ins_schedule or FastPowerSchedule(beta=2.5) + self.sub_schedule = sub_schedule or LinearSchedule() + self.sub_e_schedule = sub_e_schedule or self.sub_schedule + + # OT matching. The ground cost is *always* the squared-Euclidean (W2) + # distance, so the permutation, the Kabsch alignment and the FM path all + # minimize the same energy. The move cost is counted in ANGSTROM: c_move + # multiplies the squared move distance in A^2. Positions are stored + # normalized (÷ coordinate_std), so we fold coordinate_std^2 into c_move + # here; c_ins / c_del / c_sub are then plain A^2 costs, directly + # comparable and set straight from the config. The defaults + # (c_ins=c_del=1e6, c_sub=0) heavily penalize insertion/deletion, so + # overlapping atoms are always *moved* rather than del+ins'd. + # pre_align_iters runs the equivariant-OT inner loop (Hungarian+Kabsch) + # before the class-aware assignment. + self.pre_align_iters = int(pre_align_iters) + + cstd = getattr(distributions, "coordinate_std", None) + cstd = float(cstd) if cstd is not None else 1.0 + # Public noise scales are physical Angstroms while coordinates are stored + # normalized by ``coordinate_std``. + self.move_noise_scale = float(move_noise_scale) / cstd + self.ins_noise_scale = float(ins_noise_scale) / cstd + self.c_move = c_move * cstd * cstd # squared: normalized -> A^2 move cost + self.c_sub = c_sub + self.c_ins = c_ins + self.c_del = c_del + + self._cat_atom = torch.distributions.Categorical( + probs=distributions.atom_type_distribution + ) + self._cat_charge = torch.distributions.Categorical( + probs=distributions.charge_type_distribution + ) + self._cat_edge = torch.distributions.Categorical( + probs=distributions.edge_type_distribution + ) + + # -- elementary interpolation (identical to the original) ----------------- + def interpolate_continuous(self, x0, x1, t): + """Linear interpolation between continuous tensors at time ``t``.""" + return x0 * (1 - t) + x1 * t + + def interpolate_discrete(self, y0_idx, y1_idx, t): + """Bernoulli interpolation of class indices: keep ``y0`` w.p. ``1 - t``.""" + keep_source = torch.rand(y0_idx.shape[0], device=t.device) > t + return torch.where(keep_source, y0_idx, y1_idx) + + @staticmethod + def _corrupt_categorical(values, eligible, probability, base_probs): + """Redraw selected tokens from empirical alternatives, excluding self.""" + if probability <= 0.0 or values.numel() == 0 or base_probs.numel() <= 1: + return values + changed = eligible & ( + torch.rand(values.shape, device=values.device) < probability + ) + current = values[changed].long() + if current.numel() == 0: + return values + probs = base_probs.to(device=values.device, dtype=torch.float32) + probs = probs.expand(current.shape[0], -1).clone() + probs.scatter_(1, current.unsqueeze(-1), 0.0) + # A degenerate empirical categorical can have no alternative mass. + # Fall back to uniform alternatives in those rows only. + no_alternative = probs.sum(dim=-1) <= 0.0 + probs[no_alternative] = 1.0 + probs.scatter_(1, current.unsqueeze(-1), 0.0) + corrupted = values.clone() + corrupted[changed] = torch.multinomial(probs, 1).squeeze(-1).to(values.dtype) + return corrupted + + def _balanced_edge_noise_probability(self, num_atoms, num_pairs): + """Match the expected number of changed pairs to changed atom tokens.""" + if num_pairs == 0 or not self._noise_has_edges: + return 0.0 + return min(self.atom_type_noise_prob * num_atoms / num_pairs, 1.0) + + @staticmethod + def _extract_triu_feats(edge_index, e, n, triu_rows): + if e is None or e.numel() == 0: + return triu_rows.new_zeros(triu_rows.shape) + out = e.new_zeros((triu_rows.shape[0],)) + row, col = edge_index[0], edge_index[1] + upper = row < col + i, j = row[upper], col[upper] + pos = (i * (2 * n - i - 1)) // 2 + (j - i - 1) + out[pos] = e[upper] + return out + + def interpolate_single( + self, + sample_data: ChemAtomicData, + target_data: ChemAtomicData, + t_scalar: float, + scaffold_pairs: list[tuple[int, int]] | None = None, + ) -> tuple[ChemAtomicData, ChemAtomicData, InsertionTargets]: + """Per-sample interpolation (OT alignment + interpolation + rate targets).""" + device = sample_data.positions.device + t_i = torch.tensor(t_scalar, device=device) + + # ===== 1. OT alignment ===== + if scaffold_pairs: + sample, target = scaffold_based_assignment_single( + sample_data, + target_data, + scaffold_pairs=scaffold_pairs, + c_move=self.c_move, + c_sub=self.c_sub, + c_ins=self.c_ins, + c_del=self.c_del, + optimal_transport=self.optimal_transport, + squared=True, + ) + else: + sample, target = partial_optimal_transport_single( + sample_data, + target_data, + c_move=self.c_move, + c_sub=self.c_sub, + c_ins=self.c_ins, + c_del=self.c_del, + optimal_transport=self.optimal_transport, + squared=True, + pre_align_iters=self.pre_align_iters, + ) + + n = sample.num_nodes + xs, as_, cs = sample.positions, sample.atom_types, sample.charge_types + xt, at, ct = target.positions, target.atom_types, target.charge_types + is_aux_s = sample.is_auxiliary + is_aux_t = target.is_auxiliary + s_nl, t_nl = sample.neighbor_list, target.neighbor_list + s_edge_index = s_nl.t() if s_nl is not None else None + s_e = sample.edge_types + t_edge_index = t_nl.t() if t_nl is not None else None + t_e = target.edge_types + + # ===== 2. Masks ===== + is_sub = ((~is_aux_s) & (~is_aux_t)).squeeze(-1) + is_del = ((~is_aux_s) & (is_aux_t)).squeeze(-1) + is_ins = ((is_aux_s) & (~is_aux_t)).squeeze(-1) + + t_kappa, inst_rate_sub = self.sub_schedule.kappa_and_rate(t_i) + if self.sub_e_schedule is self.sub_schedule: + t_kappa_e, inst_rate_sub_e = t_kappa, inst_rate_sub + else: + t_kappa_e, inst_rate_sub_e = self.sub_e_schedule.kappa_and_rate(t_i) + + tau_del = torch.rand(n, 1, device=device) + t_del, inst_rate_del = self.del_schedule.kappa_and_rate(t_i) + + tau_ins = torch.rand(n, 1, device=device) + t_ins, inst_rate_ins = self.ins_schedule.kappa_and_rate(t_i) + + mask_keep_sub = is_sub + mask_keep_del = is_del & (t_del < tau_del).squeeze(-1) + mask_keep_ins = is_ins & (t_ins > tau_ins).squeeze(-1) + mask_future_ins = is_ins & (t_ins <= tau_ins).squeeze(-1) + mask_exists = mask_keep_sub | mask_keep_del | mask_keep_ins + + # A. Deletions freeze at source + if mask_keep_del.any(): + xt[mask_keep_del] = xs[mask_keep_del] + at[mask_keep_del] = as_[mask_keep_del] + ct[mask_keep_del] = cs[mask_keep_del] + + # B. Insertions jump to target with noise + if mask_keep_ins.any(): + n_ins = int(mask_keep_ins.sum()) + xs[mask_keep_ins] = ( + xt[mask_keep_ins] + + torch.randn(n_ins, xs.shape[-1], device=device) * self.ins_noise_scale + ) + as_[mask_keep_ins] = self._cat_atom.sample((n_ins,)).to(device) + cs[mask_keep_ins] = self._cat_charge.sample((n_ins,)).to(device) + + # C. Edge handling on the upper triangle over the N aligned nodes + full_edge_index, triu_rows, triu_cols, full_to_triu = complete_graph_layout( + n, device + ) + + e0_triu = self._extract_triu_feats(s_edge_index, s_e, n, triu_rows) + e1_triu = self._extract_triu_feats(t_edge_index, t_e, n, triu_rows) + + edge_has_del = mask_keep_del[triu_rows] | mask_keep_del[triu_cols] + edge_has_ins = mask_keep_ins[triu_rows] | mask_keep_ins[triu_cols] + edge_mask_del = edge_has_del & ~edge_has_ins + edge_mask_ins = edge_has_ins & ~edge_has_del + + if edge_mask_del.any(): + e1_triu[edge_mask_del] = e0_triu[edge_mask_del] + if edge_mask_ins.any(): + n_edges_ins = int(edge_mask_ins.sum()) + rand_edges = self._cat_edge.sample((n_edges_ins,)).to(device) + e0_triu[edge_mask_ins] = rand_edges.to(dtype=e0_triu.dtype) + + # D. Interpolate (positions, then discrete attrs -- order preserved) + x_t = self.interpolate_continuous(xs, xt, t_i) + # Every coordinate-predicted atom receives the base denoising jitter. + # Present insertions additionally carry endpoint-vanishing bridge noise + # through ``xs``; their non-vanishing jitter floor makes the backbone + # robust to imperfect insertion-head samples at inference. Deletions and + # future insertions remain untouched. + move_noise = torch.randn_like(x_t) * self.move_noise_scale + move_noise[~(is_sub | mask_keep_ins)] = 0.0 + x_t = x_t + move_noise + a_t = self.interpolate_discrete(as_, at, t_kappa) + # Charge is a clean-state prediction target, not an interpolated state. + # It is not embedded by the backbone; retaining the source value merely + # keeps the atomic container schema complete until the final charge head + # prediction is sampled. + c_t = cs + e_t = self.interpolate_discrete(e0_triu, e1_triu, t_kappa_e) + + if self.atom_type_noise_prob > 0.0: + # Keep the clean endpoint and derive the usual edit labels below + # from the corrupted input. Source-only deletion nodes have frozen + # targets and masked type losses, so do not corrupt them. + eligible_atoms = mask_keep_sub | mask_keep_ins + if scaffold_pairs is not None: + # Scaffold nodes occupy the leading aligned slots; their atom + # types and internal bonds cannot change during generation. + editable_atoms = eligible_atoms.clone() + editable_atoms[: len(scaffold_pairs)] = False + else: + editable_atoms = eligible_atoms + a_t = self._corrupt_categorical( + a_t, editable_atoms, self.atom_type_noise_prob, self._noise_atom_probs + ) + eligible_pairs = eligible_atoms[triu_rows] & eligible_atoms[triu_cols] + eligible_pairs &= editable_atoms[triu_rows] | editable_atoms[triu_cols] + edge_noise_prob = self._balanced_edge_noise_probability( + int(editable_atoms.sum()), int(eligible_pairs.sum()) + ) + e_t = self._corrupt_categorical( + e_t, eligible_pairs, edge_noise_prob, self._noise_edge_probs + ) + + # E. Both states share the cached complete topology. + interp_e = e_t[full_to_triu] + target_e = e1_triu[full_to_triu] + target_full = ChemAtomicData( + positions=xt, + atom_types=at, + charge_types=ct, + neighbor_list=full_edge_index.t().contiguous(), + edge_types=target_e, + ) + + node_map_exists, neighbor_list, edge_exists = existing_graph_layout( + mask_exists, full_edge_index + ) + + interp_state = ChemAtomicData( + positions=x_t[mask_exists], + atom_types=a_t[mask_exists], + charge_types=c_t[mask_exists], + neighbor_list=neighbor_list, + edge_types=interp_e[edge_exists], + is_auxiliary=(is_aux_s | is_aux_t)[mask_exists], + ) + target_state = ChemAtomicData( + positions=xt[mask_exists], + atom_types=at[mask_exists], + charge_types=ct[mask_exists], + neighbor_list=neighbor_list.clone(), + edge_types=target_e[edge_exists], + ) + + # F. Spawn logic + insertion supervision (local indices) + ins_targets = self._build_insertion_targets( + interp_state, + target_full, + mask_future_ins.nonzero(as_tuple=True)[0], + node_map_exists, + ) + + # G. Rate targets + ins_counts = torch.bincount( + ins_targets.spawn_node_idx, minlength=interp_state.num_nodes + ).to(dtype=interp_state.positions.dtype) + + node_del_rate_target = torch.zeros((n,), device=device) + node_del_rate_target[mask_keep_del] = inst_rate_del + + interp_state.add_node_property("lambda_ins", ins_counts * inst_rate_ins) + interp_state.add_node_property("n_ins", ins_counts) + interp_state.add_node_property("lambda_del", node_del_rate_target[mask_exists]) + interp_state.add_node_property( + "lambda_a_sub", + (interp_state.atom_types != target_state.atom_types).int() * inst_rate_sub, + ) + interp_state.add_edge_property( + "lambda_e_sub", + (interp_state.edge_types != target_state.edge_types).int() + * inst_rate_sub_e, + ) + interp_state.add_system_property( + "n_ins_missing", mask_future_ins.sum().float().reshape(1) + ) + interp_state.add_system_property( + "n_del_missing", mask_keep_del.sum().float().reshape(1) + ) + + # H. Coordinate centering (ZCoM in the active frame) + x_mean = interp_state.positions.mean(dim=0) + interp_state.positions = interp_state.positions - x_mean + if ins_targets.num_nodes > 0: + ins_targets.positions = ins_targets.positions - x_mean + target_state.positions = target_state.positions - target_state.positions.mean( + dim=0 + ) + + # Per-sample topology edits remain on the lightweight native container; + # the collate concatenates these tensors directly into a ChemBatch. + return interp_state, target_state, ins_targets + + def _build_insertion_targets( + self, + interp_state, + target_full, + future_indices, + orig_to_interp, + ) -> InsertionTargets: + """Spawn-node assignment and insertion-bond supervision (local indices).""" + num_interp = interp_state.num_nodes + num_inserted = future_indices.numel() + pos = target_full.positions[future_indices] + atoms = target_full.atom_types[future_indices] + charges = target_full.charge_types[future_indices] + + if num_inserted == 0 or num_interp == 0: + return InsertionTargets.empty(pos, atoms, charges, target_full.device) + + # Each inserted atom is spawned from its nearest existing atom (the + # one-to-many map used by the GMM head + insertion-edge supervision). + dist_to_interp = torch.cdist(pos, interp_state.positions) + spawn_node_idx = dist_to_interp.argmin(dim=1) + # One-to-one inverse: each spawn node's single nearest insertion (used by + # the unimodal insertion head). + spawn_dist = dist_to_interp.gather(1, spawn_node_idx.unsqueeze(1)).squeeze(1) + nearest_node_idx, nearest_ins_idx = _nearest_insertion_per_node( + spawn_node_idx, spawn_dist, num_interp + ) + + orig_to_ins = torch.full_like(orig_to_interp, -1) + orig_to_ins[future_indices] = torch.arange( + num_inserted, device=target_full.device + ) + + # Classify each (directed) target edge by its endpoints. Boolean indexing + # naturally yields empty tensors when a class has no edges, so no special + # casing is needed. + src, dst = target_full.neighbor_list.t() + src_is_inserted = orig_to_ins[src] >= 0 + dst_is_existing = orig_to_interp[dst] >= 0 + dst_is_inserted = orig_to_ins[dst] >= 0 + + # inserted -> existing + to_exist = src_is_inserted & dst_is_existing + inserted_idx = orig_to_ins[src[to_exist]] + bonds_to_existing = BondsToExisting( + spawn_idx=spawn_node_idx[inserted_idx], + existing_idx=orig_to_interp[dst[to_exist]], + inserted_idx=inserted_idx, + bond_type=target_full.edge_types[to_exist], + ) + + # inserted -> inserted, keeping the upper triangle (src_local < dst_local) + between = src_is_inserted & dst_is_inserted + src_idx = orig_to_ins[src[between]] + dst_idx = orig_to_ins[dst[between]] + upper = src_idx < dst_idx + src_idx, dst_idx = src_idx[upper], dst_idx[upper] + bonds_between_inserted = BondsBetweenInserted( + spawn_src_idx=spawn_node_idx[src_idx], + spawn_dst_idx=spawn_node_idx[dst_idx], + src_idx=src_idx, + dst_idx=dst_idx, + bond_type=target_full.edge_types[between][upper], + ) + + return InsertionTargets( + positions=pos, + atom_types=atoms, + charge_types=charges, + spawn_node_idx=spawn_node_idx, + bonds_to_existing=bonds_to_existing, + bonds_between_inserted=bonds_between_inserted, + nearest_node_idx=nearest_node_idx, + nearest_ins_idx=nearest_ins_idx, + ) diff --git a/geom/explicit_h/runtime/src/morph/flow_matching/prior.py b/geom/explicit_h/runtime/src/morph/flow_matching/prior.py new file mode 100644 index 0000000000000000000000000000000000000000..55d2a0955cd0c40e381c4c55502978a593d06e9c --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/flow_matching/prior.py @@ -0,0 +1,74 @@ +"""Prior-graph sampling for Morph's native geometric-graph container. + +The random-draw order matches the original, so the two agree under the same seed. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch +from torch.distributions import Categorical + +from morph.dataset.core.atomic_data import ChemAtomicData +from morph.utils.graph_math import complete_graph_layout + +if TYPE_CHECKING: + from morph.dataset.core.vocab import Distributions + + +# Cache the (fixed) prior categoricals per distributions object with +# validate_args=False: rebuilding + simplex-validating them per __getitem__ cost +# ~6s in the dataloader profile. Construction draws no RNG -> sample stream unchanged. +_PRIOR_CAT_CACHE: dict[int, tuple] = {} + + +def _prior_categoricals(distributions: Distributions) -> tuple: + key = id(distributions) + cats = _PRIOR_CAT_CACHE.get(key) + if cats is None: + cats = tuple( + Categorical(probs=p, validate_args=False) + for p in ( + distributions.atom_type_distribution, + distributions.edge_type_distribution, + distributions.charge_type_distribution, + distributions.n_atoms_distribution, + ) + ) + _PRIOR_CAT_CACHE[key] = cats + return cats + + +def sample_prior_graph( + distributions: Distributions, + n_atoms: int | None = None, +) -> ChemAtomicData: + """Sample a fully-connected prior graph from the training distributions. + + Atom / charge / edge types are drawn from their categorical priors, and + coordinates from a centered standard normal. + """ + p_atom_types, p_edge_types, p_charge_types, p_n_atoms = _prior_categoricals( + distributions + ) + + num_atoms = n_atoms if n_atoms is not None else int(p_n_atoms.sample()) + + atom_types = p_atom_types.sample((num_atoms,)) + charge_types = p_charge_types.sample((num_atoms,)) + + coord = torch.randn(num_atoms, 3) + coord = coord - coord.mean(dim=0) + + edge_index, _, _, triu_index = complete_graph_layout(num_atoms) + num_triu_edges = (num_atoms**2 - num_atoms) // 2 + edge_types = p_edge_types.sample((num_triu_edges,))[triu_index] + + return ChemAtomicData( + positions=coord, + atom_types=atom_types, + charge_types=charge_types, + edge_types=edge_types, + neighbor_list=edge_index.t().contiguous(), + ) diff --git a/geom/explicit_h/runtime/src/morph/flow_matching/sampling_step.py b/geom/explicit_h/runtime/src/morph/flow_matching/sampling_step.py new file mode 100644 index 0000000000000000000000000000000000000000..4b246a1bcceb3513f24df93016b92dbda488e5a7 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/flow_matching/sampling_step.py @@ -0,0 +1,104 @@ +"""Shared parameterization and optional trace records for sampling steps. + +The integrator remains the source of transition semantics. Callbacks can inspect +the same deletion, insertion, edge, and schedule decisions without duplicating +the transition implementation. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import torch + + from morph.dataset.core.insertion_data import InsertedAtoms + from morph.dataset.core.insertion_targets import ( + BondsBetweenInserted, + BondsToExisting, + ) + + +@dataclass(frozen=True) +class ScheduleStepMasses: + """Integrated categorical jump masses for one graph-level time step.""" + + substitution: torch.Tensor + edge_substitution: torch.Tensor + insertion: torch.Tensor + deletion: torch.Tensor + + +@dataclass(frozen=True) +class NodeEditProbabilities: + """Mutually exclusive numerical-step edit probabilities.""" + + any_edit: torch.Tensor + deletion_given_edit: torch.Tensor + + +def node_edit_probabilities( + substitution: torch.Tensor, + deletion: torch.Tensor, + eps: float = 1e-8, +) -> NodeEditProbabilities: + """Combine mutually exclusive substitution and deletion probabilities.""" + raw_any = substitution + deletion + return NodeEditProbabilities( + any_edit=raw_any.clamp(0.0, 1.0), + deletion_given_edit=(deletion / raw_any.clamp_min(eps)).clamp(0.0, 1.0), + ) + + +@dataclass(frozen=True) +class InsertionStepTrace: + """Realized insertion edits in the pre-edit node index space.""" + + spawn_node_idx: torch.Tensor + clean_atoms: InsertedAtoms + bonds_to_existing: BondsToExisting | None + bonds_between_inserted: BondsBetweenInserted | None + + +@dataclass(frozen=True) +class IntegrationStepTrace: + """Edits realized by one production integration step.""" + + positions: torch.Tensor + atom_type_targets: torch.Tensor + charge_targets: torch.Tensor + node_substitutions: torch.Tensor + node_deletions: torch.Tensor + edge_index: torch.Tensor + edge_type_targets: torch.Tensor + edge_substitutions: torch.Tensor + insertions: torch.Tensor + insertion: InsertionStepTrace | None + + +@dataclass(frozen=True) +class SamplingStepTrace: + """Observable model outputs, edits, and states for one sampling step. + + Tensor references are valid during the callback. Consumers retaining a + trace should detach and clone only the fields they need. + """ + + mol_t: object + mol_t_next: object + t: torch.Tensor + dt: float | torch.Tensor + preds: dict + integration: IntegrationStepTrace + is_final: bool + + +__all__ = [ + "InsertionStepTrace", + "IntegrationStepTrace", + "NodeEditProbabilities", + "SamplingStepTrace", + "ScheduleStepMasses", + "node_edit_probabilities", +] diff --git a/geom/explicit_h/runtime/src/morph/flow_matching/schedules.py b/geom/explicit_h/runtime/src/morph/flow_matching/schedules.py new file mode 100644 index 0000000000000000000000000000000000000000..079f8c8b847bc383eb54d42453cae384721e889c --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/flow_matching/schedules.py @@ -0,0 +1,186 @@ +from abc import ABC, abstractmethod + +import numpy as np +import torch +import torch.nn as nn +from scipy.stats import beta + + +class KappaSchedule(nn.Module, ABC): + """ + Abstract base class for flow-matching time schedules (kappa schedules). + + A schedule maps time t in [0, 1] to kappa(t) (CDF) and provides the rate + kappa'(t) / (1 - kappa(t)) used in rate-based flow matching. + """ + + @abstractmethod + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Maps t to kappa(t) (CDF).""" + ... + + @abstractmethod + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Derivative of kappa w.r.t. t (PDF).""" + ... + + def rate(self, t: torch.Tensor, epsilon: float = 1e-6) -> torch.Tensor: + """Rate: kappa_t_dot(t) / (1 - kappa_t(t)). + + Default implementation derived from ``kappa_t`` / ``kappa_t_dot``. + Subclasses with a cheaper closed form (e.g. ``FastPowerSchedule``) may + override. The clamp guards the division as t -> 1. + """ + return self.kappa_and_rate(t, epsilon)[1] + + def kappa_and_rate( + self, t: torch.Tensor, epsilon: float = 1e-6 + ) -> tuple[torch.Tensor, torch.Tensor]: + """Evaluate the CDF and hazard without computing the CDF twice.""" + k_t = self.kappa_t(t) + k_t_dot = self.kappa_t_dot(t) + denominator = torch.clamp(1 - k_t, min=epsilon) + return k_t, k_t_dot / denominator + + def integrated_rate( + self, t: torch.Tensor, dt, epsilon: float = 1e-6 + ) -> torch.Tensor: + """Return the exact schedule hazard integrated over ``[t, t + dt]``. + + Since ``rate = kappa' / (1 - kappa)``, its integral is the log ratio + of the survival probabilities. This avoids the left-endpoint + ``rate(t) * dt`` approximation without requiring schedule-specific code. + """ + survival = torch.clamp(1.0 - self.kappa_t(t), min=epsilon) + t_next = torch.clamp(t + dt, max=1.0) + survival_next = torch.clamp(1.0 - self.kappa_t(t_next), min=epsilon) + return torch.log(survival) - torch.log(survival_next) + + +class BetaSchedule(KappaSchedule): + """Beta-CDF schedule evaluated through SciPy for numerical stability.""" + + def __init__(self, k_alpha: float, k_beta: float): + """Initialize the Beta distribution shape parameters.""" + super().__init__() + self.k_alpha = k_alpha + self.k_beta = k_beta + + def _to_numpy(self, t: torch.Tensor): + """Helper to convert tensor to numpy array safely.""" + # Detach from graph, move to CPU, convert to numpy + return t.detach().cpu().numpy() + + def _to_tensor(self, x: np.ndarray, t_template: torch.Tensor): + """Helper to convert numpy result back to tensor on correct device.""" + return torch.tensor(x, device=t_template.device, dtype=t_template.dtype) + + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the Beta CDF.""" + t_np = self._to_numpy(t) + + # scipy.stats.beta.cdf handles the math + cdf_val = beta.cdf(t_np, self.k_alpha, self.k_beta) + + return self._to_tensor(cdf_val, t) + + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the Beta PDF.""" + t_np = self._to_numpy(t) + + # scipy.stats.beta.pdf handles the math + pdf_val = beta.pdf(t_np, self.k_alpha, self.k_beta) + + return self._to_tensor(pdf_val, t) + + +class CubicSchedule(KappaSchedule): + """Cubic schedule ``kappa(t) = t^3``.""" + + def __init__(self): + super().__init__() + + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the cubic CDF.""" + return t**3 + + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the cubic CDF derivative.""" + return 3 * t**2 + + +class LinearSchedule(KappaSchedule): + """Linear schedule ``kappa(t) = t``.""" + + def __init__(self): + super().__init__() + + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the linear CDF.""" + return t + + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the constant linear CDF derivative.""" + return torch.ones_like(t) + + +class FastPowerSchedule(KappaSchedule): + """Optimized Beta schedule for alpha equal to one.""" + + def __init__(self, beta: float): # e.g. 1.5 + super().__init__() + self.register_buffer("beta", torch.tensor(float(beta))) + + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the analytic power-schedule CDF.""" + # Analytical CDF for Alpha=1: 1 - (1-t)^beta + return 1 - torch.pow(1 - t, self.beta) + + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the analytic power-schedule PDF.""" + # Analytical PDF for Alpha=1: beta * (1-t)^(beta-1) + return self.beta * torch.pow(1 - t, self.beta - 1) + + def rate(self, t: torch.Tensor, epsilon: float = 1e-6) -> torch.Tensor: + """Evaluate the analytic power-schedule hazard.""" + # Analytical Rate for Alpha=1: beta / (1-t) + denom = torch.clamp(1 - t, min=epsilon) + return self.beta / denom + + def kappa_and_rate( + self, t: torch.Tensor, epsilon: float = 1e-6 + ) -> tuple[torch.Tensor, torch.Tensor]: + """Evaluate the analytic CDF and hazard together.""" + return self.kappa_t(t), self.rate(t, epsilon) + + +class SmoothstepSchedule(KappaSchedule): + """ + A simple polynomial schedule that concentrates edits in a bell shape. + - shift = 1.0 : Edits peak perfectly in the middle. + - shift < 1.0 : Edits happen earlier (pushes the peak towards t=0). + - shift > 1.0 : Edits happen later (pushes the peak towards t=1). + """ + + def __init__(self, shift: float = 1.0): + super().__init__() + self.shift = shift + + def kappa_t(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the time-warped smoothstep CDF.""" + # Standard smoothstep is 3t^2 - 2t^3. + # By raising t to the power of 'shift' (p), we warp time to move the peak. + p = self.shift + # Clamped because the two terms nearly cancel as t -> 1: in fp32 the + # difference can round to ~1 + 1e-7, and a CDF above 1 turns downstream + # ``(1 - kappa)`` mixing weights negative. + return (3 * t ** (2 * p) - 2 * t ** (3 * p)).clamp(0.0, 1.0) + + def kappa_t_dot(self, t: torch.Tensor) -> torch.Tensor: + """Evaluate the time-warped smoothstep CDF derivative.""" + p = self.shift + # We clamp t slightly above 0 to prevent NaN errors when shift < 0.5 + t_safe = torch.clamp(t, min=1e-7) + + # The exact mathematical derivative of kappa_t + return 6 * p * (t_safe ** (2 * p - 1) - t_safe ** (3 * p - 1)) diff --git a/geom/explicit_h/runtime/src/morph/inference/__init__.py b/geom/explicit_h/runtime/src/morph/inference/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..299d075aaa7c892f6663520711cf0ccd60fc402f --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/inference/__init__.py @@ -0,0 +1,32 @@ +"""In-process inference utilities backing the ``morph`` CLI.""" + +from morph.inference.catalog import ModelCatalog, load_catalog +from morph.inference.generate import ( + build_sampling_module, + generate_denovo, + load_checkpoint, + make_predict_trainer, + record_mol, +) +from morph.inference.hub import ( + ModelBundle, + PretrainedModel, + download_pretrained, + load_pretrained, +) +from morph.inference.registry import ModelSpec + +__all__ = [ + "ModelBundle", + "ModelCatalog", + "ModelSpec", + "PretrainedModel", + "build_sampling_module", + "download_pretrained", + "generate_denovo", + "load_catalog", + "load_checkpoint", + "load_pretrained", + "make_predict_trainer", + "record_mol", +] diff --git a/geom/explicit_h/runtime/src/morph/inference/bundles.py b/geom/explicit_h/runtime/src/morph/inference/bundles.py new file mode 100644 index 0000000000000000000000000000000000000000..56f30c3dbac384d8ea5d236a854c1889403fe427 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/inference/bundles.py @@ -0,0 +1,162 @@ +"""Dataset-independent bundle metadata, checkpoint selection and local validation.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, replace +from pathlib import Path + +from morph.inference.registry import ModelSpec, bundle_path + +ASSET_FILES = ( + "config.yaml", + "metadata.json", + "atom_tokens.txt", + "edge_tokens.txt", + "charge_tokens.txt", + "distributions.pt", +) + + +@dataclass(frozen=True) +class ModelBundle: + """Local paths and resolved identity for a checkpoint and its inference assets.""" + + spec: ModelSpec + epoch: int | None + directory: Path + checkpoint: Path + revision: str | None + + @property + def config_path(self) -> Path: + """Portable inference configuration, not the historical training config.""" + return self.directory / "config.yaml" + + +def read_metadata(directory: Path, spec: ModelSpec) -> dict: + """Read a bundle's identity and check any expectations from the catalog.""" + metadata = json.loads((directory / "metadata.json").read_text()) + if not isinstance(metadata, dict): + raise ValueError("metadata.json must contain an object") + dataset = metadata.get("dataset") + if not isinstance(dataset, str) or not dataset.strip(): + raise ValueError("metadata.json must identify the dataset") + if spec.dataset is not None and dataset.casefold() != spec.dataset.casefold(): + raise ValueError( + f"Expected {spec.dataset} assets; found {dataset!r} in {directory}" + ) + variant = metadata.get("variant") + if spec.variant is not None and variant != spec.variant: + raise ValueError( + f"Expected {spec.variant} assets; found {variant!r} in {directory}" + ) + if variant not in {"explicit_h", "implicit_h"}: + raise ValueError("metadata.variant must be 'explicit_h' or 'implicit_h'") + for field in ("atom_tokens", "edge_tokens", "charge_tokens"): + tokens = metadata.get(field) + if ( + not isinstance(tokens, list) + or not tokens + or any(not isinstance(token, str) or not token for token in tokens) + or len(set(tokens)) != len(tokens) + ): + raise ValueError( + f"metadata.{field} must be a nonempty ordered list of unique tokens" + ) + has_h = "H" in metadata["atom_tokens"] + if has_h != (variant == "explicit_h"): + raise ValueError("Atom vocabulary does not match the hydrogen representation") + declared_h = metadata.get("includes_explicit_hydrogens", has_h) + if type(declared_h) is not bool or declared_h != has_h: + raise ValueError("includes_explicit_hydrogens does not match the vocabulary") + return metadata + + +def select_checkpoint(metadata: dict, epoch: int | None, filename: str | None): + """Select a filename from metadata, with an explicit filename escape hatch.""" + if epoch is not None and filename is not None: + raise ValueError("Choose either epoch or filename, not both") + entries = metadata.get("checkpoints", []) + if not isinstance(entries, list): + raise ValueError("metadata.checkpoints must be a list") + by_epoch = {} + for entry in entries: + if not isinstance(entry, dict): + raise ValueError( + "Each checkpoint must contain filename and completed_epochs" + ) + completed = entry.get("completed_epochs") + if type(completed) is not int or completed < 1: + raise ValueError( + "Each checkpoint must have positive integer completed_epochs" + ) + path = entry.get("filename") + if not isinstance(path, str): + raise ValueError("Each checkpoint must have a filename") + path = bundle_path(path) + if completed in by_epoch or path in by_epoch.values(): + raise ValueError("Checkpoint epochs and filenames must be unique") + by_epoch[completed] = path + if filename is not None: + filename = bundle_path(filename) + selected_epoch = next( + (e for e, path in by_epoch.items() if path == filename), None + ) + return selected_epoch, filename + if by_epoch: + epoch = max(by_epoch) if epoch is None else epoch + if epoch not in by_epoch: + raise ValueError( + f"Bundle has completed epochs {sorted(by_epoch)}; requested {epoch}" + ) + return epoch, by_epoch[epoch] + if epoch is not None: + # Support older bundles without a checkpoint manifest. + return epoch, f"epoch_{epoch:03d}.ckpt" + raise ValueError("Provide epoch or filename, or list checkpoints in metadata.json") + + +def local_bundle( + spec: ModelSpec, + directory: str | Path, + *, + epoch: int | None = None, + filename: str | None = None, + checkpoint: str | Path | None = None, + revision: str | None = None, +) -> ModelBundle: + """Resolve a direct bundle directory or export root, without dataset access.""" + directory = Path(directory).expanduser().resolve() + nested = directory / spec.subfolder + if (nested / "metadata.json").is_file() or not ( + directory / "metadata.json" + ).is_file(): + directory = nested + missing = [ + str(directory / name) + for name in ASSET_FILES + if not (directory / name).is_file() + ] + if missing: + raise FileNotFoundError( + "Incomplete model bundle. Missing: " + ", ".join(missing) + ) + metadata = read_metadata(directory, spec) + for field in ("atom_tokens", "edge_tokens", "charge_tokens"): + tokens = (directory / f"{field}.txt").read_text().splitlines() + if tokens != metadata[field]: + raise ValueError(f"{field} does not match the bundle metadata.") + if checkpoint is None: + epoch, filename = select_checkpoint(metadata, epoch, filename) + checkpoint = directory / filename + else: + if filename is not None: + raise ValueError("Choose either checkpoint or filename, not both") + checkpoint = Path(checkpoint).expanduser().resolve() + if not checkpoint.is_file(): + raise FileNotFoundError( + f"Incomplete model bundle. Missing checkpoint: {checkpoint}" + ) + spec = replace(spec, dataset=metadata["dataset"], variant=metadata["variant"]) + return ModelBundle(spec, epoch, directory, checkpoint, revision) diff --git a/geom/explicit_h/runtime/src/morph/inference/catalog.py b/geom/explicit_h/runtime/src/morph/inference/catalog.py new file mode 100644 index 0000000000000000000000000000000000000000..d2e7b3b6f13584611b94c071d4da9344831d93c4 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/inference/catalog.py @@ -0,0 +1,149 @@ +"""Discover model shortcuts from a versioned catalog on Hugging Face.""" + +# Discovery needs only JSON and the Hub client, never the model/scientific stack. +# ruff: noqa: PLC0415 + +from __future__ import annotations + +import json +import os +import re +from dataclasses import dataclass +from pathlib import Path + +from morph.inference.registry import ModelSpec + +DEFAULT_CATALOG_REPO = "maltefranke/morph" +CATALOG_FILENAME = "catalog.json" + + +@dataclass(frozen=True) +class ModelCatalog: + """Discovered model specifications and the catalog's own provenance.""" + + models: dict[str, ModelSpec] + default_model: str | None + path: Path + repo_id: str | None = None + revision: str | None = None + + +def _read_catalog(path: Path, *, repo_id=None, revision=None) -> ModelCatalog: + payload = json.loads(path.read_text()) + if not isinstance(payload, dict) or type(payload.get("schema_version")) is not int: + raise ValueError("Catalog must contain an integer schema_version") + if payload["schema_version"] != 1: + raise ValueError( + f"Unsupported catalog schema_version: {payload['schema_version']}" + ) + if set(payload) - {"schema_version", "default_model", "models"}: + raise ValueError( + "Unknown catalog fields; expected schema_version, default_model, models" + ) + entries = payload.get("models") + if not isinstance(entries, list): + raise ValueError("Catalog models must be a list") + models = {} + for entry in entries: + if not isinstance(entry, dict): + raise ValueError("Each catalog model must be an object") + if set(entry) - { + "name", + "repo_id", + "subfolder", + "revision", + "dataset", + "variant", + "epochs", + }: + raise ValueError(f"Unknown fields in catalog model {entry.get('name')!r}") + name = entry.get("name") + if not isinstance(name, str) or not re.fullmatch( + r"[A-Za-z0-9][A-Za-z0-9._-]*", name + ): + raise ValueError( + "Catalog model names must be nonempty aliases without slashes or spaces" + ) + if name in models: + raise ValueError(f"Duplicate catalog model name: {name}") + model_repo = entry.get("repo_id") + if ( + not isinstance(model_repo, str) + or model_repo.count("/") != 1 + or not all(model_repo.split("/")) + or any(char.isspace() for char in model_repo) + ): + raise ValueError(f"Catalog model {name} must have an owner/repo repo_id") + model_revision = entry.get("revision") + if not isinstance(model_revision, str) or not model_revision.strip(): + raise ValueError(f"Catalog model {name} must declare its release revision") + for field in ("subfolder", "dataset", "variant"): + value = entry.get(field) + if value is not None and not isinstance(value, str): + raise ValueError(f"Catalog model {name}: {field} must be a string") + if "subfolder" in entry and entry["subfolder"] is None: + raise ValueError(f"Catalog model {name}: subfolder must be a string") + if entry.get("variant") not in {None, "explicit_h", "implicit_h"}: + raise ValueError(f"Catalog model {name}: unsupported hydrogen variant") + epochs = entry.get("epochs", []) + if not isinstance(epochs, list): + raise ValueError(f"Catalog model {name}: epochs must be a list") + models[name] = ModelSpec(**{**entry, "epochs": tuple(epochs)}) + default = payload.get("default_model") + if default is not None and (not isinstance(default, str) or default not in models): + raise ValueError("Catalog default_model must name an entry in models") + return ModelCatalog(models, default, path, repo_id, revision) + + +def load_catalog( + repo_id: str | None = None, + *, + revision: str | None = None, + path: str | Path | None = None, + cache_dir: str | Path | None = None, + local_files_only: bool = False, + token: str | bool | None = None, +) -> ModelCatalog: + """Read the current Hub catalog, its cached copy, or an explicit local file. + + Downloads only ``catalog.json``. The default location is controlled by + ``MORPH_CATALOG_REPO`` and ``MORPH_CATALOG_REVISION`` (default: main). + A catalog's revision is independent of the release revisions in its entries. + ``path`` bypasses the Hub and is mutually exclusive with repo_id/revision. + """ + if path is not None: + if repo_id is not None or revision is not None: + raise ValueError( + "A local catalog path cannot be combined with repo_id or revision" + ) + return _read_catalog(Path(path).expanduser().resolve()) + repo_id = repo_id or os.environ.get("MORPH_CATALOG_REPO", DEFAULT_CATALOG_REPO) + revision = revision or os.environ.get("MORPH_CATALOG_REVISION", "main") + from huggingface_hub import hf_hub_download + from huggingface_hub.errors import HfHubHTTPError, LocalEntryNotFoundError + + try: + downloaded = hf_hub_download( + repo_id=repo_id, + filename=CATALOG_FILENAME, + revision=revision, + cache_dir=cache_dir, + local_files_only=local_files_only, + token=token, + ) + except LocalEntryNotFoundError as exc: + raise FileNotFoundError( + f"No cached catalog for {repo_id}@{revision}. Run `morph models list` " + "online first with the same catalog source and cache directory, " + "or supply a local catalog file." + ) from exc + except HfHubHTTPError as exc: + raise RuntimeError( + f"Cannot read {repo_id}/{CATALOG_FILENAME} at {revision}. " + "Check that the catalog exists and use `hf auth login` or HF_TOKEN " + "if the repository requires access." + ) from exc + path = Path(downloaded) + # Hub cache path is snapshots//catalog.json. Do not resolve + # the symlink: its target is a blob, which would lose revision provenance. + return _read_catalog(path, repo_id=repo_id, revision=path.parent.name) diff --git a/geom/explicit_h/runtime/src/morph/inference/generate.py b/geom/explicit_h/runtime/src/morph/inference/generate.py new file mode 100644 index 0000000000000000000000000000000000000000..4db57ff2489d01594ae559e7abc4fd1247cf6014 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/inference/generate.py @@ -0,0 +1,327 @@ +# Inference helpers load the scientific stack only when invoked. +# ruff: noqa: PLC0415 + +"""In-process de-novo generation for the ``morph`` CLI. + +Canonical implementation of unconditional sampling: builds the lightning module +from hydra overrides, strictly loads a current-model checkpoint, runs one predict +pass, and returns every attempted trajectory with its validity flag. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import morph.config # noqa: F401 (import registers OmegaConf resolvers) + +# A wheel carries configs under ``morph/configs``. The source checkout keeps the +# canonical tree at repository level, so editable installs use that as fallback. +_PACKAGED_CONFIG_DIR = Path(__file__).resolve().parents[1] / "configs" +_REPO_CONFIG_DIR = Path(__file__).resolve().parents[3] / "configs" +_CONFIG_DIR = ( + _PACKAGED_CONFIG_DIR if _PACKAGED_CONFIG_DIR.is_dir() else _REPO_CONFIG_DIR +) + + +def _load_repo_env() -> None: + """Make the CLI self-contained: populate ``os.environ`` from the repo ``.env`` + so ``morph generate`` works without ``--env-file .env``. + + Configs interpolate ``${oc.env:PROJECT_ROOT}`` (e.g. ``data.root_path``) which + is resolved even for dataset-free inference. We parse ``.env`` (``export + KEY=VALUE``, shell-expanded in file order so later vars can reference earlier + ones), setting only vars not already in the environment — so an explicit + ``--env-file`` / a sourced ``.env`` still wins. ``PROJECT_ROOT`` is the one + the configs hard-require; default it to the repo root when ``.env`` is absent. + """ + repo = Path(__file__).resolve().parents[3] + env_file = repo / ".env" + if env_file.exists(): + for raw in env_file.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + line = line.removeprefix("export ") + key, sep, val = line.partition("=") + key = key.strip() + if not sep or not key or key in os.environ: + continue + os.environ[key] = os.path.expandvars(val.strip().strip('"').strip("'")) + os.environ.setdefault("PROJECT_ROOT", str(repo)) + + +def _build_module(cfg): + """Build from bundle statistics without constructing a dataset.""" + import hydra + from omegaconf import OmegaConf + + from morph.model.build import build_model_module, prepare_model_data + + OmegaConf.set_struct(cfg, False) + # A fine-tuned checkpoint already contains its pretrained base weights. + if "pretrained_ckpt" in cfg.model.module: + cfg.model.module.pretrained_ckpt = None + preprocessing = hydra.utils.instantiate(cfg.data.preprocessing) + data = prepare_model_data(cfg, preprocessing.vocab, preprocessing.distributions) + module = build_model_module(cfg, data) + return module, data.vocab, preprocessing.distributions + + +def _uniform_n_atoms_range(distributions, padding: int = 5) -> tuple[int, int]: + """(min, max) atom-count bounds for the de-novo ``uniform`` n_atoms prior. + + Delegates to the training wrapper's implementation so the two paths cannot + silently acquire different source-count priors. + """ + from morph.dataset.core.flow_matching_wrapper import ( + uniform_n_atoms_range, + ) + + return uniform_n_atoms_range(distributions, padding) + + +def _prior_loader( + distributions, + n_mols: int, + batch_size: int | None = None, + uniform_n_atoms_padding: int = 5, + n_atoms_strategy: str = "uniform", +): + """A DataLoader of ``n_mols`` prior graphs — no dataset needed. + + ``batch_size`` (default: all ``n_mols`` in one batch) chunks the priors so + large ``n_mols`` (e.g. 10k) fit in GPU memory across several predict batches. + + ``distributions`` is the exact representation-aware type prior installed on + the sampling module. It matches the t=0 distribution used by + ``FlowMatchingDatasetWrapper``, which is uniform in two independent ways: + + * represented atom / charge / edge **types** have equal probability per + token; channels absent from the representation are pinned to their inert + token; + * atom **counts** follow the configured dataset-free strategy: uniform over + the padded support, or the empirical n-atoms marginal. + + Coords come from the Gaussian prior; paired with itself as ``(mol_t, mol_1)`` + since de-novo sampling reads only ``mol_t``. + """ + import torch + + from morph.dataset.core.flow_matching_wrapper import eval_collate_fn + from morph.flow_matching.prior import sample_prior_graph + + if n_atoms_strategy == "uniform": + n_min, n_max = _uniform_n_atoms_range(distributions, uniform_n_atoms_padding) + counts = torch.randint(n_min, n_max + 1, (n_mols,)).tolist() + elif n_atoms_strategy == "empirical": + counts = [None] * n_mols + else: + raise ValueError( + "dataset-free generation supports n_atoms_strategy 'uniform' or " + f"'empirical', got {n_atoms_strategy!r}" + ) + priors = [sample_prior_graph(distributions, n_atoms=n) for n in counts] + return torch.utils.data.DataLoader( + [(p, p) for p in priors], + batch_size=batch_size or n_mols, + shuffle=False, + num_workers=0, + collate_fn=eval_collate_fn, + ) + + +def load_checkpoint(module, checkpoint_path) -> None: + """Strictly load an eager or ``torch.compile`` checkpoint.""" + import torch + + from morph.utils.checkpoints import state_dict_for_module + + ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False) + sd = ckpt.get("state_dict", ckpt) + module.load_state_dict(state_dict_for_module(sd, module), strict=True) + + +def build_sampling_module(overrides, checkpoint=None): + """Compose ``overrides`` and build a sampling-ready module — no dataset access. + + The reusable entry point for every in-process inference mode (de-novo or + scaffold): returns ``(module, vocab, distributions, cfg)`` and, when + ``checkpoint`` is given, loads it. Works for any ``cfg.model.module`` target. + Compiles both current and EMA models unless ``MORPH_COMPILE=0``. + """ + from hydra import compose, initialize_config_dir + + from morph.utils.run_config import configure_runtime + + _load_repo_env() + configure_runtime() + + with initialize_config_dir(config_dir=str(_CONFIG_DIR), version_base="1.1"): + cfg = compose(config_name="default", overrides=list(overrides)) + module, vocab, distributions = _build_module(cfg) + if checkpoint is not None: + load_checkpoint(module, Path(checkpoint)) + module.compile() + return module, vocab, distributions, cfg + + +def make_predict_trainer(cfg): + """A single-GPU (auto-accelerator) predict Trainer with logging/ckpt off.""" + import pytorch_lightning as pl + + tk = dict(cfg.trainer.trainer) + # ``num_val_samples`` is a Morph setup setting, not a Trainer argument. + tk.pop("num_val_samples", None) + if tk.get("limit_val_batches") is None: + tk.pop("limit_val_batches", None) + tk["strategy"] = "auto" + tk["devices"] = 1 + tk["limit_predict_batches"] = 1.0 + return pl.Trainer(logger=False, callbacks=[], enable_checkpointing=False, **tk) + + +def generate_denovo( + checkpoint, + overrides, + n_mols: int = 16, + batch_size: int | None = None, + seed: int = 42, + *, + return_traj: bool = False, +): + """Sample ``n_mols`` de-novo molecules from an unconditional checkpoint. + + No dataset is loaded: priors are drawn directly from the training + distributions. Returns ``(trajectories, is_valid, vocab, coordinate_std)``. + By default each trajectory contains only the final frame; pass + ``return_traj=True`` to retain all integration frames. Current and EMA models + are compiled by default; set ``MORPH_COMPILE=0`` to disable compilation. + Trajectories stay in attempt order (frames in Angstrom; + ``trajectories[i][-1]`` is the generated molecule), including failures, so + requesting ``n_mols`` never silently changes the denominator. + """ + import pytorch_lightning as pl + + pl.seed_everything(seed) + module, vocab, _distributions, cfg = build_sampling_module(overrides, checkpoint) + return sample_module( + module, + vocab, + cfg, + n_mols=n_mols, + batch_size=batch_size, + seed=None, + return_traj=return_traj, + ) + + +def sample_module( + module, + vocab, + cfg, + n_mols: int = 16, + batch_size: int | None = None, + seed: int | None = 42, + *, + return_traj: bool = False, +): + """Sample an already loaded module; return every attempt and its validity.""" + import pytorch_lightning as pl + + if n_mols < 1 or (batch_size is not None and batch_size < 1): + raise ValueError("n_mols and batch_size must be positive") + if seed is not None: + pl.seed_everything(seed) + module.predict_return_traj = return_traj + + # Use the module's prior directly: the initial graph and every insertion + # renoising step therefore share the exact same categorical probabilities. + loader = _prior_loader( + module.distributions, + n_mols, + batch_size, + uniform_n_atoms_padding=int(cfg.data.get("uniform_n_atoms_padding", 5)), + n_atoms_strategy=str(cfg.data.n_atoms_strategy), + ) + preds = make_predict_trainer(cfg).predict(module, dataloaders=loader) + + trajectories = [] + is_valid = [] + for pr in preds or []: + if isinstance(pr, dict): + batch_mols = pr.get("all_mols") or [] + batch_valid = pr.get("is_valid") or [] + if len(batch_mols) != len(batch_valid): + raise RuntimeError( + "Prediction output has misaligned molecules and validity flags" + ) + trajectories.extend(batch_mols) + is_valid.extend(bool(v) for v in batch_valid) + coordinate_std = float(module.distributions.coordinate_std) + if len(trajectories) != n_mols: + raise RuntimeError( + f"Requested {n_mols} molecules, received {len(trajectories)}" + ) + return trajectories[:n_mols], is_valid[:n_mols], vocab, coordinate_std + + +def final_mol(traj, vocab): + """RDKit mol of a trajectory's final frame (keeps the 3D conformer), or None.""" + frame = traj[-1] if isinstance(traj, list) else traj + try: + return frame.to_rdkit_mol( + vocab.atom_tokens, vocab.edge_tokens, vocab.charge_tokens + ) + except Exception: + return None + + +def record_mol(traj, vocab) -> dict: + """Summarize a trajectory's final molecule (SMILES / mol block / N / logP).""" + from rdkit import Chem + from rdkit.Chem import Crippen + + mol = final_mol(traj, vocab) + if mol is None: + return { + "valid": False, + "smiles": None, + "molblock": None, + "n_atoms": None, + "logp": None, + } + try: + lp = float(Crippen.MolLogP(mol)) + except Exception: + lp = None + return { + "valid": True, + "smiles": Chem.MolToSmiles(mol), + "molblock": Chem.MolToMolBlock(mol), + "n_atoms": Chem.AddHs(mol).GetNumAtoms(), + "logp": lp, + } + + +def write_sdf(leads, vocab, path) -> tuple[int, int]: + """Write each trajectory's final-frame molecule (3D coords + SMILES name) to an + SDF. Invalid molecules are skipped. Returns ``(n_written, n_total)``. + """ + from rdkit import Chem + + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + writer = Chem.SDWriter(str(path)) + n_written = 0 + try: + for traj in leads: + mol = final_mol(traj, vocab) + if mol is None: + continue + mol.SetProp("_Name", Chem.MolToSmiles(mol)) + writer.write(mol) + n_written += 1 + finally: + writer.close() + return n_written, len(leads) diff --git a/geom/explicit_h/runtime/src/morph/inference/hub.py b/geom/explicit_h/runtime/src/morph/inference/hub.py new file mode 100644 index 0000000000000000000000000000000000000000..f24ca9c20f401dfdc0ea1f303494834d3757f98c --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/inference/hub.py @@ -0,0 +1,260 @@ +"""Download and load complete Hugging Face model bundles without a dataset.""" + +# Keep downloads and CLI discovery independent of the scientific stack. +# ruff: noqa: PLC0415 + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from morph.inference.bundles import ( + ASSET_FILES, + ModelBundle, + local_bundle, + read_metadata, + select_checkpoint, +) +from morph.inference.registry import ModelSpec, bundle_path, get_model + +if TYPE_CHECKING: + from omegaconf import DictConfig + + from morph.dataset.core.vocab import Distributions, Vocab + from morph.inference.catalog import ModelCatalog + from morph.inference.module import InferenceModule + + +def download_pretrained( + name: str | ModelSpec | None = None, + *, + epoch: int | None = None, + subfolder: str | None = None, + revision: str | None = None, + filename: str | None = None, + catalog: ModelCatalog | None = None, + cache_dir: str | Path | None = None, + local_dir: str | Path | None = None, + local_files_only: bool = False, + token: str | bool | None = None, +) -> ModelBundle: + """Download a checkpoint and assets from a catalog entry or HF repository. + + Unregistered repositories use ``subfolder`` and their ``metadata.json`` to + select checkpoints. ``filename`` overrides epoch selection. A branch or tag + is resolved once so the metadata, config and weights share a commit. + Authentication uses ``hf auth login`` or ``HF_TOKEN`` unless supplied here. + """ + spec = get_model( + name, + subfolder=subfolder, + revision=revision, + catalog=catalog, + cache_dir=cache_dir, + local_files_only=local_files_only, + token=token, + ) + if spec.repo_id is None: + raise ValueError("Downloading requires a ModelSpec with repo_id") + if filename is not None: + filename = bundle_path(filename) + if epoch is not None: + raise ValueError("Choose either epoch or filename, not both") + else: + epoch = spec.select_epoch(epoch) + from huggingface_hub import snapshot_download + from huggingface_hub.errors import HfHubHTTPError, LocalEntryNotFoundError + + prefix = f"{spec.subfolder}/" if spec.subfolder else "" + download_args = dict( + repo_id=spec.repo_id, + cache_dir=cache_dir, + local_files_only=local_files_only, + token=token, + max_workers=4, + ) + try: + # HF snapshots end in the resolved commit hash, including offline reads. + # Always resolve metadata in the cache, even when exporting to local_dir. + snapshot = snapshot_download( + **download_args, + revision=spec.revision, + allow_patterns=[f"{prefix}metadata.json"], + ) + resolved_revision = Path(snapshot).name + metadata = read_metadata(Path(snapshot) / spec.subfolder, spec) + epoch, selected_file = select_checkpoint(metadata, epoch, filename) + root = snapshot_download( + **download_args, + revision=resolved_revision, + local_dir=local_dir, + allow_patterns=[ + f"{prefix}{path}" for path in (*ASSET_FILES, selected_file) + ], + ) + return local_bundle( + spec, + Path(root) / spec.subfolder, + epoch=epoch if filename is None else None, + filename=selected_file if filename is not None else None, + revision=resolved_revision, + ) + except LocalEntryNotFoundError as exc: + raise FileNotFoundError( + f"{spec.name} is not available in the local cache. " + "Run `morph models download` online with the same model, subfolder " + "and checkpoint selection first." + ) from exc + except HfHubHTTPError as exc: + status = exc.response.status_code if exc.response is not None else None + if status in {401, 403, 404}: + raise RuntimeError( + f"Cannot access {spec.repo_id}. Run `hf auth login` with an account " + "that can read the repository, or set HF_TOKEN. " + f"Requested revision: {spec.revision or 'main'}." + ) from exc + raise + + +@dataclass +class PretrainedModel: + """Loaded model, vocabulary, empirical statistics and sampling configuration.""" + + module: InferenceModule + vocab: Vocab + distributions: Distributions + config: DictConfig + bundle: ModelBundle + + def generate( + self, + n_mols: int = 16, + *, + batch_size: int | None = None, + seed: int = 42, + return_traj: bool = False, + ) -> tuple[list, list[bool], Any, float]: + """Return (trajectories, validity_flags, vocab, coordinate_std). + + Every attempted molecule is retained. Coordinates are in Angstrom. + Each trajectory contains its final frame unless ``return_traj=True``. + Sampling uses EMA weights by default and never loads a dataset. + """ + from morph.inference.generate import sample_module + + return sample_module( + self.module, + self.vocab, + self.config, + n_mols=n_mols, + batch_size=batch_size, + seed=seed, + return_traj=return_traj, + ) + + +def load_pretrained( + name: str | ModelSpec | None = None, + *, + epoch: int | None = None, + subfolder: str | None = None, + revision: str | None = None, + filename: str | None = None, + catalog: ModelCatalog | None = None, + device: str = "cpu", + weights_dir: str | Path | None = None, + checkpoint: str | Path | None = None, + cache_dir: str | Path | None = None, + local_files_only: bool = False, + token: str | bool | None = None, + steps: int = 100, + use_ema: bool = True, +) -> PretrainedModel: + """Load a catalog name, HF owner/repo ID, ModelSpec, or local bundle. + + With only ``weights_dir``, identity and available epochs come from the bundle + metadata; the default catalog model is not consulted. ``subfolder``, + ``revision`` and ``filename`` support unregistered Hub releases. + Download only the selected checkpoint unless ``weights_dir`` supplies a + local bundle (no network). ``checkpoint`` can override the weights in a local + bundle; its architecture and vocabulary must match. Construction uses the + saved portable config, not today's training defaults. Only the selected EMA + or current network is constructed and loaded, with strict key/shape checks. + No remote Python code is imported. The returned module is eager, in + evaluation mode, and on ``device``, with gradients disabled. + """ + if name is None and weights_dir is not None: + spec = ModelSpec(Path(weights_dir).expanduser().name, subfolder=subfolder or "") + else: + spec = get_model( + name, + subfolder=subfolder, + revision=revision, + catalog=catalog, + cache_dir=cache_dir, + local_files_only=local_files_only or weights_dir is not None, + token=token, + ) + if filename is not None: + filename = bundle_path(filename) + if epoch is not None or checkpoint is not None: + raise ValueError("filename cannot be combined with epoch or checkpoint") + else: + epoch = spec.select_epoch(epoch) + if device not in {"auto", "cpu", "cuda"}: + raise ValueError("device must be 'auto', 'cpu', or 'cuda'") + if steps < 1: + raise ValueError("steps must be positive") + if checkpoint is not None and weights_dir is None: + raise ValueError( + "checkpoint requires weights_dir with matching inference assets" + ) + if weights_dir is None: + bundle = download_pretrained( + spec, + epoch=epoch, + filename=filename, + cache_dir=cache_dir, + local_files_only=local_files_only, + token=token, + ) + else: + bundle = local_bundle( + spec, weights_dir, epoch=epoch, filename=filename, checkpoint=checkpoint + ) + + import torch + from omegaconf import OmegaConf + + from morph.inference.module import build_inference_module, load_inference_weights + + if device == "auto": + device = "cuda" if torch.cuda.is_available() else "cpu" + if device == "cuda" and not torch.cuda.is_available(): + raise RuntimeError("CUDA is unavailable. Use device='cpu' or a GPU worker.") + cfg = OmegaConf.load(bundle.config_path) + dataset = cfg.data.get("dataset_name") + if ( + dataset is not None + and str(dataset).casefold() != bundle.spec.dataset.casefold() + ): + raise ValueError("Saved config dataset_name does not match bundle metadata") + cfg.data.preprocessing.root = str(bundle.directory) + cfg.data.preprocessing.train_dataset = None + cfg.data.preprocessing.num_workers = 0 + for field in ("atom_tokens", "edge_tokens", "charge_tokens", "distributions"): + suffix = "pt" if field == "distributions" else "txt" + cfg.data.preprocessing[f"{field}_path"] = str( + bundle.directory / f"{field}.{suffix}" + ) + cfg.integrator.integrator.num_integration_steps = steps + cfg.model.module.use_ema_for_eval = use_ema + cfg.trainer.trainer.accelerator = "gpu" if device == "cuda" else "cpu" + cfg.trainer.trainer.devices = 1 + cfg.trainer.trainer.strategy = "auto" + cfg.logging.enabled = False + module, vocab, distributions = build_inference_module(cfg) + load_inference_weights(module.model, bundle.checkpoint, use_ema=use_ema) + module.eval().to(device) + return PretrainedModel(module, vocab, distributions, cfg, bundle) diff --git a/geom/explicit_h/runtime/src/morph/inference/module.py b/geom/explicit_h/runtime/src/morph/inference/module.py new file mode 100644 index 0000000000000000000000000000000000000000..fc60035fa4ec1a36653cf22f7936f745971a6930 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/inference/module.py @@ -0,0 +1,100 @@ +"""Prediction-only module: one network, with either EMA or current weights.""" + +from __future__ import annotations + +import os +from collections import OrderedDict +from typing import TYPE_CHECKING + +import hydra +import pytorch_lightning as pl +import torch + +from morph.model.build import prepare_model_data +from morph.model.layers.attention import fix_efficient_attention_bias_metadata +from morph.model.lightning._sampling import SamplingMixin +from morph.utils.checkpoints import canonicalize_state_dict, state_dict_for_module + +if TYPE_CHECKING: + from pathlib import Path + + from omegaconf import DictConfig + + from morph.model.build import ModelData + + +class InferenceModule(SamplingMixin, pl.LightningModule): + """Reuse the production sampler without constructing training or EMA copies.""" + + def __init__(self, cfg: DictConfig, data: ModelData): + super().__init__() + self.model = hydra.utils.instantiate(cfg.model.module.model) + self.vocab = data.vocab + self.distributions = data.prior + self.representation = data.representation + self.allow_charged = data.allow_charged + self.integrator = hydra.utils.instantiate( + cfg.model.module.integrator, distributions=data.prior + ) + self.weights = "ema" if cfg.model.module.use_ema_for_eval else "current" + self.is_compiled = False + + def _get_model(self): + return self.model + + def forward(self, *args, **kwargs): + """Forward through the selected inference network.""" + return self.model(*args, **kwargs) + + def compile(self): + """Compile the single network unless MORPH_COMPILE=0.""" + if not self.is_compiled and os.environ.get("MORPH_COMPILE", "1") != "0": + fix_efficient_attention_bias_metadata() + self.model.compile(dynamic=True) + self.is_compiled = True + + +def build_inference_module(cfg): + """Build one unconditional network from the saved config and bundle assets.""" + if cfg.model.module._target_ != "morph.model.lightning.base.LightningModuleRates": + raise ValueError( + "The pretrained inference loader supports unconditional models." + ) + preprocessing = hydra.utils.instantiate(cfg.data.preprocessing) + data = prepare_model_data(cfg, preprocessing.vocab, preprocessing.distributions) + return InferenceModule(cfg, data), data.vocab, data.empirical + + +def load_inference_weights( + model: torch.nn.Module, checkpoint_path: str | Path, *, use_ema: bool = True +) -> None: + """Strictly load just one network from a memory-mapped Lightning checkpoint. + + The unselected network and optimizer tensor storage remain on disk. After + copying the selected tensors into ``model``, local mappings are released. + Missing EMA weights are an error, never a fallback to current weights. + """ + checkpoint = torch.load( + checkpoint_path, map_location="cpu", weights_only=False, mmap=True + ) + canonical = canonicalize_state_dict(checkpoint.get("state_dict", checkpoint)) + prefix = "model_ema." if use_ema else "model." + selected = OrderedDict( + (name.removeprefix(prefix), tensor) + for name, tensor in canonical.items() + if name.startswith(prefix) + ) + if not selected: + raise RuntimeError( + f"Checkpoint has no {prefix[:-1]} weights: {checkpoint_path}" + ) + metadata = getattr(canonical, "_metadata", None) + if metadata is not None: + selected._metadata = { + ("" if name == prefix[:-1] else name.removeprefix(prefix)): value + for name, value in metadata.items() + if name == prefix[:-1] or name.startswith(prefix) + } + model.load_state_dict(state_dict_for_module(selected, model), strict=True) + model.requires_grad_(False) + model.eval() diff --git a/geom/explicit_h/runtime/src/morph/inference/registry.py b/geom/explicit_h/runtime/src/morph/inference/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..bd0de35c8a6ab6ab40fe2a99e6494c55273c0ceb --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/inference/registry.py @@ -0,0 +1,109 @@ +"""Model specifications and name resolution through a discoverable Hub catalog.""" + +from dataclasses import dataclass, replace +from pathlib import PurePosixPath +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pathlib import Path + + from morph.inference.catalog import ModelCatalog + + +def bundle_path(value: str, *, allow_empty: bool = False) -> str: + """Validate a literal relative path inside a model bundle or repository.""" + if not value and allow_empty: + return "" + path = PurePosixPath(value) + if ( + not value + or path.is_absolute() + or ".." in path.parts + or path.as_posix() == "." + or any(char in value for char in "\\*?[]") + ): + raise ValueError( + f"Expected a relative bundle path without glob patterns: {value!r}" + ) + return path.as_posix() + + +@dataclass(frozen=True) +class ModelSpec: + """Location and optional identity checks for any compatible model bundle. + + An empty ``epochs`` tuple defers selection to ``metadata.json``. Each catalog + entry owns its repository, subfolder and revision; none are dataset defaults. + ``repo_id=None`` describes local-only assets. + """ + + name: str + repo_id: str | None = None + subfolder: str = "" + revision: str | None = None + dataset: str | None = None + variant: str | None = None + epochs: tuple[int, ...] = () + + def __post_init__(self): + """Normalize the bundle path and validate optional published epochs.""" + object.__setattr__( + self, "subfolder", bundle_path(self.subfolder, allow_empty=True) + ) + if any(type(epoch) is not int or epoch < 1 for epoch in self.epochs): + raise ValueError("Catalog epochs must be positive completed-epoch integers") + if len(set(self.epochs)) != len(self.epochs): + raise ValueError("Catalog epochs must be unique") + + def select_epoch(self, epoch: int | None = None) -> int | None: + """Use the latest catalog epoch, or defer to the bundle metadata.""" + if epoch is not None and (type(epoch) is not int or epoch < 1): + raise ValueError("epoch must be a positive completed-epoch integer") + selected = max(self.epochs) if epoch is None and self.epochs else epoch + if self.epochs and selected not in self.epochs: + raise ValueError( + f"{self.name} has epochs {', '.join(map(str, self.epochs))}; " + f"requested {selected}. Epochs count completed training epochs." + ) + return selected + + +def get_model( + name: str | ModelSpec | None = None, + *, + subfolder: str | None = None, + revision: str | None = None, + catalog: "ModelCatalog | None" = None, + cache_dir: "str | Path | None" = None, + local_files_only: bool = False, + token: str | bool | None = None, +) -> ModelSpec: + """Resolve a catalog name, explicit specification, or HF ``owner/repo`` ID.""" + if isinstance(name, ModelSpec): + spec = name + elif isinstance(name, str) and name.count("/") == 1 and all(name.split("/")): + spec = ModelSpec(name, repo_id=name) + else: + # Explicit specifications and repository IDs never need catalog access. + from morph.inference.catalog import load_catalog # noqa: PLC0415 + + if catalog is None: + catalog = load_catalog( + cache_dir=cache_dir, local_files_only=local_files_only, token=token + ) + selected = catalog.default_model if name is None else name + if selected is None: + raise ValueError("This catalog has no default model; choose a model name") + try: + spec = catalog.models[selected] + except KeyError: + raise ValueError( + f"Unknown model {selected!r}. Available: {', '.join(catalog.models)}. " + "You can also pass a Hugging Face owner/repo ID or ModelSpec." + ) from None + if subfolder is not None and subfolder != spec.subfolder: + # A different bundle in the same repository has its own identity/epochs. + spec = replace(spec, subfolder=subfolder, dataset=None, variant=None, epochs=()) + if revision is not None and revision != spec.revision: + spec = replace(spec, revision=revision, epochs=()) + return spec diff --git a/geom/explicit_h/runtime/src/morph/model/README.md b/geom/explicit_h/runtime/src/morph/model/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d8f28600adbce10e359cf262e88682644cb942e8 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/README.md @@ -0,0 +1,199 @@ +# Model package + +The repository has one first-class generative architecture: the molecular DiT +in `backbones/dit.py`. Conditional applications wrap that backbone without +forking its clean denoising lane. + +## Layout + +| Area | Purpose | +| --- | --- | +| `backbones/dit.py` | Molecular DiT backbone and prediction-head assembly | +| `backbones/conditional_dit.py` | Optional node, edge, and cross-attention ports | +| `layers/attention.py` | Pair bias, geometric attention, and context-distance bias | +| `layers/heads.py` | Node, edge, insertion, deletion, and coordinate heads | +| `layers/lora.py` | Checkpoint-compatible low-rank linear adapters | +| `lightning/base.py` | The single Lightning module used by the DiT models | +| `lightning/_optimizer.py` | Muon/AdamW routing, stepping, clipping, and schedulers | +| `finetune/base/` | Shared checkpoint loading and conditional encoder components | +| `finetune/scaffold/` | Scaffold-decoration Lightning module and backbone wrapper | + +Task packages re-export their public classes. Hydra targets therefore use paths +such as +`morph.model.finetune.scaffold.ScaffoldConditionalDiTBackboneWithHeads`, not +leaf-module or legacy flat paths. + +## First-class DiT + +`DiTBackboneWithHeads` embeds atoms, edges, diffusion time, and current graph +size, applies the DiT stack, and exposes the complete rate-model output +contract. Its pair representation is directed during featurization: each +directed edge receives its own source/target node features. Symmetry is imposed +only on prediction lanes whose mathematical targets are symmetric. + +The default config is `configs/model/backbones/dit.yaml`. It uses the shared +Muon/AuxAdam optimizer recipe and the tuned multi-head loss weights. + +`model.module.model.incident_bond_embedding=true` adds the sum of each atom's +existing incident edge embeddings, excluding `NO_BOND`, through a +zero-initialized linear projection into its node embedding. It preserves bond +counts, shares the edge embedding parameters with attention and the decoder, +and uses the existing prediction heads. + +For GEOM, `data.interpolator.atom_type_noise_prob=0.01` adds training-only +categorical corruption after interpolation. Selected tokens are redrawn from +empirical alternatives, excluding their current value. The probability for +each undirected pair is scaled to match the expected number of atom changes; +clean targets, deletion-only nodes, and locked scaffold types/bonds are retained. +The default is zero. This augmentation does not change inference schedules. + +Low-rank adaptation is opt-in through `model.lora`. The +`finetune/lora_finetune` model recipe loads a pretrained unconditional model, +freezes its ordinary parameters, and trains LoRA matrices on the attention and +SwiGLU block projections with AdamW. The adapter residual is exactly zero at +initialization, and its base `weight`/`bias` state-dict names are unchanged. + +The insertion-content head is a separate, explicit Hydra choice. The default is +`insertion_head=unimodal`; use `insertion_head=gmm` for the mixture-density +alternative. Common state/edit heads live in `configs/heads/head.yaml`, while +the two insertion-head recipes live under `configs/insertion_head/`. + +## Tensor contract + +Let `B` be the number of molecules, `N` the total current atoms, `E` the +directed edges, `A`/`C`/`T` the atom/charge/bond vocabulary sizes, and `K` the +number of mixture components. Nodes and edges are contiguous by molecule. +Runtime graphs contain both directions of every non-self atom pair, including +the `` token. Output edges follow the input `edge_index` order. + +`model(mols_t, t, max_seqlen=...)` receives: + +| Input | Shape | Meaning | +| --- | --- | --- | +| `mols_t.positions` (`x`) | `[N, 3]` | Centered coordinates divided by training `coordinate_std` | +| `mols_t.atom_types` (`a`) | `[N]` | Integer atom-token indices | +| `mols_t.charge_types` (`c`) | `[N]` | Charge-token indices; retained in the container, not embedded | +| `mols_t.edge_types` (`e`) | `[E]` | Integer bond-token indices | +| `mols_t.edge_index` | `[2, E]` | Directed atom pairs; integer indices into current nodes | +| `mols_t.batch` | `[N]` | Molecule index for each node | +| `t` | `[B, 1]` | Interpolation time for each molecule | +| `max_seqlen` | Python integer | Largest current graph, resolved before a compiled forward pass | + +The output dictionary contains: + +| Key | Shape | Meaning | +| --- | --- | --- | +| `pos_head` | `[N, 3]` | Predicted clean coordinates in normalized units | +| `atom_type_head` | `[N, A]` | Clean atom-type logits | +| `charge_head` | `[N, C]` | Clean charge logits | +| `edge_type_head` | `[E, T]` | Clean bond-type logits, symmetric in endpoints | +| `do_sub_a_head` | `[N, 1]` | Logit for a pending atom substitution | +| `do_sub_e_head` | `[E, 1]` | Logit for a pending bond substitution, symmetric in endpoints | +| `do_del_head` | `[N, 1]` | Logit for a pending deletion | +| `ins_rate_head` | `[N, 1]` | Positive expected number of pending insertions per spawning node | +| `h_latent` | `[N, head_node_dim]` | Node features used by insertion-edge prediction | +| `insertion_head` | dictionary | Clean inserted-atom predictions, described below | + +Unimodal insertion outputs are `mu: [N, 3]`, `a_logits: [N, A]`, and +`c_logits: [N, C]`. Each spawning node is supervised with its nearest assigned +pending insertion. The mean is the spawning atom's position plus a learned offset. + +GMM insertion outputs are `mu: [N, K, 3]`, `sigma: [N, K]`, `pi: [N, K]`, +`a_probs: [N, K, A]`, and `c_probs: [N, K, C]`. `sigma` is an isotropic standard +deviation in normalized units; `pi`, `a_probs`, and `c_probs` are probabilities. +The mixture likelihood supervises every pending insertion assigned to a node. + +Sampling applies sigmoid to edit logits and samples categorical logits. The +integrator multiplies edit posteriors and expected insertion counts by schedule +masses. At most one insertion per spawning node is applied in a numerical step. +Inserted bonds are predicted from clean inserted features before insertion noise +is applied. Final samples and saved trajectory frames are in Angstroms. Frozen +scaffold coordinates are restored to their input frame. + +## Training targets and loss contract + +Training receives `(mols_t, mols_1, insertion_targets, t)`. The first two states +share current-node and edge ordering; `mols_1` contains their clean targets. +`InsertionTargets` retains the future atoms and two structured bond groups. +Its spawn/existing indices address `mols_t`; inserted indices address the future +atoms. `collate_insertion_targets` offsets these spaces independently without +mutating individual samples. + +Numerical `lambda_ins`, `lambda_del`, `lambda_a_sub`, and `lambda_e_sub` values +are retained on `mols_t`. Binary labels and supervision masks use their `> 0` +tests. `n_ins` is the pending insertion count used by Poisson NLL. + +Training helpers return named `LossTerm(values, graph_mask)` entries. Values have +shape `[B, ...]`; masks, when present, have shape `[B]`. Time weights are applied +per molecule before averaging. Empty supervision produces zero-valued terms with +false masks and retains autograd dependencies required by distributed training. + +| Loss | Supervision | +| --- | --- | +| `do_sub_a`, `do_sub_e`, `do_del` | Binary cross-entropy against positive lambda values | +| `sub_a_class`, `sub_e_class` | Cross-entropy on substitutions, excluding deletion targets and task-protected states | +| `ins_rate` | Poisson NLL on all current nodes, including zero insertion counts | +| `ins_state` | Unimodal position/type/charge loss or joint typed-GMM NLL | +| `ins_e`, `ins_e_ii` | Bond cross-entropy using target inserted-atom features | +| `x` | Coordinate MSE on supervised, non-deletion nodes | +| `c` | Charge cross-entropy on supervised, non-deletion nodes | + +The representation controls charge and topology loss participation. Position +and charge time weighting is always active; `use_time_weights` additionally +enables schedule weighting for edit loss groups. Component weights come from +`configs/loss_weights/`. + +## Conditional tasks + +The conditional backbone composes the clean DiT and introduces zero-gated +conditioning ports. At initialization, a conditional model is therefore +identity-equivalent to the loaded unconditional checkpoint. + +- Scaffold decoration derives node and edge conditions from `keep_mask`. It can + also encode the scaffold subgraph as cross-attention context with a geometric + distance bias. `freeze_keep_positions` controls whether kept coordinates are + fixed (and returned in their input frame) or allowed to flow; discrete and + coordinate loss exclusions remain independently configurable. + +The fine-tuning base loads unconditional keys into the conditional clean lane, +resynchronizes EMA weights, and optionally freezes pretrained modules according +to each task wrapper's `freeze_pretrained()` policy. + +`ConditionalDiTBackbone.forward` accepts these optional pre-embedded ports: + +| Port | Shape | Meaning | +| --- | --- | --- | +| `cond_tokens` | `[B, L, ctx_dim]` | External context tokens | +| `cond_token_mask` | `[B, L]` | Boolean, true for valid context tokens | +| `cond_token_attn_bias` | `[B, L, N_max]` or `[B, H, L, N_max]` | Additive token-to-atom attention bias | +| `node_cond` | `[N, d_model]` | Conditions aligned to current nodes | +| `edge_cond` | `[E, in_edge_nf]` | Conditions aligned to current directed edges | + +Here `L` is context length and `H` the number of attention heads. Each port +has its own zero-initialized tanh gate. `keep_mask` is a node-level boolean +mask carried through topology edits; newly inserted nodes are not retained atoms. + +## Config entry points + +The task-oriented Hydra groups mirror the source layout: + +```text +configs/model/backbones/dit.yaml +configs/model/finetune/scaffold_finetune.yaml +configs/data/qm9/{qm9,scaffold_decoration}.yaml +configs/data/geom/{geom,scaffold_decoration}.yaml +configs/defaults/qm9/{qm9,scaffold_decoration}.yaml +configs/defaults/geom/{geom,scaffold_decoration}.yaml +``` + +Examples: + +```bash +python run.py defaults=qm9/qm9 +python run.py defaults=geom/geom +python run.py defaults=qm9/scaffold_decoration \ + model.module.pretrained_ckpt=/path/to/unconditional.ckpt +``` + +There are intentionally no compatibility aliases for the previous flat source +modules or Hydra group names. diff --git a/geom/explicit_h/runtime/src/morph/model/__init__.py b/geom/explicit_h/runtime/src/morph/model/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1497c59eef37001653fa1f7767cac1a31c4e5ac0 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/__init__.py @@ -0,0 +1 @@ +"""Model package: backbones, reusable layers, losses, and Lightning modules.""" diff --git a/geom/explicit_h/runtime/src/morph/model/backbones/__init__.py b/geom/explicit_h/runtime/src/morph/model/backbones/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d4ba7a4c605c984ddfedbbe7028fdd62086396e --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/backbones/__init__.py @@ -0,0 +1 @@ +"""The molecular DiT backbone and its conditional extension.""" diff --git a/geom/explicit_h/runtime/src/morph/model/backbones/conditional_dit.py b/geom/explicit_h/runtime/src/morph/model/backbones/conditional_dit.py new file mode 100644 index 0000000000000000000000000000000000000000..4287090a6a5fb5f8c100023de6a9fd41784cb28b --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/backbones/conditional_dit.py @@ -0,0 +1,615 @@ +""" +Conditional DiT backbone: a :class:`DiTBackbone` with three **independent, +opt-in** conditioning ports (cross-attention / node / edge). With every +port disabled it is functionally identical to the plain ``DiTBackbone``; each +enabled port adds a zero-init gated stream. + +**Embed-outside contract.** The backbone performs *no* modality-specific +embedding. Every conditioning input arrives already at its port's fusion width +from an external encoder, and the backbone only does zero-init ``tanh``-gated +fusion (plus the intrinsic per-block attention K/V projection). A freshly loaded +unconditional DiT is therefore bit-identical to its baseline until the gates +train away from 0. Each port is skipped (free pass-through) when its kwarg is +``None``. + +* **Cross-attention conditioning** (``cond_tokens`` ``(B, K, ctx_dim)``): atoms + attend to a set of ``K`` external context tokens via gated cross-attention + (Flamingo-style) inserted before each ``DiTBlock``. The tokens are *not* tied + to any one modality or to a sequential structure — today they are per-atom + encodings of a scaffold, but they can + be any token set (e.g. language tokens). ``K`` is arbitrary and need not + relate to the atom count. Logits accept an additive + per-pair bias ``(B, K, N_padded)`` to seed prior coupling between specific + tokens and atoms. + +* **Node conditioning** (``node_cond`` ``(N, d_model)``): added to the per-atom + hidden state ``h`` through ``tanh(node_gate)`` right after ``self.dit.encode``. + 1-1 aligned with the denoised atoms (ragged batch order), so no attention. + +* **Edge conditioning** (``edge_cond`` ``(E, in_edge_nf)``): added to + ``e_embed`` through ``tanh(edge_cond_gate)``. Already 1-1 aligned with edges, + so no attention. + +Each port's parameters (the ``cross_attn_blocks`` and the node/edge gates) are +only built when its ``use_*`` flag is set, so unused parameters never enter +the graph (the trainer runs ``ddp_find_unused_parameters_false``). + +Composition (not inheritance): :class:`ConditionalDiTBackbone` *holds* a +:class:`morph.model.backbones.dit.DiTBackbone` instance as ``self.dit`` and, +when ``use_cross_attn`` is set, a parallel ``self.cross_attn_blocks`` ModuleList. +Its forward calls the DiT's :meth:`encode`/:meth:`decode` helpers and iterates +``self.dit.blocks``, interleaving cross-attention between them when enabled. To +load a pretrained DiT, use :meth:`ConditionalDiTBackbone.load_dit_state_dict`, +which forwards to ``self.dit.load_state_dict``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch_geometric.utils import to_dense_batch + +from morph.model.backbones import dit as _dit +from morph.model.backbones.dit import ( + DiTBackbone, + DiTBackboneWithHeads, + SwiGLU, + _swiglu_width, +) +from morph.model.layers.attention import compute_dense_index, pad_dense, unpad_dense +from morph.utils.graph_math import batched_triu_edge_layout + +if TYPE_CHECKING: + from omegaconf import DictConfig + + from morph.dataset.core.atomic_data import ChemBatch + +# --------------------------------------------------------------------------- +# Gated cross-attention block (Flamingo-style) +# --------------------------------------------------------------------------- + + +class GatedCrossAttention(nn.Module): + """Gated cross-attention: atoms (queries) attend to conditioning tokens. + + Both the attention and feed-forward branches are wrapped in a tanh gate + on a learnable scalar initialised to 0, so the layer is the identity at + init (Flamingo, https://arxiv.org/abs/2204.14198). + + Attention logits accept an additive per-pair bias of shape ``(B, K, N)`` + (token-then-atom indexing); ``bias[b, k, n]`` is added to the logit of + atom ``n`` attending to token ``k``. + """ + + def __init__( + self, + hidden_dim: int, + ctx_dim: int, + num_heads: int, + mlp_ratio: float = 4.0, + ): + super().__init__() + if hidden_dim % num_heads != 0: + raise ValueError( + f"hidden_dim ({hidden_dim}) must be divisible by " + f"num_heads ({num_heads})" + ) + self.num_heads = num_heads + self.head_dim = hidden_dim // num_heads + self.scale = self.head_dim**-0.5 + + self.norm_q = nn.LayerNorm(hidden_dim) + self.norm_kv = nn.LayerNorm(ctx_dim) + + self.q_proj = nn.Linear(hidden_dim, hidden_dim, bias=True) + self.kv_proj = nn.Linear(ctx_dim, 2 * hidden_dim, bias=True) + self.out_proj = nn.Linear(hidden_dim, hidden_dim, bias=True) + + self.attn_gate = nn.Parameter(torch.zeros(1)) + + mlp_hidden = _swiglu_width(hidden_dim, mlp_ratio, 64) + self.norm_ffn = nn.LayerNorm(hidden_dim) + self.ffn = SwiGLU(hidden_dim, mlp_hidden) + self.ffn_gate = nn.Parameter(torch.zeros(1)) + + self._init_weights() + + def _init_weights(self): + for module in self.modules(): + if isinstance(module, nn.Linear): + nn.init.xavier_uniform_(module.weight) + if module.bias is not None: + nn.init.constant_(module.bias, 0) + nn.init.zeros_(self.attn_gate) + nn.init.zeros_(self.ffn_gate) + + def forward( + self, + x: torch.Tensor, + ctx: torch.Tensor, + ctx_mask: torch.Tensor | None, + attn_bias: torch.Tensor | None, + query_mask: torch.Tensor | None, + ) -> torch.Tensor: + """ + Args: + x: (B, N, d) atom features (queries). + ctx: (B, K, d_ctx) conditioning tokens. + ctx_mask: (B, K) bool, True for valid tokens. ``None`` attends to all. + attn_bias: additive bias on attention logits, or ``None``. Either + (B, K, N) shared across heads or (B, H, K, N) per head. + query_mask: (B, N) bool, True for valid atoms. Used to zero out + cross-attention output at padded query positions. + + Returns: + (B, N, d) updated features. + """ + B, N, _ = x.shape + K = ctx.shape[1] + H, Dh = self.num_heads, self.head_dim + + # A padded mixed-context batch can contain K=0 examples alongside + # conditioned examples. Those rows must be an exact identity through + # BOTH branches of this adapter, matching an all-K=0 batch where the + # cross-attention port is skipped altogether. + if K == 0: + return x + has_context = None + safe_ctx_mask = ctx_mask + if ctx_mask is not None: + has_context = ctx_mask.any(dim=-1) + if bool((~has_context).any()): + # Avoid an all--inf SDPA row. Its arbitrary placeholder output is + # removed below together with the independent FFN residual. + safe_ctx_mask = ctx_mask.clone() + safe_ctx_mask[~has_context, 0] = True + + q = self.q_proj(self.norm_q(x)) + kv = self.kv_proj(self.norm_kv(ctx)) + k, v = kv.chunk(2, dim=-1) + + q = q.view(B, N, H, Dh).transpose(1, 2) # (B, H, N, Dh) + k = k.view(B, K, H, Dh).transpose(1, 2) + v = v.view(B, K, H, Dh).transpose(1, 2) + + attn_mask: torch.Tensor | None = None + if attn_bias is not None or ctx_mask is not None: + if attn_bias is not None: + # -> (B, H_or_1, N, K). A 4-D bias is already per-head; a 3-D one + # gets a singleton head axis and broadcasts inside SDPA. + attn_mask = ( + attn_bias.transpose(-1, -2) + if attn_bias.dim() == 4 + else attn_bias.transpose(1, 2).unsqueeze(1) + ).to(q.dtype) + else: + attn_mask = torch.zeros((B, 1, N, K), dtype=q.dtype, device=q.device) + if safe_ctx_mask is not None: + attn_mask = attn_mask.masked_fill( + ~safe_ctx_mask[:, None, None, :], float("-inf") + ) + + out = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask) + out = out.transpose(1, 2).contiguous().view(B, N, -1) + out = self.out_proj(out) + + if query_mask is not None: + out = out * query_mask.unsqueeze(-1).to(out.dtype) + if has_context is not None: + out = out * has_context[:, None, None].to(out.dtype) + + x = x + torch.tanh(self.attn_gate) * out + ffn_out = self.ffn(self.norm_ffn(x)) + if has_context is not None: + ffn_out = ffn_out * has_context[:, None, None].to(ffn_out.dtype) + x = x + torch.tanh(self.ffn_gate) * ffn_out + return x + + +# --------------------------------------------------------------------------- +# Backbone (composes a DiTBackbone with optional gated conditioning ports) +# --------------------------------------------------------------------------- + + +class ConditionalDiTBackbone(nn.Module): + """DiT backbone with four opt-in gated conditioning ports (§ module doc). + + Holds a :class:`DiTBackbone` (``self.dit``) plus, when ``use_cross_attn`` is + set, a parallel ``ModuleList`` of :class:`GatedCrossAttention` modules + (``self.cross_attn_blocks``). The forward delegates input projection / final + layer / heads to ``self.dit.encode`` / ``self.dit.decode``, iterates + ``self.dit.blocks``, and interleaves a gated cross-attention block before + each block when the cross-attention port is enabled and ``cond_tokens`` is + supplied. + + Loading a pretrained vanilla DiT is just:: + + model.load_dit_state_dict(dit_backbone_sd) + + which forwards to ``self.dit.load_state_dict`` — the conditioning modules + keep their zero-gated init so the augmented model behaves + identically to the loaded DiT until the gates train away from 0. + """ + + def __init__( + self, + d_input: int, + d_cond: int, + ctx_dim: int, + d_model: int = 512, + out_node_nf: int = 256, + out_edge_nf: int | None = None, + num_layers: int = 12, + nhead: int = 16, + mlp_ratio: float = 4.0, + in_edge_nf: int = 128, + rbf_embedding_args: DictConfig | None = None, + pair_bias_hidden_dim: int = 32, + pair_bias_all_layers: bool | None = None, + pair_bias_mode: str | None = None, + ffn_hidden_dim: int | None = None, + ffn_multiple_of: int = 64, + qk_norm: bool = True, + edge_pair_dim: int | None = None, + coordinate_output_init_std: float = 0.0, + center_coordinate_output: bool = True, + num_atom_types: int | None = None, + use_cross_attn: bool = False, + use_node_cond: bool = False, + use_edge_cond: bool = False, + cross_attn_mlp_ratio: float | None = None, + lora: DictConfig | dict[str, Any] | None = None, + ): + super().__init__() + self.ctx_dim = ctx_dim + self.in_edge_nf = in_edge_nf + self.d_model = d_model + # Kept so wrappers building a per-head cross-attn bias know the head count. + self.nhead = nhead + self.use_cross_attn = use_cross_attn + self.use_node_cond = use_node_cond + self.use_edge_cond = use_edge_cond + self.num_layers = num_layers + + self.dit = DiTBackbone( + d_input=d_input, + d_cond=d_cond, + d_model=d_model, + out_node_nf=out_node_nf, + out_edge_nf=out_edge_nf, + num_layers=num_layers, + nhead=nhead, + mlp_ratio=mlp_ratio, + in_edge_nf=in_edge_nf, + rbf_embedding_args=rbf_embedding_args, + pair_bias_hidden_dim=pair_bias_hidden_dim, + pair_bias_all_layers=pair_bias_all_layers, + pair_bias_mode=pair_bias_mode, + ffn_hidden_dim=ffn_hidden_dim, + ffn_multiple_of=ffn_multiple_of, + qk_norm=qk_norm, + edge_pair_dim=edge_pair_dim, + coordinate_output_init_std=coordinate_output_init_std, + center_coordinate_output=center_coordinate_output, + num_atom_types=num_atom_types, + lora=lora, + ) + + # Cross-attention (sequence / cond_tokens) port. Built only when + # enabled — the per-block blocks are the heaviest conditioning params, + # and leaving them unused would trip ddp_find_unused_parameters_false. + # Per-block cross-attention FFN dominates trainable-param count (~76% of + # a block at mlp_ratio=4). Expose its width independently of the DiT + # body's mlp_ratio (which must stay tied to the loaded pretrained + # checkpoint) so adapter capacity can be tuned cheaply. ``None`` falls + # back to ``mlp_ratio``. + if use_cross_attn: + x_attn_mlp_ratio = ( + mlp_ratio + if cross_attn_mlp_ratio is None + else float(cross_attn_mlp_ratio) + ) + self.cross_attn_blocks = nn.ModuleList( + [ + GatedCrossAttention( + d_model, ctx_dim, nhead, mlp_ratio=x_attn_mlp_ratio + ) + for _ in range(num_layers) + ] + ) + else: + self.cross_attn_blocks = None + + # Embed-outside: conditioning arrives pre-projected, so the backbone + # keeps only the zero-init fusion gate per port (built when enabled so + # unused params never trip ddp_find_unused_parameters_false). + if use_node_cond: + self.node_gate = nn.Parameter(torch.zeros(1)) + else: + self.register_parameter("node_gate", None) + + if use_edge_cond: + self.edge_cond_gate = nn.Parameter(torch.zeros(1)) + else: + self.register_parameter("edge_cond_gate", None) + + def forward( + self, + a_embed: torch.Tensor, + x: torch.Tensor, + cond: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + e_embed: torch.Tensor, + batch: torch.Tensor, + max_seqlen: int, + cond_tokens: torch.Tensor | None = None, + cond_token_mask: torch.Tensor | None = None, + cond_token_attn_bias: torch.Tensor | None = None, + node_cond: torch.Tensor | None = None, + edge_cond: torch.Tensor | None = None, + atom_types: torch.Tensor | None = None, + node_counts: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Same as :meth:`DiTBackbone.forward` plus the cross-attention / node / + edge conditioning kwargs. All conditioning arrives pre-embedded + at its port's fusion width (see the module docstring); each port is + skipped when its kwarg is ``None``. + + Args: + a_embed: (N, d_input) embedded atom features. + x: (N, 3) normalized input positions. + cond: (B, d_cond) graph conditioning features. + edge_index: Two (E,) tensors of directed source/destination indices. + e_embed: (E, in_edge_nf) embedded bond features. + batch: (N,) graph indices for packed nodes. + max_seqlen: Largest graph size, resolved before entering compilation. + atom_types: Optional (N,) atom indices for pair-type attention bias. + node_counts: Optional (B,) cached graph sizes. + cond_tokens: (B, K, ctx_dim) external context tokens the atoms + attend to. If ``None`` the gated cross-attention blocks are + skipped (cheaper and equivalent to gate=0). + cond_token_mask: (B, K) bool, True at valid tokens. + cond_token_attn_bias: additive bias on cross-attention logits, either + (B, K, N_padded) shared across heads or (B, nhead, K, N_padded) + per head. ``N_padded`` is the max number of atoms across the + current batch as produced by ``to_dense_batch``. + node_cond: (N, d_model) per-atom conditioning aligned 1-to-1 with + the denoised atoms (ragged batch order), added to ``h`` through a + zero-init gate. Requires ``use_node_cond=True``. + edge_cond: (E, in_edge_nf) per-edge conditioning features aligned + 1-to-1 with ``edge_index`` / ``e_embed``, added through a + zero-init gate. Requires ``use_edge_cond=True``. + See ``DiTBackbone.forward`` for the remaining arguments. + """ + if node_counts is None: + node_counts = torch.bincount(batch, minlength=cond.shape[0]) + h, cu_seqlens, c = self.dit.encode(a_embed, x, cond, batch, node_counts) + triu_edge_positions, full_to_triu = batched_triu_edge_layout( + edge_index, batch, node_counts + ) + + if node_cond is not None: + if self.node_gate is None: + raise ValueError( + "node_cond was provided but the backbone was constructed " + "with use_node_cond=False. Set use_node_cond=True to enable " + "per-node conditioning." + ) + if node_cond.shape != h.shape: + raise ValueError( + f"node_cond shape {tuple(node_cond.shape)} != node feature " + f"shape {tuple(h.shape)} (N, d_model). node_cond must arrive " + "pre-embedded and aligned 1-to-1 with the atoms." + ) + h = h + torch.tanh(self.node_gate) * node_cond.to(dtype=h.dtype) + + if cond_tokens is not None: + if self.cross_attn_blocks is None: + raise ValueError( + "cond_tokens was provided but the backbone was constructed " + "with use_cross_attn=False. Set use_cross_attn=True to enable " + "the cross-attention port." + ) + if ( + cond_token_attn_bias is not None + and cond_token_attn_bias.shape[-1] != max_seqlen + ): + raise ValueError( + "cond_token_attn_bias last dim " + f"{cond_token_attn_bias.shape[-1]} != padded num atoms " + f"{max_seqlen}. Bias must match the padded length produced " + "by to_dense_batch." + ) + + batch_size = cond.shape[0] + do_cross_attn = self.cross_attn_blocks is not None and cond_tokens is not None + # One cached flat<->dense scatter index for all attention layers (the + # cross-attn query padding here + the self-attn padding inside each + # DiTBlock). Reused instead of recomputing to_dense_batch per layer. + dense_slot = ( + compute_dense_index(batch, batch_size, max_seqlen, node_counts) + if _dit.USE_DENSE_INDEX + else None + ) + pair_bias, atom_mask, distance_embed = self.dit.pair_bias( + x, + edge_index, + e_embed, + batch, + batch_size, + max_seqlen, + atom_types=atom_types, + lengths=node_counts, + dense_slot=dense_slot, + triu_edge_positions=triu_edge_positions, + ) + + for i, block in enumerate(self.dit.blocks): + if do_cross_attn: + if dense_slot is not None: + h_dense = pad_dense(h, dense_slot, batch_size, max_seqlen) + else: + h_dense, _ = to_dense_batch( + h, batch, batch_size=batch_size, max_num_nodes=max_seqlen + ) + h_dense = self.cross_attn_blocks[i]( + h_dense, + cond_tokens, + cond_token_mask, + cond_token_attn_bias, + query_mask=atom_mask, + ) + h = ( + unpad_dense(h_dense, dense_slot) + if dense_slot is not None + else h_dense[atom_mask] + ) + + h = block( + h, + c, + batch, + cu_seqlens, + max_seqlen, + pair_bias=self.dit.pair_bias_for_layer(pair_bias, i), + atom_mask=atom_mask, + dense_slot=dense_slot, + ) + + if edge_cond is not None: + if self.edge_cond_gate is None: + raise ValueError( + "edge_cond was provided but the backbone was constructed " + "with use_edge_cond=False. Set use_edge_cond=True to enable " + "per-edge conditioning." + ) + if edge_cond.shape[0] != e_embed.shape[0]: + raise ValueError( + f"edge_cond first dim {edge_cond.shape[0]} != number of " + f"edges {e_embed.shape[0]}; edge_cond must be aligned to " + "edge_index / e_embed." + ) + if edge_cond.shape[-1] != self.in_edge_nf: + raise ValueError( + f"edge_cond last dim {edge_cond.shape[-1]} != backbone " + f"in_edge_nf {self.in_edge_nf}. edge_cond must arrive " + "pre-embedded." + ) + e_embed = e_embed + torch.tanh(self.edge_cond_gate) * edge_cond + + triu_edge_index = ( + edge_index[0][triu_edge_positions], + edge_index[1][triu_edge_positions], + ) + outputs = self.dit.decode( + h, + c, + batch, + x, + triu_edge_index, + e_embed[triu_edge_positions], + distance_embed, + ) + return *outputs, full_to_triu + + def load_dit_state_dict( + self, + dit_state_dict: dict[str, torch.Tensor], + strict: bool = True, + ) -> Any: + """Load a vanilla ``DiTBackbone`` state_dict into ``self.dit``. + + Cross-attention blocks are unaffected and keep their zero-gated init. + """ + return self.dit.load_state_dict(dit_state_dict, strict=strict) + + +# --------------------------------------------------------------------------- +# Top-level wrapper (extends DiTBackboneWithHeads) +# --------------------------------------------------------------------------- + + +class ConditionalDiTBackboneWithHeads(DiTBackboneWithHeads): + """:class:`DiTBackboneWithHeads` with cross-attention forward kwargs. + + Constructor is identical to the parent — set ``backbone_model_args._target_`` + to ``morph.model.backbones.conditional_dit.ConditionalDiTBackbone`` to swap + the backbone in via Hydra. The forward simply forwards the new kwargs into + ``self.backbone``. + """ + + def freeze_pretrained(self) -> int: + """Freeze every submodule whose weights load from the unconditional + pretrained checkpoint. + + On a freshly-loaded checkpoint this leaves trainable only the + modules introduced by ``ConditionalDiTBackbone`` itself: the gated + cross-attention blocks and, when present, the per-node and per-edge + conditioning streams. Subclasses can extend the freeze policy to their + own auxiliary modules. + """ + before = sum(p.numel() for p in self.parameters() if p.requires_grad) + # Clean-lane modules loaded from the pretrained checkpoint: + self.embedding_backbone.requires_grad_(False) + self.backbone.dit.requires_grad_(False) + # LoRA is part of the clean lane structurally, but it is the trainable + # fine-tuning delta rather than a pretrained parameter. + self.backbone.dit.set_lora_trainable() + self.heads.requires_grad_(False) + self.insertion_head.requires_grad_(False) + self.ins_edge_head.requires_grad_(False) + after = sum(p.numel() for p in self.parameters() if p.requires_grad) + return before - after + + def forward( + self, + mols_t: ChemBatch, + t: torch.Tensor, + cond_tokens: torch.Tensor | None = None, + cond_token_mask: torch.Tensor | None = None, + cond_token_attn_bias: torch.Tensor | None = None, + node_cond: torch.Tensor | None = None, + edge_cond: torch.Tensor | None = None, + max_seqlen: int | None = None, + ) -> dict[str, Any]: + """Predict clean states and edits using optional context tokens.""" + x, a, _c, _e, edge_index, batch = mols_t.unpack() + # Eager callers (sampling) leave max_seqlen None; the compiled training + # path passes it precomputed to keep the graph host-read-free. + if max_seqlen is None: + max_seqlen = mols_t.max_seqlen + + a_embed, e_embed, cond = self.embedding_backbone(mols_t, t) + + edge_index_tuple = (edge_index[0], edge_index[1]) + + h, x_out, e_out_triu, full_to_triu = self.backbone( + a_embed, + x, + cond, + edge_index_tuple, + e_embed, + batch, + max_seqlen, + cond_tokens=cond_tokens, + cond_token_mask=cond_token_mask, + cond_token_attn_bias=cond_token_attn_bias, + node_cond=node_cond, + edge_cond=edge_cond, + atom_types=a, + node_counts=mols_t.num_nodes_per_graph, + ) + + return self._assemble_outputs( + h, + x_out, + e_out_triu, + full_to_triu, + x, + batch, + edge_index_tuple, + ) diff --git a/geom/explicit_h/runtime/src/morph/model/backbones/dit.py b/geom/explicit_h/runtime/src/morph/model/backbones/dit.py new file mode 100644 index 0000000000000000000000000000000000000000..745fc58d6ce78ab0260d7d6162671592faa04b14 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/backbones/dit.py @@ -0,0 +1,824 @@ +"""The molecular Diffusion Transformer. + +This is the repository's sole first-class generative backbone. It combines +adaLN-Zero conditioning, equal-budget SwiGLU feed-forwards, QK normalization, +pair-biased attention, exactly symmetric undirected-edge decoding, +and residual graph-centred coordinate prediction. +""" + +from __future__ import annotations + +import math +import os +from typing import TYPE_CHECKING, Any + +import hydra +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch_geometric.utils import scatter + +from morph.model.layers import lora as lora_layers +from morph.model.layers.attention import ( + PairBias, + compute_dense_index, + packed_self_attention, +) +from morph.model.layers.heads import InsertionEdgePredictorMixin +from morph.model.layers.kernels import graph_broadcast, normalized_edge_features +from morph.model.layers.lora import LoRALinear +from morph.utils.graph_math import batched_triu_edge_layout + +if TYPE_CHECKING: + from omegaconf import DictConfig + + from morph.dataset.core.atomic_data import ChemBatch + +USE_DENSE_INDEX = os.environ.get("MORPH_DENSE_INDEX", "1") == "1" + + +def modulate( + x: torch.Tensor, + shift: torch.Tensor, + scale: torch.Tensor, + batch_idx: torch.Tensor, + offsets: torch.Tensor | None = None, +) -> torch.Tensor: + """Apply graph-level adaptive modulation to packed node features.""" + return x * (1 + graph_broadcast(scale, batch_idx, offsets)) + graph_broadcast( + shift, batch_idx, offsets + ) + + +class FinalLayer(nn.Module): + """Final adaLN-conditioned projection in packed layout.""" + + def __init__(self, hidden_dim: int, out_dim: int): + super().__init__() + self.norm_final = nn.LayerNorm(hidden_dim, elementwise_affine=False, eps=1e-6) + self.linear = nn.Linear(hidden_dim, out_dim) + self.adaLN_modulation = nn.Sequential( + nn.SiLU(), nn.Linear(hidden_dim, 2 * hidden_dim) + ) + + def forward( + self, x: torch.Tensor, c: torch.Tensor, batch_idx: torch.Tensor + ) -> torch.Tensor: + """Modulate normalized node features with graph-level conditioning.""" + shift, scale = self.adaLN_modulation(c).chunk(2, dim=1) + return self.linear(modulate(self.norm_final(x), shift, scale, batch_idx)) + + +def _swiglu_width(hidden_dim: int, mlp_ratio: float, multiple_of: int) -> int: + """Match the parameter budget of a GELU FFN with the given ratio.""" + if mlp_ratio <= 0: + raise ValueError(f"mlp_ratio must be positive, got {mlp_ratio}") + if multiple_of <= 0: + raise ValueError(f"multiple_of must be positive, got {multiple_of}") + target = 2.0 * hidden_dim * mlp_ratio / 3.0 + return max(multiple_of, round(target / multiple_of) * multiple_of) + + +class SwiGLU(nn.Module): + """Fused SwiGLU feed-forward network.""" + + def __init__(self, in_features: int, hidden_features: int): + super().__init__() + self.in_features = in_features + self.hidden_features = hidden_features + self.gate_value = nn.Linear(in_features, 2 * hidden_features) + self.out_proj = nn.Linear(hidden_features, in_features) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Apply a SiLU-gated feed-forward projection.""" + gate, value = self.gate_value(x).chunk(2, dim=-1) + return self.out_proj(F.silu(gate) * value) + + +class DiTBlock(nn.Module): + """adaLN-Zero block with QK normalization and equal-budget SwiGLU.""" + + def __init__( + self, + hidden_dim: int, + num_heads: int, + mlp_ratio: float = 4.0, + ffn_hidden_dim: int | None = None, + ffn_multiple_of: int = 64, + qk_norm: bool = True, + ): + super().__init__() + if hidden_dim % num_heads != 0: + raise ValueError( + f"hidden_dim ({hidden_dim}) must be divisible by " + f"num_heads ({num_heads})" + ) + self.hidden_dim = hidden_dim + self.num_heads = num_heads + self.head_dim = hidden_dim // num_heads + + self.norm1 = nn.LayerNorm(hidden_dim, elementwise_affine=False, eps=1e-6) + self.qkv = nn.Linear(hidden_dim, 3 * hidden_dim) + self.q_norm = nn.RMSNorm(self.head_dim, eps=1e-6) if qk_norm else nn.Identity() + self.k_norm = nn.RMSNorm(self.head_dim, eps=1e-6) if qk_norm else nn.Identity() + self.proj = nn.Linear(hidden_dim, hidden_dim) + + self.norm2 = nn.LayerNorm(hidden_dim, elementwise_affine=False, eps=1e-6) + if ffn_hidden_dim is None: + ffn_hidden_dim = _swiglu_width(hidden_dim, mlp_ratio, ffn_multiple_of) + if ffn_hidden_dim <= 0: + raise ValueError(f"ffn_hidden_dim must be positive, got {ffn_hidden_dim}") + self.mlp = SwiGLU(hidden_dim, ffn_hidden_dim) + self.adaLN_modulation = nn.Sequential( + nn.SiLU(), nn.Linear(hidden_dim, 6 * hidden_dim) + ) + + def forward( + self, + x: torch.Tensor, + c: torch.Tensor, + batch_idx: torch.Tensor, + cu_seqlens: torch.Tensor, + max_seqlen: int, + pair_bias: torch.Tensor | None = None, + atom_mask: torch.Tensor | None = None, + dense_slot: torch.Tensor | None = None, + ) -> torch.Tensor: + """Apply conditioned packed attention and gated feed-forward updates.""" + shift_msa, scale_msa, gate_msa, shift_mlp, scale_mlp, gate_mlp = ( + self.adaLN_modulation(c).chunk(6, dim=1) + ) + + x_mod = modulate(self.norm1(x), shift_msa, scale_msa, batch_idx, cu_seqlens) + qkv = self.qkv(x_mod).view(-1, 3, self.num_heads, self.head_dim) + attn_out = packed_self_attention( + qkv, + cu_seqlens, + max_seqlen, + batch_idx, + pair_bias=pair_bias, + atom_mask=atom_mask, + dense_slot=dense_slot, + q_norm=self.q_norm, + k_norm=self.k_norm, + ) + attn_out = self.proj(attn_out.reshape(-1, self.hidden_dim)) + + x = x + graph_broadcast(gate_msa, batch_idx, cu_seqlens) * attn_out + x = x + graph_broadcast(gate_mlp, batch_idx, cu_seqlens) * self.mlp( + modulate(self.norm2(x), shift_mlp, scale_mlp, batch_idx, cu_seqlens) + ) + return x + + +class SymmetricEdgeDecoder(nn.Module): + """Decode undirected edges from a compact DeepSets-style pair feature.""" + + def __init__( + self, + node_dim: int, + edge_dim: int, + distance_dim: int, + output_dim: int, + pair_dim: int, + ): + super().__init__() + self.node_proj = nn.Sequential( + nn.LayerNorm(node_dim), + nn.Linear(node_dim, pair_dim), + nn.SiLU(), + ) + input_dim = 2 * pair_dim + edge_dim + distance_dim + self.output = nn.Sequential( + nn.LayerNorm(input_dim), + nn.Linear(input_dim, output_dim), + nn.LayerNorm(output_dim), + nn.GELU(), + ) + + def forward( + self, + h: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + edge_embed: torch.Tensor, + distance_embed: torch.Tensor, + ) -> torch.Tensor: + """Decode symmetric edge features from nodes, bonds, and distances.""" + rows, cols = edge_index + p = self.node_proj(h) + normalized = normalized_edge_features( + p, edge_index, edge_embed, distance_embed, self.output[0] + ) + if normalized is not None: + return self.output[3](self.output[2](self.output[1](normalized))) + p_i, p_j = p[rows], p[cols] + scale = math.sqrt(0.5) + pair_sum = scale * (p_i + p_j) + pair_difference = scale * (p_i - p_j).abs() + del p_i, p_j + return self.output( + torch.cat([pair_sum, pair_difference, edge_embed, distance_embed], dim=-1) + ) + + +class ResidualCoordinateReadout(nn.Module): + """Predict residual clean coordinates and optionally project out graph COM.""" + + def __init__( + self, + input_dim: int, + hidden_dim: int, + output_init_std: float = 0.0, + center_output: bool = True, + ): + super().__init__() + if output_init_std < 0: + raise ValueError( + f"output_init_std must be non-negative, got {output_init_std}" + ) + self.center_output = center_output + self.norm = nn.RMSNorm(input_dim, eps=1e-6) + self.hidden = nn.Linear(input_dim, hidden_dim) + self.output = nn.Linear(hidden_dim, 3) + nn.init.xavier_uniform_(self.hidden.weight) + nn.init.zeros_(self.hidden.bias) + if output_init_std == 0: + nn.init.zeros_(self.output.weight) + else: + nn.init.normal_(self.output.weight, mean=0.0, std=output_init_std) + nn.init.zeros_(self.output.bias) + + def forward( + self, + h: torch.Tensor, + x_t: torch.Tensor, + batch: torch.Tensor, + num_graphs: int, + ) -> torch.Tensor: + """Predict clean coordinates with optional graphwise centering.""" + delta = self.output(F.silu(self.hidden(self.norm(h)))) + x_1 = x_t + delta + if not self.center_output: + return x_1 + if num_graphs == 1: + # Inductor 2.9 can miscompile scatter-mean with one output graph. + return x_1 - x_1.mean(dim=0, keepdim=True) + center = scatter(x_1, batch, dim=0, dim_size=num_graphs, reduce="mean") + return x_1 - center[batch] + + +class DiTBackbone(nn.Module): + """Scalar DiT trunk with learned geometry scaling in every layer/head.""" + + LORA_TARGETS = ("qkv", "proj", "mlp.gate_value", "mlp.out_proj") + + def __init__( + self, + d_input: int, + d_cond: int, + d_model: int = 512, + out_node_nf: int = 256, + out_edge_nf: int | None = None, + num_layers: int = 12, + nhead: int = 16, + mlp_ratio: float = 4.0, + in_edge_nf: int = 128, + rbf_embedding_args: DictConfig | None = None, + pair_bias_hidden_dim: int = 32, + pair_bias_all_layers: bool | None = None, + pair_bias_mode: str | None = None, + ffn_hidden_dim: int | None = None, + ffn_multiple_of: int = 64, + qk_norm: bool = True, + edge_pair_dim: int | None = None, + coordinate_output_init_std: float = 0.0, + center_coordinate_output: bool = True, + num_atom_types: int | None = None, + lora: DictConfig | dict[str, Any] | None = None, + ): + super().__init__() + if num_layers <= 0: + raise ValueError(f"num_layers must be positive, got {num_layers}") + if rbf_embedding_args is None: + raise ValueError("rbf_embedding_args is required") + + self.d_model = d_model + self.out_node_nf = out_node_nf + self.out_edge_nf = out_node_nf if out_edge_nf is None else out_edge_nf + # These two arguments only remain for saved checkpoint configurations. + # New presets omit both and always use scaling in every layer. Older + # shared-bias configs supplied all_layers but predate the mode field. + if pair_bias_mode is None: + pair_bias_mode = "scale" if pair_bias_all_layers is None else "shared" + self.pair_bias_all_layers = ( + True if pair_bias_all_layers is None else bool(pair_bias_all_layers) + ) + if pair_bias_mode not in {"shared", "scale"}: + raise ValueError( + f"Pair-bias sweep mode {pair_bias_mode!r} has been retired; " + "use its archived training source to load that checkpoint" + ) + if pair_bias_mode != "shared" and not self.pair_bias_all_layers: + raise ValueError( + "Layer-specific pair bias requires pair_bias_all_layers=True" + ) + self.pair_bias_mode = pair_bias_mode + if self.out_edge_nf <= 0: + raise ValueError(f"out_edge_nf must be positive, got {self.out_edge_nf}") + self.input_proj = nn.Sequential( + nn.Linear(d_input, d_model), + nn.SiLU(), + nn.Linear(d_model, d_model), + ) + self.pos_embedding = nn.Sequential( + nn.Linear(3, d_model, bias=False), + nn.SiLU(), + nn.Linear(d_model, d_model), + ) + self.cond_proj = nn.Sequential( + nn.Linear(d_cond, d_model), + nn.SiLU(), + nn.Linear(d_model, d_model), + ) + self.blocks = nn.ModuleList( + [ + DiTBlock( + d_model, + nhead, + mlp_ratio=mlp_ratio, + ffn_hidden_dim=ffn_hidden_dim, + ffn_multiple_of=ffn_multiple_of, + qk_norm=qk_norm, + ) + for _ in range(num_layers) + ] + ) + + self.final_layer = FinalLayer(d_model, d_model) + self.node_out = ( + nn.Identity() + if out_node_nf == d_model + else nn.Sequential( + nn.Linear(d_model, out_node_nf), + nn.SiLU(), + nn.Linear(out_node_nf, out_node_nf), + ) + ) + self.rbf_embedding = hydra.utils.instantiate(rbf_embedding_args) + self._rbf_has_dropout = any( + isinstance(module, nn.Dropout) and module.p > 0 + for module in self.rbf_embedding.modules() + ) + rbf_out_dim = rbf_embedding_args.get( + "out_dim", rbf_embedding_args.get("num_rbf", 16) + ) + self.pair_bias = PairBias( + rbf_embedding=self.rbf_embedding, + rbf_out_dim=rbf_out_dim, + edge_nf=in_edge_nf, + num_heads=nhead, + hidden_dim=pair_bias_hidden_dim, + num_atom_types=num_atom_types, + ) + if edge_pair_dim is None: + edge_pair_dim = min(out_node_nf, 256) + if edge_pair_dim <= 0: + raise ValueError(f"edge_pair_dim must be positive, got {edge_pair_dim}") + self.edge_output = SymmetricEdgeDecoder( + node_dim=out_node_nf, + edge_dim=in_edge_nf, + distance_dim=rbf_out_dim, + output_dim=self.out_edge_nf, + pair_dim=edge_pair_dim, + ) + + self._initialize_weights() + # FinalLayer produces a shared latent, not the task prediction itself. + nn.init.xavier_uniform_(self.final_layer.linear.weight) + nn.init.zeros_(self.final_layer.linear.bias) + # Build this after the generic initialization so its deliberately zero + # task projection is preserved at initialization. + self.pos_out = ResidualCoordinateReadout( + input_dim=out_node_nf, + hidden_dim=d_model, + output_init_std=coordinate_output_init_std, + center_output=center_coordinate_output, + ) + # Keep parameter names and initialization compatible with the selected + # scaling checkpoint and preserve the common model's RNG state. + if pair_bias_mode == "scale": + for block in self.blocks: + block.geometry_scale = nn.Parameter(torch.ones(nhead)) + if lora is not None and bool(lora.get("enabled", False)): + rank = int(lora.get("rank", 8)) + self.enable_lora( + rank=rank, + alpha=float(lora.get("alpha", rank)), + dropout=float(lora.get("dropout", 0.0)), + targets=lora.get("targets", self.LORA_TARGETS), + freeze_base=bool(lora.get("freeze_base", True)), + ) + + def pair_bias_for_layer(self, pair_bias, index): + """Select a layer's bias while retaining shared geometry computation.""" + if not self.pair_bias_all_layers and index > 0: + return None + block = self.blocks[index] + if self.pair_bias_mode == "scale": + # Multiplying a padded -inf by a learnable factor gives NaN factor + # gradients; scale finite entries and restore padding afterwards. + valid = torch.isfinite(pair_bias) + scaled = pair_bias.masked_fill(~valid, 0) * block.geometry_scale.to( + pair_bias.dtype + ).view(1, -1, 1, 1) + return scaled.masked_fill(~valid, -torch.inf) + return pair_bias + + def enable_lora( + self, + *, + rank: int = 8, + alpha: float | None = None, + dropout: float = 0.0, + targets: tuple[str, ...] | list[str] = LORA_TARGETS, + freeze_base: bool = True, + ) -> int: + """Add LoRA to selected block projections. + + Targets are paths relative to every :class:`DiTBlock`. With + ``freeze_base=True``, only the low-rank matrices remain trainable inside + this backbone. Returns their total parameter count. + """ + if any(lora_layers.iter_lora_layers(self)): + raise RuntimeError("LoRA is already enabled on this DiT backbone") + if isinstance(targets, str): + targets = (targets,) + targets = tuple(targets) + if not targets: + raise ValueError("At least one LoRA target is required") + if len(set(targets)) != len(targets): + raise ValueError(f"LoRA targets must be unique, got {targets}") + unknown = sorted(set(targets) - set(self.LORA_TARGETS)) + if unknown: + raise ValueError( + f"Unknown LoRA targets {unknown}; choose from {self.LORA_TARGETS}" + ) + alpha = float(rank if alpha is None else alpha) + if rank <= 0: + raise ValueError(f"LoRA rank must be positive, got {rank}") + if alpha <= 0: + raise ValueError(f"LoRA alpha must be positive, got {alpha}") + if not 0.0 <= dropout < 1.0: + raise ValueError(f"LoRA dropout must be in [0, 1), got {dropout}") + if freeze_base: + self.requires_grad_(False) + + for block in self.blocks: + modules = { + "qkv": (block, "qkv"), + "proj": (block, "proj"), + "mlp.gate_value": (block.mlp, "gate_value"), + "mlp.out_proj": (block.mlp, "out_proj"), + } + for target in targets: + parent, name = modules[target] + linear = getattr(parent, name) + setattr( + parent, + name, + LoRALinear.from_linear( + linear, + rank=rank, + alpha=alpha, + dropout=dropout, + freeze_base=freeze_base, + ), + ) + return lora_layers.set_lora_trainable(self) + + @property + def has_lora(self) -> bool: + """Whether this backbone contains low-rank adapters.""" + return any(lora_layers.iter_lora_layers(self)) + + def set_lora_trainable(self, trainable: bool = True) -> int: + """Toggle gradients for every adapter and return its parameter count.""" + return lora_layers.set_lora_trainable(self, trainable) + + def merge_lora_(self) -> DiTBackbone: + """Fold all adapters into their base projections for inference.""" + lora_layers.merge_lora_(self) + return self + + def unmerge_lora_(self) -> DiTBackbone: + """Restore the separate adapter projections used for training.""" + lora_layers.unmerge_lora_(self) + return self + + def train(self, mode: bool = True) -> DiTBackbone: + """Set training mode, unmerging adapters before any optimization.""" + # Never optimize a base weight that already contains its adapter delta. + if mode: + self.unmerge_lora_() + return super().train(mode) + + def _initialize_weights(self) -> None: + def _basic_init(module: nn.Module) -> None: + if isinstance(module, nn.Linear): + nn.init.xavier_uniform_(module.weight) + if module.bias is not None: + nn.init.zeros_(module.bias) + + self.apply(_basic_init) + for block in self.blocks: + nn.init.zeros_(block.adaLN_modulation[-1].weight) + nn.init.zeros_(block.adaLN_modulation[-1].bias) + + nn.init.zeros_(self.final_layer.adaLN_modulation[-1].weight) + nn.init.zeros_(self.final_layer.adaLN_modulation[-1].bias) + nn.init.zeros_(self.final_layer.linear.weight) + nn.init.zeros_(self.final_layer.linear.bias) + + # Keep PairBias non-trivial once adaLN-Zero opens the attention residual. + nn.init.xavier_uniform_(self.pair_bias.down_proj.weight) + + def encode( + self, + a_embed: torch.Tensor, + x: torch.Tensor, + cond: torch.Tensor, + batch: torch.Tensor, + node_counts: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Project packed node inputs and graph conditioning.""" + batch_size = cond.shape[0] + h = self.input_proj(a_embed) + self.pos_embedding(x) + c = self.cond_proj(cond) + if node_counts is None: + node_counts = torch.bincount(batch, minlength=batch_size) + cu_seqlens = F.pad(node_counts.cumsum(dim=0), (1, 0)).to(torch.int32) + return h, cu_seqlens, c + + def decode( + self, + h: torch.Tensor, + c: torch.Tensor, + batch_idx: torch.Tensor, + x: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + e_embed: torch.Tensor, + distance_embed: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Apply the node, position, and undirected-edge output projections.""" + h = self.node_out(self.final_layer(h, c, batch_idx)) + # Preserve independent dropout draws for custom stochastic RBF configs. + # Shipped configs use dropout=0 and take the one-pass reuse path. + if self.training and self._rbf_has_dropout: + rows, cols = edge_index + distance_embed = self.rbf_embedding((x[rows] - x[cols]).norm(dim=1)) + e_out = self.edge_output(h, edge_index, e_embed, distance_embed) + return h, self.pos_out(h, x, batch_idx, c.shape[0]), e_out + + def forward( + self, + a_embed: torch.Tensor, + x: torch.Tensor, + cond: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + e_embed: torch.Tensor, + batch: torch.Tensor, + max_seqlen: int, + atom_types: torch.Tensor | None = None, + node_counts: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Run the packed DiT and return node, position, and triu edge features.""" + if node_counts is None: + node_counts = torch.bincount(batch, minlength=cond.shape[0]) + h, cu_seqlens, c = self.encode(a_embed, x, cond, batch, node_counts) + batch_size = cond.shape[0] + triu_edge_positions, full_to_triu = batched_triu_edge_layout( + edge_index, batch, node_counts + ) + dense_slot = ( + compute_dense_index(batch, batch_size, max_seqlen, node_counts) + if USE_DENSE_INDEX + else None + ) + pair_bias, atom_mask, distance_embed = self.pair_bias( + x, + edge_index, + e_embed, + batch, + batch_size, + max_seqlen, + atom_types=atom_types, + lengths=node_counts, + dense_slot=dense_slot, + triu_edge_positions=triu_edge_positions, + ) + + for idx, block in enumerate(self.blocks): + h = block( + h, + c, + batch, + cu_seqlens, + max_seqlen, + pair_bias=self.pair_bias_for_layer(pair_bias, idx), + atom_mask=atom_mask, + dense_slot=dense_slot, + ) + + triu_edge_index = ( + edge_index[0][triu_edge_positions], + edge_index[1][triu_edge_positions], + ) + outputs = self.decode( + h, + c, + batch, + x, + triu_edge_index, + e_embed[triu_edge_positions], + distance_embed, + ) + return *outputs, full_to_triu + + +# --------------------------------------------------------------------------- +# Embedding and complete model +# --------------------------------------------------------------------------- + + +class DiTEmbedding(nn.Module): + """Embedding module for DiT. + + Produces atom embeddings, edge embeddings, and a *graph-level* conditioning + vector (time + node count). + + Conditioning remains graph-level so ``DiTBackbone`` can feed it through + adaLN without duplicating it across packed nodes. + """ + + def __init__( + self, + atom_type_embedding_args: DictConfig, + edge_type_embedding_args: DictConfig, + time_embedding_args: DictConfig, + node_count_embedding_args: DictConfig, + incident_bond_embedding: bool = False, + ): + super().__init__() + self.atom_type_embedding = hydra.utils.instantiate(atom_type_embedding_args) + self.edge_type_embedding = hydra.utils.instantiate(edge_type_embedding_args) + self.time_embedding = hydra.utils.instantiate(time_embedding_args) + self.node_count_embedding = hydra.utils.instantiate(node_count_embedding_args) + self.incident_bond_projection = None + if incident_bond_embedding: + if int(edge_type_embedding_args.num_embeddings) < 2: + raise ValueError("Incident-bond embeddings require bond tokens") + atom_dim = ( + atom_type_embedding_args.get("out_dim") + or atom_type_embedding_args.embedding_dim + ) + edge_dim = ( + edge_type_embedding_args.get("out_dim") + or edge_type_embedding_args.embedding_dim + ) + self.incident_bond_projection = nn.Linear( + int(edge_dim), int(atom_dim), bias=False + ) + nn.init.zeros_(self.incident_bond_projection.weight) + + def forward( + self, + mols: ChemBatch, + t: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Returns: + a_embed: (N, atom_dim) atom embeddings (per-node) + e_embed: (E, edge_dim) edge embeddings (per-edge) + cond: (B, cond_dim) graph-level conditioning vector + """ + a, e = mols.a, mols.e + + a_embed = self.atom_type_embedding(a) + e_embed = self.edge_type_embedding(e) + if self.incident_bond_projection is not None: + # Reuse the same edge embeddings supplied to attention and the + # pair decoder. NO_BOND contributes nothing to the incident sum. + incident = e_embed.new_zeros((a.shape[0], e_embed.shape[-1])) + bonded_embeddings = e_embed * (e != 0).unsqueeze(-1) + # Edges are directed and symmetric: summing each row counts every + # neighbour once at its receiving node. Keep sums, not averages. + incident.index_add_(0, mols.edge_index[0], bonded_embeddings) + a_embed = a_embed + self.incident_bond_projection(incident) + + t_embed = self.time_embedding(t) + n_embed = self.node_count_embedding(mols.num_nodes_per_graph) + + cond = torch.cat([t_embed, n_embed], dim=-1) + + return a_embed, e_embed, cond + + +# --------------------------------------------------------------------------- +# Complete model +# --------------------------------------------------------------------------- + + +class DiTBackboneWithHeads(InsertionEdgePredictorMixin, nn.Module): + """Full DiT Model: Embedding -> DiT Backbone -> Prediction Heads. + + This is the public model assembled by ``model=backbones/dit``. + """ + + def __init__( + self, + atom_type_embedding_args: DictConfig, + edge_type_embedding_args: DictConfig, + time_embedding_args: DictConfig, + node_count_embedding_args: DictConfig, + backbone_model_args: DictConfig, + heads_args: DictConfig, + insertion_head_args: DictConfig, + ins_edge_head_args: DictConfig, + incident_bond_embedding: bool = False, + ): + super().__init__() + + self.embedding_backbone = DiTEmbedding( + atom_type_embedding_args=atom_type_embedding_args, + edge_type_embedding_args=edge_type_embedding_args, + time_embedding_args=time_embedding_args, + node_count_embedding_args=node_count_embedding_args, + incident_bond_embedding=incident_bond_embedding, + ) + + self.backbone = hydra.utils.instantiate(backbone_model_args) + self.heads = hydra.utils.instantiate(heads_args) + self.insertion_head = hydra.utils.instantiate(insertion_head_args) + self.ins_edge_head = hydra.utils.instantiate(ins_edge_head_args) + + def freeze_pretrained(self) -> int: + """Freeze the loaded model while leaving DiT LoRA matrices trainable.""" + before = sum(p.numel() for p in self.parameters() if p.requires_grad) + self.requires_grad_(False) + self.backbone.set_lora_trainable() + after = sum(p.numel() for p in self.parameters() if p.requires_grad) + return before - after + + def initialize_output_priors(self, distributions) -> None: + """Initialize categorical outputs from training-set marginal priors.""" + self.heads.initialize_class_priors( + atom=distributions.atom_type_distribution, + charge=distributions.charge_type_distribution, + edge=distributions.edge_type_distribution, + ) + self.insertion_head.initialize_class_priors( + atom=distributions.atom_type_distribution, + charge=distributions.charge_type_distribution, + ) + self.ins_edge_head.initialize_edge_prior(distributions.edge_type_distribution) + + def forward( + self, + mols_t: ChemBatch, + t: torch.Tensor, + max_seqlen: int | None = None, + ) -> dict[str, Any]: + """Forward pass through Embedding -> DiT Backbone -> Heads.""" + x, a, _c, _e, edge_index, batch = mols_t.unpack() + # max_seqlen must be a precomputed Python int on the compiled (training) + # path: computing it here reads segment_lengths -> .item() + # (aten._local_scalar_dense), which fails torch.compile's backend and drops + # the whole DiT to eager. Eager callers (sampling) leave it None. + if max_seqlen is None: + max_seqlen = mols_t.max_seqlen + + a_embed, e_embed, cond = self.embedding_backbone(mols_t, t) + + edge_index_tuple = (edge_index[0], edge_index[1]) + + h, x_out, e_out_triu, full_to_triu = self.backbone( + a_embed, + x, + cond, + edge_index_tuple, + e_embed, + batch, + max_seqlen, + atom_types=a, + node_counts=mols_t.num_nodes_per_graph, + ) + + return self._assemble_outputs( + h, + x_out, + e_out_triu, + full_to_triu, + x, + batch, + edge_index_tuple, + ) diff --git a/geom/explicit_h/runtime/src/morph/model/build.py b/geom/explicit_h/runtime/src/morph/model/build.py new file mode 100644 index 0000000000000000000000000000000000000000..fa287a60f9118cae9ab03cfeddf44d5f6dfd1229 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/build.py @@ -0,0 +1,92 @@ +"""Shared model construction for training and dataset-free inference.""" + +from copy import deepcopy +from dataclasses import dataclass + +import hydra +from omegaconf import DictConfig, OmegaConf + +from morph.dataset.core.representation import Representation, make_uniform_type_prior +from morph.dataset.core.vocab import Distributions, Vocab, setup_token_weights +from morph.utils.metrics import init_metrics + + +@dataclass(frozen=True) +class ModelData: + """Vocabulary, empirical statistics, and the representation-aware flow prior.""" + + vocab: Vocab + empirical: Distributions + prior: Distributions + representation: Representation + allow_charged: bool + + +def prepare_model_data( + cfg: DictConfig, vocab: Vocab, distributions: Distributions +) -> ModelData: + """Resolve vocabulary config and separate empirical statistics from the prior.""" + OmegaConf.set_struct(cfg, False) + cfg.data.vocab = vocab + representation = Representation(cfg.representation) + return ModelData( + vocab=vocab, + empirical=deepcopy(distributions), + prior=make_uniform_type_prior(distributions, vocab, representation), + representation=representation, + allow_charged=bool(cfg.data.get("allow_charged", False)), + ) + + +def build_model_metrics( + data: ModelData, *, train_smiles=None, train_smiles_are_canonical=False +) -> dict: + """Build matching metric buffers for training and checkpoint loading. + + Dataset-free callers omit training SMILES; novelty has no persistent buffers. + Distribution and geometry buffers must still match the training checkpoint. + """ + distributions, vocab = data.empirical, data.vocab + collections = init_metrics( + train_smiles=train_smiles, + target_n_atoms_distribution=distributions.n_atoms_distribution, + atom_type_distribution=distributions.atom_type_distribution, + edge_type_distribution=distributions.edge_type_distribution, + charge_type_distribution=distributions.charge_type_distribution, + atom_tokens=list(vocab.atom_tokens), + edge_tokens=list(vocab.edge_tokens), + charge_tokens=list(vocab.charge_tokens), + allow_charged=data.allow_charged, + distributions=distributions, + representation=data.representation, + train_smiles_are_canonical=train_smiles_are_canonical, + ) + names = ("metrics", "stability_metrics", "distribution_metrics", "batch_metrics") + return dict(zip(names, collections, strict=True)) + + +def build_model_module( + cfg: DictConfig, data: ModelData, *, metrics: dict | None = None +): + """Construct the configured Lightning module from explicit data and metrics.""" + weighting = cfg.model.token_weighting + atom_weights, edge_weights, charge_weights = setup_token_weights( + vocab=data.vocab, + distributions=data.empirical, + weight_alpha=weighting.weight_alpha, + type_loss_token_weights=weighting.type_loss_token_weights, + ) + if metrics is None: + metrics = build_model_metrics(data) + return hydra.utils.instantiate( + cfg.model.module, + _recursive_=False, + distributions=data.prior, + loss_weight_distributions=data.empirical, + atom_type_weights=atom_weights, + edge_token_weights=edge_weights, + charge_token_weights=charge_weights, + allow_charged=data.allow_charged, + representation=data.representation.value, + **metrics, + ) diff --git a/geom/explicit_h/runtime/src/morph/model/finetune/__init__.py b/geom/explicit_h/runtime/src/morph/model/finetune/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..47b5a9eb5d013717c4851897a7d253d4a157279c --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/finetune/__init__.py @@ -0,0 +1,6 @@ +"""Task-specific fine-tuning, one subpackage per application. + +The ``base`` package contains shared building blocks; ``scaffold`` pairs the +task's Lightning module with its conditional backbone. Public classes are +re-exported at each package boundary for concise Hydra targets. +""" diff --git a/geom/explicit_h/runtime/src/morph/model/finetune/base/__init__.py b/geom/explicit_h/runtime/src/morph/model/finetune/base/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..005c1613e1a4df3198bd5f565e73a0004e9e5c8c --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/finetune/base/__init__.py @@ -0,0 +1,28 @@ +"""Shared building blocks for conditional fine-tuning tasks.""" + +from morph.model.finetune.base.conditional_backbone import ( + ConditioningDiTBackboneWithHeads, + CondPorts, +) +from morph.model.finetune.base.context_encoder import ( + KeepEdgeCond, + KeepNodeCond, + LightweightContextEncoder, +) +from morph.model.finetune.base.finetune_base import ( + LightningModuleRatesFinetune, + load_pretrained_into_lightning_model, +) +from morph.model.layers.attention import DistanceAttnBias, build_distance_attn_bias + +__all__ = [ + "CondPorts", + "ConditioningDiTBackboneWithHeads", + "DistanceAttnBias", + "KeepEdgeCond", + "KeepNodeCond", + "LightningModuleRatesFinetune", + "LightweightContextEncoder", + "build_distance_attn_bias", + "load_pretrained_into_lightning_model", +] diff --git a/geom/explicit_h/runtime/src/morph/model/finetune/base/conditional_backbone.py b/geom/explicit_h/runtime/src/morph/model/finetune/base/conditional_backbone.py new file mode 100644 index 0000000000000000000000000000000000000000..f9c2e279574ac3c93195f335f041f855ed2a1f5e --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/finetune/base/conditional_backbone.py @@ -0,0 +1,78 @@ +"""Template-method base for task-specific conditioning backbones. + +The scaffold wrapper extends +:class:`~morph.model.backbones.conditional_dit.ConditionalDiTBackboneWithHeads` +with the *same* ``forward``: take the standard conditioning ports, optionally +populate some of them from task-specific inputs, then delegate to the parent. +Only the "populate" step differs between tasks, so it is the single overridable +hook (:meth:`_build_conditioning`); the (long) signature and the delegation live +here once instead of being copy-pasted into every subclass. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from morph.model.backbones.conditional_dit import ConditionalDiTBackboneWithHeads + +if TYPE_CHECKING: + import torch + + from morph.dataset.core.atomic_data import ChemBatch + +# The five conditioning ports threaded from ``forward`` into ``super().forward``. +CondPorts = dict[str, "torch.Tensor | None"] + + +class ConditioningDiTBackboneWithHeads(ConditionalDiTBackboneWithHeads): + """A :class:`ConditionalDiTBackboneWithHeads` with a task-specific + conditioning hook run in front of the conditional forward. + """ + + def _build_conditioning( + self, + mols_t: ChemBatch, + cond: CondPorts, + force_uncond: bool, + ) -> CondPorts: + """Populate / adjust the conditioning ports for this task. + + ``cond`` holds the five caller-supplied ports (``cond_tokens``, + ``cond_token_mask``, ``cond_token_attn_bias``, ``node_cond``, + ``edge_cond``). Caller-supplied (non-``None``) values + must be preserved — the inference-time caching hook passes them + directly. Returns the (possibly updated) dict. Default: no conditioning. + """ + return cond + + def forward( + self, + mols_t: ChemBatch, + t: torch.Tensor, + cond_tokens: torch.Tensor | None = None, + cond_token_mask: torch.Tensor | None = None, + cond_token_attn_bias: torch.Tensor | None = None, + node_cond: torch.Tensor | None = None, + edge_cond: torch.Tensor | None = None, + max_seqlen: int | None = None, + force_uncond: bool = False, + ) -> dict[str, Any]: + """Encode task context and run the conditional DiT with its heads.""" + force_uncond = force_uncond or bool(getattr(mols_t, "force_uncond", False)) + cond = self._build_conditioning( + mols_t, + { + "cond_tokens": cond_tokens, + "cond_token_mask": cond_token_mask, + "cond_token_attn_bias": cond_token_attn_bias, + "node_cond": node_cond, + "edge_cond": edge_cond, + }, + force_uncond, + ) + return super().forward( + mols_t, + t, + max_seqlen=max_seqlen, + **cond, + ) diff --git a/geom/explicit_h/runtime/src/morph/model/finetune/base/context_encoder.py b/geom/explicit_h/runtime/src/morph/model/finetune/base/context_encoder.py new file mode 100644 index 0000000000000000000000000000000000000000..08ef01fce6650b5f2752efc776714adacf393707 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/finetune/base/context_encoder.py @@ -0,0 +1,356 @@ +"""Lightweight context encoding for conditional fine-tuning. + +The scaffold recipe conditions a frozen DiT on the scaffold atoms by encoding +them into cross-attention tokens with a per-atom featuriser: + + type embedding + charge embedding + 3D-position MLP -> concat -> + token MLP (-> ctx_dim) -> pack to dense ``(B, K, ctx_dim)`` -> + optional self-attention so the tokens mix. + +:class:`LightweightContextEncoder` factors that shared featurization out into +``atom_embed`` / ``charge_embed`` / ``pos_proj`` / ``token_proj`` / ``attn``. + +This class directly encodes the scaffold subgraph for scaffold decoration. + +The optional ``use_pair_bias`` flavour biases the self-attention with an +edge-based :class:`~morph.model.layers.attention.PairBias` (RBF distance + +edge-type of the context molecule's own bonds), so tokens mix with +geometry/topology awareness instead of plain token-space attention. + +The per-node / per-edge scaffold-membership conditioning modules +(:class:`KeepNodeCond`, :class:`KeepEdgeCond`) and the shared cross-attn +distance-bias helper (:func:`build_distance_attn_bias`) also live here. + +The output projection is intentionally *un-gated*: the clean lane's +cross-attention gates carry the zero-init identity guarantee, so a freshly +initialised encoder leaves an unconditional checkpoint unchanged at step 0. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch_geometric.utils import to_dense_batch + +from morph.model.layers.attention import PairBias +from morph.model.layers.embedding import RBFEmbedding +from morph.utils.graph_math import batched_triu_edge_layout + +if TYPE_CHECKING: + from morph.dataset.core.atomic_data import ChemBatch + + +class _BiasedSelfAttnBlock(nn.Module): + """Pre-norm self-attention + FFN over context tokens with an additive + per-head pair bias ``(B, H, K, K)`` (``-inf`` at masked pairs). Mirrors the + DiT block's biased SDPA attention but without adaLN conditioning — the + context encoder has no time signal. + """ + + def __init__(self, dim: int, nhead: int, mlp_ratio: float, dropout: float): + super().__init__() + assert dim % nhead == 0, "ctx_dim must be divisible by nhead" + self.nhead = nhead + self.norm1 = nn.LayerNorm(dim) + self.qkv = nn.Linear(dim, 3 * dim) + self.proj = nn.Linear(dim, dim) + self.norm2 = nn.LayerNorm(dim) + hidden = int(dim * mlp_ratio) + self.ffn = nn.Sequential( + nn.Linear(dim, hidden), nn.GELU(), nn.Linear(hidden, dim) + ) + self.drop = nn.Dropout(dropout) + + def forward(self, x: torch.Tensor, attn_bias: torch.Tensor) -> torch.Tensor: + B, K, D = x.shape + h = self.norm1(x) + qkv = self.qkv(h).reshape(B, K, 3, self.nhead, D // self.nhead) + q, k, v = qkv.permute(2, 0, 3, 1, 4).unbind(0) # each (B, H, K, d) + o = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_bias) + o = o.transpose(1, 2).reshape(B, K, D) + x = x + self.drop(self.proj(o)) + x = x + self.drop(self.ffn(self.norm2(x))) + return x + + +class LightweightContextEncoder(nn.Module): + """Per-atom (type/charge/position) featuriser + optional self-attention. + + Carries no pretrained weights — all parameters train from scratch, so + :meth:`freeze_pretrained_body` is a no-op (present so the wrappers' + ``freeze_pretrained`` dispatch is uniform across encoder types). + + Args: + num_atom_types: Atom-type vocabulary size. + num_charges: Charge vocabulary size. + atom_embed_dim / charge_embed_dim / pos_embed_dim: Per-feature widths. + ctx_dim: Output token width. Must match ``ConditionalDiTBackbone.ctx_dim``. + extra_feat_dim: Extra per-atom feature width concatenated *before* the + token MLP (e.g. error embeddings). 0 for the plain encoders. + num_attn_layers: Transformer self-attention layers over the tokens of + each graph. 0 → mixing-free per-atom encoder (no ``attn`` submodule). + nhead / mlp_ratio / dropout: Self-attention hyper-parameters. + use_pair_bias: When True, the self-attention is biased by an + edge-based :class:`~morph.model.backbones.dit.PairBias` (RBF of + pairwise distance + edge-type embedding of the context molecule's + own bonds), so the tokens mix with geometry/topology awareness like + the DiT backbone — instead of the plain token-space attention. The + biased path reads ``mol.e`` / ``mol.edge_index`` in :meth:`encode` + (the plain path needs only nodes). Requires ``num_attn_layers > 0`` + and a ``num_edge_types``. + num_edge_types / edge_embed_dim / num_rbf / rbf_dmax / + pair_bias_hidden_dim: pair-bias hyper-parameters (used only when + ``use_pair_bias``). + """ + + def __init__( + self, + num_atom_types: int, + num_charges: int, + atom_embed_dim: int = 64, + charge_embed_dim: int = 16, + pos_embed_dim: int = 32, + ctx_dim: int = 128, + extra_feat_dim: int = 0, + num_attn_layers: int = 0, + nhead: int = 8, + mlp_ratio: float = 4.0, + dropout: float = 0.0, + use_pair_bias: bool = False, + num_edge_types: int | None = None, + edge_embed_dim: int = 32, + num_rbf: int = 32, + rbf_dmax: float = 5.0, + pair_bias_hidden_dim: int = 32, + ): + super().__init__() + self.ctx_dim = ctx_dim + + self.atom_embed = nn.Embedding(num_atom_types, atom_embed_dim) + self.charge_embed = nn.Embedding(num_charges, charge_embed_dim) + self.pos_proj = nn.Sequential( + nn.Linear(3, pos_embed_dim), + nn.GELU(), + nn.Linear(pos_embed_dim, pos_embed_dim), + ) + + feat_dim = atom_embed_dim + charge_embed_dim + pos_embed_dim + extra_feat_dim + self.token_proj = nn.Sequential( + nn.Linear(feat_dim, ctx_dim), + nn.GELU(), + nn.Linear(ctx_dim, ctx_dim), + ) + + # Two self-attention flavours. ``use_pair_bias`` swaps the plain + # token-space TransformerEncoder for a custom biased stack that respects + # the context molecule's bonds/geometry. They are mutually exclusive; + # only the active one holds parameters. + self.attn: nn.TransformerEncoder | None = None + self.biased_blocks: nn.ModuleList | None = None + self.edge_embed: nn.Embedding | None = None + self.pair_bias: PairBias | None = None + + if use_pair_bias: + if num_attn_layers <= 0: + raise ValueError("use_pair_bias requires num_attn_layers > 0") + if num_edge_types is None: + raise ValueError("use_pair_bias requires num_edge_types") + self.edge_embed = nn.Embedding(num_edge_types, edge_embed_dim) + self.pair_bias = PairBias( + rbf_embedding=RBFEmbedding( + num_rbf=num_rbf, + rbf_dmax=rbf_dmax, + out_dim=num_rbf, + trainable=False, + ), + rbf_out_dim=num_rbf, + edge_nf=edge_embed_dim, + num_heads=nhead, + hidden_dim=pair_bias_hidden_dim, + ) + self.biased_blocks = nn.ModuleList( + _BiasedSelfAttnBlock(ctx_dim, nhead, mlp_ratio, dropout) + for _ in range(num_attn_layers) + ) + elif num_attn_layers > 0: + layer = nn.TransformerEncoderLayer( + d_model=ctx_dim, + nhead=nhead, + dim_feedforward=int(ctx_dim * mlp_ratio), + dropout=dropout, + activation="gelu", + batch_first=True, + norm_first=True, + ) + self.attn = nn.TransformerEncoder( + layer, num_layers=num_attn_layers, norm=nn.LayerNorm(ctx_dim) + ) + + # ------------------------------------------------------------------ # + # Shared building blocks used by subclass forwards. + # ------------------------------------------------------------------ # + def base_feats( + self, a: torch.Tensor, c: torch.Tensor, x: torch.Tensor + ) -> torch.Tensor: + """Concatenated per-atom ``[type | charge | position]`` features.""" + a_embed = self.atom_embed(a) + c_embed = self.charge_embed(c) + x_embed = self.pos_proj(x.to(dtype=a_embed.dtype)) + return torch.cat([a_embed, c_embed, x_embed], dim=-1) + + def apply_self_attn( + self, cond_tokens: torch.Tensor, cond_token_mask: torch.Tensor + ) -> torch.Tensor: + """Mix tokens within each graph; zero padded rows (no NaN leakage).""" + if self.attn is None: + return cond_tokens + out = self.attn(cond_tokens, src_key_padding_mask=~cond_token_mask) + return out.masked_fill(~cond_token_mask.unsqueeze(-1), 0.0) + + def encode( + self, mol: ChemBatch, extra_feat: torch.Tensor | None = None + ) -> tuple[torch.Tensor, torch.Tensor]: + """Full pipeline: featurise every atom → tokens → dense → self-attn. + + Args: + mol: a :class:`ChemBatch` exposing ``.a``, ``.c``, ``.x``, ``.batch`` + (and, when ``use_pair_bias``, ``.e`` / ``.edge_index``). + extra_feat: optional ``(N, extra_feat_dim)`` per-atom features to + concatenate before the token MLP. + + Returns ``(cond_tokens, cond_token_mask)`` of shapes + ``(B, K_max, ctx_dim)`` and ``(B, K_max)``. + """ + batch = mol.batch + batch_size = mol.num_graphs + feats = self.base_feats(mol.a, mol.c, mol.x) + if extra_feat is not None: + feats = torch.cat([feats, extra_feat.to(dtype=feats.dtype)], dim=-1) + tokens = self.token_proj(feats) + cond_tokens, cond_token_mask = to_dense_batch( + tokens, batch, batch_size=batch_size + ) + if self.pair_bias is not None: + cond_tokens = self._apply_biased_self_attn( + cond_tokens, cond_token_mask, mol, batch, batch_size + ) + else: + cond_tokens = self.apply_self_attn(cond_tokens, cond_token_mask) + return cond_tokens, cond_token_mask + + def _apply_biased_self_attn( + self, + cond_tokens: torch.Tensor, + cond_token_mask: torch.Tensor, + mol: ChemBatch, + batch: torch.Tensor, + batch_size: int, + ) -> torch.Tensor: + """Self-attention biased by the context molecule's own edges/geometry.""" + K = cond_tokens.shape[1] + edge_index = mol.edge_index + e_embed = self.edge_embed(mol.e) + triu_edge_positions, _ = batched_triu_edge_layout( + edge_index, batch, mol.num_nodes_per_graph + ) + # (B, H, K, K), already -inf at padded pairs. K matches the token pad + # length because both dense-pack ``batch`` at the same max node count. + bias, atom_mask, _ = self.pair_bias( + mol.x, + (edge_index[0], edge_index[1]), + e_embed, + batch, + batch_size, + K, + lengths=mol.num_nodes_per_graph, + triu_edge_positions=triu_edge_positions, + ) + # A fully padded query row is all -inf → NaN softmax. Zero those rows' + # bias (their outputs are masked to 0 below anyway). + bias = bias.masked_fill((~atom_mask)[:, None, :, None], 0.0) + for block in self.biased_blocks: + cond_tokens = block(cond_tokens, bias) + return cond_tokens.masked_fill(~cond_token_mask.unsqueeze(-1), 0.0) + + @torch.compiler.disable + def forward(self, mol: ChemBatch) -> tuple[torch.Tensor, torch.Tensor]: + """Encode every atom of ``mol`` into dense cross-attention tokens + ``(cond_tokens (B,K,ctx_dim), cond_token_mask (B,K))``. + + The scaffold wrapper instantiates this class directly. Compiler-disabled: + ``to_dense_batch`` / + `PairBias` derive shapes dynamo can't trace. Subclasses that need extra + per-atom inputs (error tokens) override ``forward`` with their own + signature; the plain per-atom / per-structure encoders use this as-is. + """ + return self.encode(mol) + + def freeze_pretrained_body(self) -> int: + """No-op: this encoder has no pretrained-loaded body. Returns 0.""" + return 0 + + +# Categorical edge types by scaffold membership of the two endpoints. +_EDGE_DEC_DEC = 0 # both endpoints in decoration +_EDGE_SC_DEC = 1 # exactly one endpoint in the scaffold +_EDGE_SC_SC = 2 # both endpoints in the scaffold +_N_EDGE_SCAFFOLD_TYPES = 3 + + +class KeepEdgeCond(nn.Module): + """Per-edge scaffold-class → pre-embedded ``edge_cond``. + + Classifies each edge as decoration-decoration / scaffold-decoration / + scaffold-scaffold and looks it up straight at ``in_edge_nf`` (embed-outside: + the backbone adds it through a zero-init ``edge_cond_gate``). Recomputed each + forward from the live ``edge_index`` + ``keep_mask`` so it tracks the + integrator's deletions/insertions. Returns ``None`` when the batch carries no + ``keep_mask``. + """ + + def __init__(self, in_edge_nf: int): + super().__init__() + self.embed = nn.Embedding(_N_EDGE_SCAFFOLD_TYPES, in_edge_nf) + + def forward(self, mols_t: ChemBatch) -> torch.Tensor | None: + """Embed retained-atom edge classes; return None without a keep mask.""" + sc_mask = getattr(mols_t, "keep_mask", None) + if sc_mask is None: + return None + sc_mask = sc_mask.bool() + edge_index = mols_t.edge_index + edge_types = ( + sc_mask[edge_index[0]].long() + sc_mask[edge_index[1]].long() + ).clamp(max=_EDGE_SC_SC) + out = self.embed(edge_types) + # Type 0 (editable-editable) carries NO signal — only kept-touching edges + # condition. An all-zero keep_mask therefore yields + # zero edge_cond instead of a constant leak through the shared gate. + return out * (edge_types > 0).unsqueeze(-1).to(out.dtype) + + +class KeepNodeCond(nn.Module): + """Per-node scaffold membership (0 = decoration, 1 = keep) → ``node_cond``. + + Recomputed each forward from the live ``keep_mask``. Returns ``None`` when + the batch carries no ``keep_mask``. + """ + + def __init__(self, d_model: int): + super().__init__() + self.embed = nn.Embedding(2, d_model) + + def forward(self, mols_t: ChemBatch) -> torch.Tensor | None: + """Embed kept atoms while leaving editable atoms unconditioned.""" + sc_mask = getattr(mols_t, "keep_mask", None) + if sc_mask is None: + return None + keep = sc_mask.long() + out = self.embed(keep) + # Only KEPT atoms (keep_mask=1) condition; editable atoms (0) carry no + # signal, so an all-zero keep_mask yields zero + # node_cond instead of a constant leak through the shared node_gate. + return out * keep.unsqueeze(-1).to(out.dtype) diff --git a/geom/explicit_h/runtime/src/morph/model/finetune/base/finetune_base.py b/geom/explicit_h/runtime/src/morph/model/finetune/base/finetune_base.py new file mode 100644 index 0000000000000000000000000000000000000000..644084c72cc4600a182d373d3247be3f48281e66 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/finetune/base/finetune_base.py @@ -0,0 +1,228 @@ +"""Shared fine-tuning LightningModule and pretrained-checkpoint loading. + +:class:`LightningModuleRatesFinetune` loads an unconditional DiT checkpoint into +the clean lane at init and (optionally) freezes it, dispatching to the model's own +``freeze_pretrained``. The module-level helpers do the checkpoint surgery. + +The unconditional DiT checkpoints store backbone weights at +``state_dict["model.backbone."]``. When fine-tuning with a +``ConditionalDiTBackboneWithHeads`` (which composes a ``DiTBackbone`` as +``self.backbone.dit``), the matching destination keys are ``backbone.dit.``; +:func:`load_pretrained_into_lightning_model` rewrites keys accordingly and uses +``load_state_dict(strict=False)`` so the cross-attn blocks (zero-gated at init) and +any other new modules keep their initial values. + +The task-specific subclass lives in the sibling ``scaffold`` package. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path + +import torch + +from morph.model.lightning.base import LightningModuleRates +from morph.utils.checkpoints import canonical_checkpoint_key + +LOGGER = logging.getLogger(__name__) + + +def _extract_lightning_state_dict( + ckpt: dict, prefer_ema: bool = False +) -> dict[str, torch.Tensor]: + """Pull the model state dict out of a raw Lightning checkpoint. + + The Lightning module wraps the actual model under ``model.<...>`` and an + EMA copy under ``model_ema.<...>``. We return just the keys under + ``model.`` (or ``model_ema.`` if ``prefer_ema``), with that prefix + stripped so they line up with ``self.model.load_state_dict`` callers. + """ + sd = ckpt.get("state_dict", ckpt) + src_prefix = "model_ema." if prefer_ema else "model." + fallback = "model." if prefer_ema else "model_ema." + + matches = {k: v for k, v in sd.items() if k.startswith(src_prefix)} + if not matches: + matches = {k: v for k, v in sd.items() if k.startswith(fallback)} + if not matches: + raise ValueError( + "No keys starting with 'model.' or 'model_ema.' in checkpoint." + ) + src_prefix = fallback + + # Strip the outer wrapper and any torch.compile() ``_orig_mod`` component. + stripped: dict[str, torch.Tensor] = {} + for k, v in matches.items(): + name = canonical_checkpoint_key(k[len(src_prefix) :]) + stripped[name] = v + LOGGER.info( + "Extracted %d params from checkpoint under '%s'.", len(stripped), src_prefix + ) + return stripped + + +def _retarget_backbone_keys_for_conditional( + state_dict: dict[str, torch.Tensor], +) -> dict[str, torch.Tensor]: + """Rewrite ``backbone.`` → ``backbone.dit.``. + + Used when loading a vanilla DiT checkpoint into a ConditionalDiT + backbone, which holds the DiT under ``self.backbone.dit``. + """ + pattern = re.compile(r"^backbone\.(?!dit\.)(.+)") + retargeted = {} + for key, value in state_dict.items(): + match = pattern.match(key) + if match: + retargeted[f"backbone.dit.{match.group(1)}"] = value + else: + retargeted[key] = value + return retargeted + + +def load_pretrained_into_lightning_model( + model: torch.nn.Module, + ckpt_path: str | Path, + *, + prefer_ema: bool = False, + strict: bool = False, +) -> tuple[list[str], list[str]]: + """Load a pretrained checkpoint into a Lightning model's ``self.model``. + + Automatically retargets vanilla-DiT keys to the ConditionalDiT layout when + necessary. Returns ``(missing, unexpected)`` from ``load_state_dict``. + + Args: + model: the inner model (``LightningModuleRates.model``), NOT the + outer Lightning module. + ckpt_path: Path to the Lightning checkpoint (``.ckpt`` file). + prefer_ema: If True, load from ``model_ema.*`` keys (typically what + you want for fine-tuning since EMA weights are smoother). + strict: Forwarded to ``load_state_dict``. Default ``False`` because + new modules (cross-attn blocks, conditioning gates/encoders) won't + exist in the source checkpoint. + """ + ckpt_path = Path(ckpt_path) + if not ckpt_path.exists(): + raise FileNotFoundError(f"Pretrained checkpoint not found: {ckpt_path}") + + LOGGER.info( + "Loading pretrained weights from %s (prefer_ema=%s)", ckpt_path, prefer_ema + ) + raw = torch.load(ckpt_path, map_location="cpu", weights_only=False) + sd = _extract_lightning_state_dict(raw, prefer_ema=prefer_ema) + + # Retarget ONLY a vanilla-DiT source (keys ``backbone.

``) into the + # ConditionalDiT layout (``backbone.dit.

``). An already-conditional source + # (its own ``backbone.dit.*`` + ``backbone.cross_attn_blocks.*`` + + # ``backbone.graph_gate``) must load as-is: retargeting would mis-move the + # cross-attn adapter and gates under ``backbone.dit.*``, where the destination + # has no such params, so they drop under ``strict=False`` and the trained + # adapter silently resets to init. Guard on the SOURCE, not just the dest. + dest_is_conditional = any( + name.startswith("backbone.dit.") for name, _ in model.named_parameters() + ) + source_is_conditional = any(k.startswith("backbone.dit.") for k in sd) + if dest_is_conditional and not source_is_conditional: + sd = _retarget_backbone_keys_for_conditional(sd) + + missing, unexpected = model.load_state_dict(sd, strict=strict) + if missing: + LOGGER.info( + "Pretrained load: %d missing keys (expected for new modules like " + "cross_attn_blocks). First few: %s", + len(missing), + missing[:5], + ) + if unexpected: + LOGGER.warning( + "Pretrained load: %d unexpected keys (may indicate arch mismatch). " + "First few: %s", + len(unexpected), + unexpected[:5], + ) + return missing, unexpected + + +class LightningModuleRatesFinetune(LightningModuleRates): + """Fine-tuning variant that loads pretrained DiT weights at init. + + The shared Lightning module delegates its Muon/AuxAdam behavior to the + optimizer mixin configured by ``configs/model/backbones/dit.yaml``. + """ + + def __init__( + self, + *args, + pretrained_ckpt: str | None = None, + pretrained_prefer_ema: bool = True, + freeze_pretrained_backbone: bool = False, + freeze_keep_positions: bool = False, + **kwargs, + ): + super().__init__(*args, **kwargs) + self._freeze_keep_positions = freeze_keep_positions + + if pretrained_ckpt: + load_pretrained_into_lightning_model( + self.model, + pretrained_ckpt, + prefer_ema=pretrained_prefer_ema, + ) + # Re-sync the EMA copy so it starts from the loaded weights, + # not from the freshly instantiated (random) ones. + self.model_ema.load_state_dict(self.model.state_dict()) + + if freeze_pretrained_backbone: + # Each BackboneWithHeads subclass owns its freeze policy via + # ``freeze_pretrained()`` (no fragile name-prefix matching); a + # subclass may extend it to freeze its own encoder body too. + frozen = self.model.freeze_pretrained() + self.model_ema.freeze_pretrained() + # The EMA copy is never optimized, including any LoRA matrices + # that the model-side freeze policy deliberately re-enabled. + self.model_ema.requires_grad_(False) + LOGGER.info( + "Froze %s params via %s.freeze_pretrained().", + frozen, + type(self.model).__name__, + ) + + def _apply_inference_edit_masks( + self, + mol_t, + x1_pred, + do_sub_a_probs, + do_sub_e_probs, + do_del_probs, + num_ins_pred, + ): + """Force-fix the kept atoms & their internal edges at inference. + + Zeroes the substitution / deletion probabilities for the kept-atom set + (and sub-e for kept-kept edges) *before* the integrator, so kept-atom types + and bonds cannot drift during sampling — true inpainting. The keep set is + the ``keep_mask`` node-property port, which every edit task reuses as + its generic keep mask (Bemis-Murcko scaffold, grown fragment, linker + endpoints, …), not only literal scaffolds. ``num_ins_pred`` is left + untouched so decorations can still grow from kept-atom seeds. With + ``freeze_keep_positions``, the clean coordinate prediction is pinned to + the current kept coordinates, giving them zero Euler velocity. A no-op + when the batch carries no keep mask. + """ + keep_mask = getattr(mol_t, "keep_mask", None) # generic keep-set port + if keep_mask is None: + return x1_pred, do_sub_a_probs, do_sub_e_probs, do_del_probs, num_ins_pred + keep_node = keep_mask.bool() + do_sub_a_probs = do_sub_a_probs.clone() + do_del_probs = do_del_probs.clone() + do_sub_a_probs[keep_node] = 0.0 + do_del_probs[keep_node] = 0.0 + keep_edge = keep_node[mol_t.edge_index[0]] & keep_node[mol_t.edge_index[1]] + do_sub_e_probs = do_sub_e_probs.clone() + do_sub_e_probs[keep_edge] = 0.0 + if self._freeze_keep_positions: + x1_pred = torch.where(keep_node[:, None], mol_t.positions, x1_pred) + return x1_pred, do_sub_a_probs, do_sub_e_probs, do_del_probs, num_ins_pred diff --git a/geom/explicit_h/runtime/src/morph/model/finetune/scaffold/__init__.py b/geom/explicit_h/runtime/src/morph/model/finetune/scaffold/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b0d8cde88fb6ab386fb9b6a83f6e359ca19ed3ce --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/finetune/scaffold/__init__.py @@ -0,0 +1,13 @@ +"""Scaffold-decoration model and Lightning module.""" + +from morph.model.finetune.scaffold.scaffold import ( + LightningModuleRatesScaffoldFinetune, +) +from morph.model.finetune.scaffold.scaffold_context import ( + ScaffoldConditionalDiTBackboneWithHeads, +) + +__all__ = [ + "LightningModuleRatesScaffoldFinetune", + "ScaffoldConditionalDiTBackboneWithHeads", +] diff --git a/geom/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold.py b/geom/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold.py new file mode 100644 index 0000000000000000000000000000000000000000..01867fd78848d1b8de0ea3cefac731bbf8cfc4c8 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold.py @@ -0,0 +1,52 @@ +"""Scaffold-decoration fine-tuning LightningModule.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from morph.model.finetune.base import LightningModuleRatesFinetune + +if TYPE_CHECKING: + import torch + + +class LightningModuleRatesScaffoldFinetune(LightningModuleRatesFinetune): + """Scaffold-aware fine-tuning. + + On top of pretrained loading from :class:`LightningModuleRatesFinetune`, + implements the two scaffold hooks defined on the base + :class:`LightningModuleRates`: + + * independent discrete and coordinate hooks return ``mols_t.keep_mask`` + when their corresponding exclusion flag is enabled. + The inference-time scaffold force-fix (``_apply_inference_edit_masks``) + lives on the shared parent :class:`LightningModuleRatesFinetune`; this class + adds the training-time loss exclusion. + + The flags ``exclude_scaffold_from_sub_loss`` and + ``exclude_scaffold_from_x_loss`` default to ``True`` — the typical + fine-tuning setting where scaffold atoms are frozen at inference, so + training them adds no useful signal and only contributes noise. + """ + + def __init__( + self, + *args, + exclude_scaffold_from_sub_loss: bool = True, + exclude_scaffold_from_x_loss: bool = True, + freeze_keep_positions: bool = True, + **kwargs, + ): + super().__init__(*args, freeze_keep_positions=freeze_keep_positions, **kwargs) + self._exclude_scaffold_from_sub_loss = exclude_scaffold_from_sub_loss + self._exclude_scaffold_from_x_loss = exclude_scaffold_from_x_loss + + def _node_loss_exclusion_mask(self, mols_t) -> torch.Tensor | None: + if not self._exclude_scaffold_from_sub_loss: + return None + return getattr(mols_t, "keep_mask", None) + + def _position_loss_exclusion_mask(self, mols_t) -> torch.Tensor | None: + if not self._exclude_scaffold_from_x_loss: + return None + return getattr(mols_t, "keep_mask", None) diff --git a/geom/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold_context.py b/geom/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold_context.py new file mode 100644 index 0000000000000000000000000000000000000000..8ffea81ddf0ed41fb3ccc5adc735b2ddeb1be146 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold_context.py @@ -0,0 +1,145 @@ +"""Scaffold conditioning for scaffold-decoration fine-tuning. + +Two conditioning signals, **always on**, derived from ``mols_t.keep_mask`` +(both recomputed each forward so they track the integrator's del/ins): + +* ``node_cond`` — per-atom scaffold membership (0 = decoration, 1 = keep), via + the shared :class:`~morph.model.finetune.base.KeepNodeCond`. +* ``edge_cond`` — per-edge scaffold class (dec-dec / scaffold-dec / + scaffold-scaffold), via the shared + :class:`~morph.model.finetune.base.KeepEdgeCond`. + +Plus an **optional** cross-attention stream, toggled by the backbone's +``use_cross_attn`` flag: + +* **Option 1** (``use_cross_attn: false``) — node + edge conditioning only. +* **Option 2** (``use_cross_attn: true``) — node + edge + cross-attention. The + scaffold atoms are extracted from ``mols_t`` (induced subgraph via + :func:`~morph.dataset.core.atomic_data.filter_nodes`) and encoded into context + tokens by a :class:`~morph.model.finetune.base.LightweightContextEncoder`. + +Scaffold decoration carries no graph-level property condition. The per-node, +per-edge, and distance-bias building blocks live in +:mod:`morph.model.finetune.base.context_encoder`. +""" + +from __future__ import annotations + +import hydra +import torch + +from morph.dataset.core.atomic_data import ChemBatch, filter_nodes +from morph.model.finetune.base import ( + ConditioningDiTBackboneWithHeads, + CondPorts, + DistanceAttnBias, + KeepEdgeCond, + KeepNodeCond, + LightweightContextEncoder, + build_distance_attn_bias, +) + + +class ScaffoldConditionalDiTBackboneWithHeads(ConditioningDiTBackboneWithHeads): + """:class:`ConditionalDiTBackboneWithHeads` with built-in scaffold context. + + On every forward, builds ``node_cond`` + ``edge_cond`` from + ``mols_t.keep_mask`` (when the matching backbone port is enabled) and, + when ``use_cross_attn`` is set, encodes the scaffold subgraph into + ``cond_tokens`` via a shared ``LightweightContextEncoder``. Caller-supplied + kwargs always win (the inference-time caching hook: pass ``cond_tokens`` + directly to skip the encoder). + + Extra Hydra-instantiable args on top of the parent: + * ``xattn_encoder_args`` — the ``LightweightContextEncoder`` config; + required iff the backbone has ``use_cross_attn: true``. + * ``use_distance_bias`` / ``dist_bias_init_gamma`` — geometric + correspondence prior (query atom ↔ co-located scaffold token). + """ + + def __init__( + self, + *args, + xattn_encoder_args: dict | None = None, + use_distance_bias: bool = True, + dist_bias_init_gamma: float = 1.0, + **kwargs, + ): + super().__init__(*args, **kwargs) + + bb = self.backbone + self.node_cond_encoder = KeepNodeCond(bb.d_model) if bb.use_node_cond else None + self.edge_cond_encoder = ( + KeepEdgeCond(bb.in_edge_nf) if bb.use_edge_cond else None + ) + + # Optional cross-attention (option 2): encode the scaffold subgraph with + # a lightweight encoder shared by conditional fine-tuning tasks. + if bb.use_cross_attn: + if xattn_encoder_args is None: + raise ValueError( + "backbone.use_cross_attn is true but xattn_encoder_args is None; " + "provide the LightweightContextEncoder config, or set " + "use_cross_attn=false for node+edge-only scaffold conditioning." + ) + self.xattn_encoder: LightweightContextEncoder | None = ( + hydra.utils.instantiate(xattn_encoder_args) + ) + self.dist_bias = ( + DistanceAttnBias(dist_bias_init_gamma) if use_distance_bias else None + ) + else: + self.xattn_encoder = None + self.dist_bias = None + + def freeze_pretrained(self) -> int: + """Freeze the clean lane; the scaffold conditioning modules stay + trainable (they carry no pretrained body). + """ + frozen = super().freeze_pretrained() + if self.xattn_encoder is not None: + frozen += self.xattn_encoder.freeze_pretrained_body() + return frozen + + # filter_nodes + the encoder run data-dependent shape ops dynamo can't trace; + # isolate them so the rest of the model still compiles. + @torch.compiler.disable + def _encode_scaffold_cross_attn( + self, mols_t: ChemBatch + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]: + """Encode the scaffold subgraph of ``mols_t`` into cross-attn tokens.""" + scaffold = filter_nodes(mols_t, mols_t.keep_mask.bool()) + cond_tokens, cond_token_mask = self.xattn_encoder(scaffold) + cond_token_attn_bias = None + if self.dist_bias is not None: + cond_token_attn_bias = build_distance_attn_bias( + self.dist_bias, + mols_t.x, + mols_t.batch, + int(mols_t.max_seqlen), + scaffold.x, + scaffold.batch, + cond_tokens.shape[1], + cond_tokens.shape[0], + ) + return cond_tokens, cond_token_mask, cond_token_attn_bias + + def _build_conditioning( + self, mols_t: ChemBatch, cond: CondPorts, force_uncond: bool + ) -> CondPorts: + if getattr(mols_t, "keep_mask", None) is not None: + if cond["node_cond"] is None and self.node_cond_encoder is not None: + cond["node_cond"] = self.node_cond_encoder(mols_t) + if cond["edge_cond"] is None and self.edge_cond_encoder is not None: + cond["edge_cond"] = self.edge_cond_encoder(mols_t) + if ( + not force_uncond + and self.xattn_encoder is not None + and cond["cond_tokens"] is None + ): + cond["cond_tokens"], cond["cond_token_mask"], sc_bias = ( + self._encode_scaffold_cross_attn(mols_t) + ) + if cond["cond_token_attn_bias"] is None: + cond["cond_token_attn_bias"] = sc_bias + return cond diff --git a/geom/explicit_h/runtime/src/morph/model/layers/__init__.py b/geom/explicit_h/runtime/src/morph/model/layers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0a05254bc5f23cf8659009d164755c1c23312b00 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/layers/__init__.py @@ -0,0 +1,5 @@ +"""Reusable nn building blocks. + +Lower-level :class:`torch.nn.Module` pieces composed by the DiT backbones: +attention, feature/time/count embeddings, prediction heads, and GMM helpers. +""" diff --git a/geom/explicit_h/runtime/src/morph/model/layers/attention.py b/geom/explicit_h/runtime/src/morph/model/layers/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..7a54b3842f9965e913b6fbc807a9b213a6299fc0 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/layers/attention.py @@ -0,0 +1,532 @@ +# Private compiler patches are imported only when compilation is requested. +# ruff: noqa: PLC0415 + +"""Attention for the DiT / context backbones — one home for the attention path. + +Centralises (a) the packed variable-length **self-attention** primitive and its +packed<->dense plumbing, and (b) the additive **bias family** that shapes attention: + +* :class:`PairBias` — per-head self-attention bias (RBF distance + edge type + + optional atom-pair type), + used by the DiT backbone / context encoders. +* :class:`DistanceAttnBias` — fixed ``-softplus(gamma)·d²`` cross-attention bias (shared + across heads). +* :func:`build_distance_attn_bias` — dense-batch masking/plumbing for that bias. + +Self-attention runs in packed ``(total_N, ...)`` layout. Unbiased blocks use +FlashAttention's true variable-length kernel when the optional dependency is +installed and explicitly enabled. Biased attention defaults to PyTorch SDPA; +``MORPH_ATTENTION_BACKEND=flex`` opts into Triton FlexAttention on CUDA. +""" + +from __future__ import annotations + +import os +from functools import cache + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch_geometric.utils import to_dense_batch + +from morph.model.layers.kernels import pack_normalized_qkv + +try: + from flash_attn import flash_attn_varlen_qkvpacked_func # type: ignore + + _HAS_FLASH_ATTN = True +except ImportError: # pragma: no cover + flash_attn_varlen_qkvpacked_func = None + _HAS_FLASH_ATTN = False + +USE_FLASH_ATTN = os.environ.get("MORPH_FLASH_ATTN", "0") == "1" +BIASED_ATTENTION_BACKEND = os.environ.get("MORPH_ATTENTION_BACKEND", "sdpa") +if BIASED_ATTENTION_BACKEND not in {"sdpa", "flex"}: + raise ValueError("MORPH_ATTENTION_BACKEND must be 'sdpa' or 'flex'") +_FLEX_ATTENTION = None +if BIASED_ATTENTION_BACKEND == "flex": + from torch.nn.attention.flex_attention import flex_attention + + _FLEX_ATTENTION = torch.compile(flex_attention, dynamic=True) + + +@cache +def fix_efficient_attention_bias_metadata(): + """Keep symbolic bias-gradient alignment correct across multiples of 16. + + PyTorch 2.9's efficient-attention backward meta kernel chooses a Python + branch for aligned lengths. A dynamic compiled graph can retain the wrong + logical size or stride when lengths cross 32, 48, etc. Specify the logical + shape separately from storage strides computed with ceiling division. The CUDA + kernel + and all numerical attention operations remain PyTorch's native ones. + """ + if not torch.__version__.startswith("2.9."): + return + from torch._meta_registrations import meta__scaled_dot_product_efficient_backward + + def backward_meta( + grad_out, + query, + key, + value, + attn_bias, + out, + logsumexp, + philox_seed, + philox_offset, + dropout_p, + grad_input_mask, + is_causal=False, + scale=None, + ): + grad_q, grad_k, grad_v, _ = meta__scaled_dot_product_efficient_backward( + grad_out, + query, + key, + value, + attn_bias, + out, + logsumexp, + philox_seed, + philox_offset, + dropout_p, + [*grad_input_mask[:3], False], + is_causal, + scale, + ) + grad_bias = None + if attn_bias is not None and grad_input_mask[3]: + length = attn_bias.shape[-1] + aligned_length = ((length + 15) // 16) * 16 + # A padded allocation followed by [:length] can itself specialize + # the logical size to the aligned extent when first traced at an + # aligned length. Specify logical sizes and storage strides directly. + padded_sizes = (*attn_bias.shape[:-1], aligned_length) + strides = [] + stride = 1 + for size in reversed(padded_sizes): + strides.append(stride) + stride *= torch.sym_max(size, 1) + grad_bias = torch.empty_strided( + attn_bias.shape, + tuple(reversed(strides)), + dtype=attn_bias.dtype, + device=attn_bias.device, + ) + return grad_q, grad_k, grad_v, grad_bias + + torch.library.register_fake( + torch.ops.aten._scaled_dot_product_efficient_attention_backward.default, + backward_meta, + allow_override=True, + ) + + +@cache +def _flash_attention_device_supported(device: torch.device) -> bool: + """Whether flash-attn supports the CUDA architecture behind ``device``.""" + if device.type != "cuda": + return False + # flash-attn v2 requires Ampere (compute capability 8.x) or newer. Merely + # importing the extension is insufficient on mixed-generation clusters. + return torch.cuda.get_device_capability(device)[0] >= 8 + + +# --------------------------------------------------------------------------- +# Packed <-> dense index helpers (shared with conditional_dit via re-export) +# --------------------------------------------------------------------------- +def compute_local_index( + batch_idx: torch.Tensor, + batch_size: int, + lengths: torch.Tensor | None = None, +) -> torch.Tensor: + """Intra-graph position of each packed node (``batch_idx`` graph-contiguous).""" + if lengths is None: + lengths = torch.bincount(batch_idx, minlength=batch_size) + offsets = torch.cumsum(lengths, 0) - lengths + return ( + torch.arange(batch_idx.shape[0], device=batch_idx.device) - offsets[batch_idx] + ) + + +def compute_dense_index( + batch_idx: torch.Tensor, + batch_size: int, + max_seqlen: int, + lengths: torch.Tensor | None = None, +) -> torch.Tensor: + """Flat-node -> dense-slot map ``b * max_seqlen + intra_graph_pos``.""" + return batch_idx * max_seqlen + compute_local_index(batch_idx, batch_size, lengths) + + +def pad_dense(x: torch.Tensor, dense_slot, batch_size, max_seqlen) -> torch.Tensor: + """Scatter packed values into ``(batch_size, max_seqlen, *rest)``.""" + flat = x.new_zeros((batch_size * max_seqlen, *x.shape[1:])) + flat[dense_slot] = x + return flat.view(batch_size, max_seqlen, *x.shape[1:]) + + +def unpad_dense(x_dense: torch.Tensor, dense_slot) -> torch.Tensor: + """Gather dense ``(B, max_seqlen, *rest)`` back to packed ``(total_N, *rest)``.""" + return x_dense.reshape(-1, *x_dense.shape[2:])[dense_slot] + + +class _UniqueGather(torch.autograd.Function): + @staticmethod + def forward(ctx, values, slots): + ctx.save_for_backward(slots) + ctx.input_shape = values.shape + return values[slots] + + @staticmethod + def backward(ctx, grad): + (slots,) = ctx.saved_tensors + result = grad.new_zeros(ctx.input_shape) + result[slots] = grad + return result, None + + +def unpad_dense_unique(values, slots): + """Gather unique dense slots with a direct-copy backward (profiling opt-in). + + Slots must come from compute_dense_index: each real atom owns one slot. + Repeated slots require the general unpad_dense implementation instead. + """ + flat = values.reshape(-1, *values.shape[2:]) + return _UniqueGather.apply(flat, slots) + + +# --------------------------------------------------------------------------- +# Packed self-attention +# --------------------------------------------------------------------------- +def flash_attention_supported(reference: torch.Tensor) -> bool: + """Whether the optional FlashAttention varlen kernel can run on ``reference``.""" + return ( + _HAS_FLASH_ATTN + and reference.is_cuda + and reference.dtype in (torch.float16, torch.bfloat16) + and _flash_attention_device_supported(reference.device) + ) + + +def packed_self_attention( + qkv: torch.Tensor, + cu_seqlens: torch.Tensor, + max_seqlen: int, + batch_idx: torch.Tensor, + *, + pair_bias: torch.Tensor | None = None, + atom_mask: torch.Tensor | None = None, + dense_slot: torch.Tensor | None = None, + q_norm: nn.Module | None = None, + k_norm: nn.Module | None = None, +) -> torch.Tensor: + """Variable-length self-attention on packed sequences. + + Args: + qkv: (total_N, 3, H, D) packed q/k/v. + cu_seqlens: (B+1,) int32 cumulative sequence lengths. + max_seqlen: largest sequence length in the batch (Python int). + batch_idx: (total_N,) batch assignment, used by the SDPA path. + pair_bias: optional dense ``(B, H, N, N)`` additive attention bias. + atom_mask: optional cached ``(B, N)`` valid-atom mask. + dense_slot: cached ``compute_dense_index`` scatter map (avoids repadding). + q_norm: Optional query normalization, fused with CUDA inference packing. + k_norm: Optional key normalization, fused with CUDA inference packing. + + Returns: + (total_N, H, D) + """ + use_flash = pair_bias is None and USE_FLASH_ATTN and flash_attention_supported(qkv) + qkv_padded = None + if dense_slot is not None and atom_mask is not None and not use_flash: + qkv_padded = pack_normalized_qkv(qkv, cu_seqlens, max_seqlen, q_norm, k_norm) + if qkv_padded is None and q_norm is not None and k_norm is not None: + q, k, v = qkv.unbind(dim=1) + qkv = torch.stack((q_norm(q).to(q.dtype), k_norm(k).to(k.dtype), v), dim=1) + + if use_flash: + return flash_attn_varlen_qkvpacked_func( + qkv, cu_seqlens, max_seqlen, dropout_p=0.0, causal=False + ) + + # Dense SDPA handles the biased block and is the portable fallback. + batch_size = cu_seqlens.numel() - 1 + if dense_slot is not None: + if qkv_padded is None: + qkv_padded = pad_dense(qkv, dense_slot, batch_size, max_seqlen) + if atom_mask is None: + flat_mask = torch.zeros( + batch_size * max_seqlen, dtype=torch.bool, device=qkv.device + ) + flat_mask[dense_slot] = True + atom_mask = flat_mask.view(batch_size, max_seqlen) + + def _unpad(out): + return unpad_dense(out, dense_slot) + else: + qkv_padded, inferred_mask = to_dense_batch( + qkv, batch_idx, max_num_nodes=max_seqlen + ) + if atom_mask is None: + atom_mask = inferred_mask + + def _unpad(out): + return out[inferred_mask] + + q, k, v = qkv_padded.unbind(dim=2) + q = q.transpose(1, 2) + k = k.transpose(1, 2) + v = v.transpose(1, 2) + assert atom_mask is not None + attn_mask = pair_bias if pair_bias is not None else atom_mask[:, None, None, :] + if pair_bias is not None and BIASED_ATTENTION_BACKEND == "flex" and q.is_cuda: + # Torch 2.9 accumulates score-mod buffer gradients in FP32. Keep this + # differentiable cast so PairBias receives a gradient of its own dtype. + bias = attn_mask.float() + + def score_mod(score, b, h, qi, ki): + return score + bias[b, h, qi, ki] + + out = _FLEX_ATTENTION(q, k, v, score_mod=score_mod) + else: + out = F.scaled_dot_product_attention(q, k, v, attn_mask=attn_mask) + out = out.transpose(1, 2) + return _unpad(out) + + +# --------------------------------------------------------------------------- +# Self-attention pair bias (distance + edge type + optional atom-pair type) +# --------------------------------------------------------------------------- +class PairBias(nn.Module): + """Per-head additive attention bias from distance, edge, and atom-pair type. + + Logically the bias is:: + + bias[b, h, i, j] = down_proj( GELU( + distance_proj(rbf(||x_i - x_j||)) + + edge_proj(edge_features[i, j]) + + edge_present[i, j] * atom_pair_embedding({atom_type_i, atom_type_j}) + ) )[h] + + where ``edge_features[i, j] = e_embed`` at edges in ``edge_index`` and + zero elsewhere. Distance and hidden features are computed only for real + atom pairs. The small final per-head bias is scattered into the one dense + tensor needed by the biased SDPA block. + + The optional atom-pair table uses an unordered triangular index, so its + contribution is exactly symmetric under swapping atoms ``i`` and ``j``. + It is zero-initialized so enabling it preserves the pre-existing PairBias + at initialization while still receiving gradients through ``down_proj``. + Blocks gate the full attention residual via adaLN-Zero. + Padded pairs are ``-inf``-masked. ``down_proj`` is left at its Xavier init + (inherited from the parent backbone's ``_basic_init``). + """ + + def __init__( + self, + rbf_embedding: nn.Module, + rbf_out_dim: int, + edge_nf: int, + num_heads: int, + hidden_dim: int, + num_atom_types: int | None = None, + ): + super().__init__() + if num_atom_types is not None and num_atom_types <= 0: + raise ValueError("num_atom_types must be positive when provided") + self.num_heads = num_heads + self.num_atom_types = num_atom_types + self.rbf_embedding = rbf_embedding + self.dist_to_hidden = nn.Linear(rbf_out_dim, hidden_dim, bias=True) + self.edge_to_hidden = nn.Linear(edge_nf, hidden_dim, bias=False) + self.atom_pair_embedding: nn.Embedding | None = None + if num_atom_types is not None: + num_unordered_pairs = num_atom_types * (num_atom_types + 1) // 2 + self.atom_pair_embedding = nn.Embedding(num_unordered_pairs, hidden_dim) + nn.init.zeros_(self.atom_pair_embedding.weight) + self.act = nn.GELU(approximate="tanh") + self.down_proj = nn.Linear(hidden_dim, num_heads, bias=False) + + def _project_hidden(self, hidden: torch.Tensor) -> torch.Tensor: + """Read out one shared geometry bias per attention head.""" + return self.down_proj(self.act(hidden)) + + def _atom_pair_indices( + self, atom_types_i: torch.Tensor, atom_types_j: torch.Tensor + ) -> torch.Tensor: + """Return unordered triangular indices for paired atom types.""" + assert self.num_atom_types is not None + lower = torch.minimum(atom_types_i, atom_types_j) + upper = torch.maximum(atom_types_i, atom_types_j) + return lower * self.num_atom_types - lower * (lower - 1) // 2 + upper - lower + + def _scatter_bias( + self, + edge_bias, + diagonal_bias, + batch, + local, + rows, + cols, + batch_size, + max_seqlen, + symmetric, + ): + bias = edge_bias.new_full( + (batch_size, self.num_heads, max_seqlen, max_seqlen), -torch.inf + ) + bias[batch, :, local, local] = diagonal_bias + bias[batch[rows], :, local[rows], local[cols]] = edge_bias + if symmetric: + bias[batch[cols], :, local[cols], local[rows]] = edge_bias + return bias + + def _edge_hidden( + self, + e_embed: torch.Tensor, + rows: torch.Tensor, + cols: torch.Tensor, + atom_types: torch.Tensor | None, + ) -> torch.Tensor: + """Project edge features with the optional unordered atom-pair term.""" + edge_hidden = self.edge_to_hidden(e_embed) + if self.atom_pair_embedding is not None: + if atom_types is None: + raise ValueError( + "atom_types is required when atom-pair PairBias is enabled" + ) + pair_indices = self._atom_pair_indices(atom_types[rows], atom_types[cols]) + edge_hidden = edge_hidden + self.atom_pair_embedding(pair_indices) + return edge_hidden + + def forward( + self, + x: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + e_embed: torch.Tensor, + batch: torch.Tensor, + batch_size: int, + max_seqlen: int, + atom_types: torch.Tensor | None = None, + lengths: torch.Tensor | None = None, + dense_slot: torch.Tensor | None = None, + triu_edge_positions: torch.Tensor | None = None, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Returns: + bias: (B, num_heads, max_seqlen, max_seqlen) additive attention + bias, already ``-inf`` at padded pairs so softmax masks them. + atom_mask: (B, max_seqlen) True at valid atoms (cached for the + attention path so it doesn't repad). + edge_distance_embed: RBF embeddings aligned with ``edge_index`` or, + when ``triu_edge_positions`` is supplied, those representative + undirected edges, for reuse by the edge decoder. + """ + if lengths is None: + lengths = torch.bincount(batch, minlength=batch_size) + if dense_slot is None: + dense_slot = compute_dense_index(batch, batch_size, max_seqlen, lengths) + local = dense_slot - batch * max_seqlen + + # Runtime molecular graphs are complete and directed without self-edges, + # so edge_index already enumerates every off-diagonal atom pair. Use it + # directly instead of rebuilding sum(n_i**2) pair indices and mapping the + # edges back into that layout. Only the shared zero-distance diagonal is + # missing. + full_rows, full_cols = edge_index + if triu_edge_positions is None: + rows, cols = full_rows, full_cols + selected_e_embed = e_embed + else: + rows = full_rows[triu_edge_positions] + cols = full_cols[triu_edge_positions] + selected_e_embed = e_embed[triu_edge_positions] + edge_distances = (x[rows] - x[cols]).norm(dim=-1) + distance_embed = self.rbf_embedding(torch.cat((edge_distances, x.new_zeros(1)))) + edge_distance_embed = distance_embed[:-1] + diagonal_distance_embed = distance_embed[-1:] + + edge_hidden = self.dist_to_hidden(edge_distance_embed) + edge_hidden = edge_hidden + self._edge_hidden( + selected_e_embed, rows, cols, atom_types + ).to(dtype=edge_hidden.dtype) + edge_bias = self._project_hidden(edge_hidden) + diagonal_bias = self._project_hidden( + self.dist_to_hidden(diagonal_distance_embed) + ) + + bias = self._scatter_bias( + edge_bias, + diagonal_bias, + batch, + local, + rows, + cols, + batch_size, + max_seqlen, + triu_edge_positions is not None, + ) + atom_mask = torch.arange(max_seqlen, device=x.device).unsqueeze( + 0 + ) < lengths.unsqueeze(1) + return ( + bias, + atom_mask, + edge_distance_embed, + ) + + +# --------------------------------------------------------------------------- +# Cross-attention distance biases +# --------------------------------------------------------------------------- +class DistanceAttnBias(nn.Module): + """Additive cross-attention bias from query↔key 3D distances. + + Gives the gated cross-attention a geometric **correspondence prior**: a query + atom attends most strongly to the context atom that sits at the *same + location*, falling off with distance. Returns a ``(B, K, N)`` bias matching + ``cond_token_attn_bias`` (``bias[b, k, n]`` is added to the logit of query + atom ``n`` attending to context token ``k``). Monotone in distance (max at + d=0), i.e. "attend to the co-located atom". + + Well-posed only when queries and keys share a 3D frame. The overall + cross-attn contribution is still governed by the per-block ``attn_gate`` + (~0 at init), so this only shapes WHERE attention lands. + + The single ``(B, K, N)`` map is broadcast over every head, so all heads share + the same learned length scale. + """ + + def __init__(self, init_gamma: float = 1.0): + super().__init__() + # softplus(raw_gamma) is the inverse length-scale² of the falloff. + g = max(float(init_gamma), 1e-4) + raw = torch.log(torch.expm1(torch.tensor(g))) if g < 20 else torch.tensor(g) + self.raw_gamma = nn.Parameter(raw) + + def forward(self, x_q: torch.Tensor, x_k: torch.Tensor) -> torch.Tensor: + """Return negative squared distances scaled by a positive coefficient.""" + # x_q: (B, N, 3) query positions; x_k: (B, K, 3) key positions. + d2 = torch.cdist(x_k.float(), x_q.float()).pow(2) # (B, K, N) + return -F.softplus(self.raw_gamma) * d2 + + +def build_distance_attn_bias( + dist_bias: DistanceAttnBias, + x_q: torch.Tensor, + batch_q: torch.Tensor, + n_pad_q: int, + x_k: torch.Tensor, + batch_k: torch.Tensor, + k_pad: int, + batch_size: int, +) -> torch.Tensor: + """Dense ``(B, k_pad, n_pad_q)`` geometric cross-attn bias (query atom ↔ + co-located context token). Shared plumbing for conditional + wrappers, which differ only in which batch supplies the key tokens. + """ + xq, _ = to_dense_batch(x_q, batch_q, batch_size=batch_size, max_num_nodes=n_pad_q) + xk, _ = to_dense_batch(x_k, batch_k, batch_size=batch_size, max_num_nodes=k_pad) + return dist_bias(xq, xk) diff --git a/geom/explicit_h/runtime/src/morph/model/layers/embedding.py b/geom/explicit_h/runtime/src/morph/model/layers/embedding.py new file mode 100644 index 0000000000000000000000000000000000000000..10bc72dabda728e6e36aa916b70a7b886442973d --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/layers/embedding.py @@ -0,0 +1,394 @@ +import math + +import torch +import torch.nn as nn +import torch.nn.functional as F + + +class Embedding(nn.Module): + """ + Advanced embedding layer for node features. + + Structure: + Input Indices -> Lookup -> Linear -> Norm -> Activation -> Dropout -> Output + """ + + def __init__( + self, + num_embeddings: int, + embedding_dim: int, + out_dim: int = None, + dropout: float = 0.0, + ): + """ + Args: + num_embeddings: Vocabulary size (e.g., number of unique node types). + embedding_dim: Size of the initial lookup vector. + out_dim: Size of the output vector (if None, typically matches + embedding_dim). + dropout: Probability of dropout. + """ + super().__init__() + + # If out_dim isn't specified, keep dimensions constant + if out_dim is None: + out_dim = embedding_dim + + # 1. The Base Lookup + self.emb = nn.Embedding(num_embeddings, embedding_dim) + + # 2. The Feature Projection Block (MLP) + self.projection = nn.Sequential( + nn.Linear(embedding_dim, out_dim), + nn.LayerNorm(out_dim), + nn.GELU(), + nn.Dropout(dropout), + ) + + # Optional: Initialize weights specifically for embeddings if needed + self._init_weights() + + def _init_weights(self): + # Xavier initialization often works better than default for projections + nn.init.xavier_uniform_(self.emb.weight) + for module in self.projection.modules(): + if isinstance(module, nn.Linear): + nn.init.xavier_uniform_(module.weight) + + def forward(self, x): + """Project categorical tokens with independent dropout per occurrence.""" + # Molecular vocabularies are tiny and contain many repeated tokens. + # Project each category once. Training's dense lookup avoids Inductor's + # expensive repeated-index embedding backward in deterministic mode. + # Keep independent dropout draws per occurrence during training, and + # avoid a large one-hot allocation for non-molecular vocabularies. + stochastic = self.training and any( + isinstance(layer, nn.Dropout) and layer.p > 0 for layer in self.projection + ) + if ( + stochastic + or self.emb.num_embeddings > 64 + or x.dtype not in (torch.int32, torch.int64) + ): + return self.projection(self.emb(x)) + table = self.projection(self.emb.weight) + if not torch.is_grad_enabled(): + # Match the dense lookup's autocast output (normalization may have + # promoted the projected table back to FP32). + if ( + torch.is_autocast_enabled(table.device.type) + and table.dtype != torch.float64 + ): + table = table.to(torch.get_autocast_dtype(table.device.type)) + return F.embedding(x, table) + selector = F.one_hot(x.long(), num_classes=self.emb.num_embeddings) + return selector.to(table.dtype) @ table + + +class SinusoidalEncoding(nn.Module): + """ + The mathematical engine for sinusoidal embeddings. + Computes: [sin(x*w0), cos(x*w0), sin(x*w1), cos(x*w1)...] + """ + + def __init__(self, embedding_dim: int, max_period: float = 10000.0): + super().__init__() + if embedding_dim % 2 != 0: + raise ValueError(f"Embedding dimension {embedding_dim} must be even.") + + self.embedding_dim = embedding_dim + half_dim = embedding_dim // 2 + + # Calculate frequencies (DDPM style / Vaswani style) + # We want frequencies ranging from 1 to 1/max_period + # The formula creates a geometric progression of frequencies + lambda_max = max_period + + # Derived from: exp(-2 * i / d * ln(10000)) + # This matches the JAX 'half_dim - 1' logic for exact boundary alignment + exponent = -math.log(lambda_max) * torch.arange( + start=0, end=half_dim, dtype=torch.float32 + ) + exponent = exponent / (half_dim - 1) + + freqs = torch.exp(exponent) + + # Register as fixed buffer (not a learnable parameter) + self.register_buffer("freqs", freqs) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """ + Args: + x: Tensor of shape [Batch, 1] (values can be float or int) + + Returns: + Tensor of shape [Batch, embedding_dim] + """ + # x: [Batch, 1], freqs: [Half_Dim] + # args: [Batch, Half_Dim] + args = x.float() * self.freqs.unsqueeze(0) + + # Concatenate sin and cos -> [Batch, Dim] + embedding = torch.cat([torch.sin(args), torch.cos(args)], dim=-1) + + return embedding + + +class ExtrapolatableCountEmbedding(nn.Module): + """Count embedding designed to extrapolate slightly outside the training range. + + A naive sinusoidal count embedding whose shortest period is ``2π`` (a + geometric series of frequencies starting at ``freq=1``) produces aliased, + non-monotonic features for small integer counts (e.g. QM9 with counts in + ``[3, 29]``): neighbouring counts map to unrelated sin/cos patterns, and + counts just outside the training support land on patterns the downstream + linear projection has never seen — so it cannot extrapolate. + + This module fixes both issues: + + * It uses a **low-frequency** Fourier basis whose shortest period is larger + than the expected training support (``min_period``), so every dimension + varies smoothly over the training range and its immediate neighbourhood. + * It concatenates a **raw normalised scalar** ``count / max_count``, giving + the MLP a strictly linear (monotonic, unbounded) axis to extrapolate + along — the Fourier basis only contributes local precision within range. + + With defaults tuned for QM9 (``min_period=64``, ``max_period=512``, + ``max_count=32``), counts in ``[0, 32]`` map to half a period at most on + any Fourier component, and ``count=30`` — just outside the training + support — produces an embedding that is a smooth continuation of the + ``count=29`` embedding rather than a novel aliased fingerprint. + + Input: Long / Float Tensor ``[Batch]`` or ``[Batch, 1]``. + Output: Float Tensor ``[Batch, out_dim]``. + """ + + def __init__( + self, + embedding_dim: int, + out_dim: int = None, + min_period: float = 64.0, + max_period: float = 512.0, + max_count: float = 32.0, + ): + super().__init__() + if out_dim is None: + out_dim = embedding_dim + if embedding_dim % 2 != 0: + raise ValueError( + f"embedding_dim {embedding_dim} must be even (sin/cos pairs)." + ) + if min_period <= 0 or max_period < min_period: + raise ValueError( + f"Expected 0 < min_period <= max_period, got " + f"min_period={min_period}, max_period={max_period}." + ) + if max_count <= 0: + raise ValueError(f"max_count must be > 0, got {max_count}.") + + self.embedding_dim = embedding_dim + self.out_dim = out_dim + self.min_period = float(min_period) + self.max_period = float(max_period) + self.max_count = float(max_count) + + # Log-spaced periods in [min_period, max_period]. Highest frequency + # has the *shortest* period (= min_period), ensuring every component + # stays in a monotonic half-cycle across [0, max_count]. + half_dim = embedding_dim // 2 + if half_dim == 1: + periods = torch.tensor([min_period], dtype=torch.float32) + else: + log_pmin = math.log(min_period) + log_pmax = math.log(max_period) + periods = torch.exp(torch.linspace(log_pmin, log_pmax, half_dim)) + self.register_buffer("freqs", 2 * math.pi / periods) + + # MLP takes [fourier_features, raw_scalar] and mixes them. The raw + # scalar is the path responsible for linear extrapolation. + self.projection = nn.Sequential( + nn.Linear(embedding_dim + 1, out_dim), + nn.SiLU(), + nn.Linear(out_dim, out_dim), + ) + + self._init_weights() + + def _init_weights(self): + for m in self.modules(): + if isinstance(m, nn.Linear): + nn.init.xavier_uniform_(m.weight) + if m.bias is not None: + nn.init.zeros_(m.bias) + + def forward(self, counts: torch.Tensor) -> torch.Tensor: + """Embed counts using Fourier features and a scaled scalar count.""" + if counts.ndim == 1: + counts = counts.unsqueeze(-1) + + x = counts.float() + args = x * self.freqs.unsqueeze(0) + fourier = torch.cat([torch.sin(args), torch.cos(args)], dim=-1) + scalar = x / self.max_count + return self.projection(torch.cat([fourier, scalar], dim=-1)) + + +class TimeEmbedding(nn.Module): + """ + Embeds continuous time/noise levels. + Input: Float Tensor [Batch, 1] (e.g., values 0.0 to 1.0) + Output: Float Tensor [Batch, Dim] + """ + + def __init__(self, embedding_dim: int, out_dim: int = None): + super().__init__() + if out_dim is None: + out_dim = embedding_dim + # For time in [0,1], max_period=10000 ensures high precision + # for even very small time steps. + self.encoder = SinusoidalEncoding(embedding_dim, max_period=5000.0) + + # Standard MLP block for Diffusion/Flow models + # Projects the fixed sinusoids into the learnable semantic space + self.mlp = nn.Sequential( + nn.Linear(embedding_dim, embedding_dim), + nn.SiLU(), # SiLU is standard for time + nn.Linear(embedding_dim, out_dim), + ) + + def forward(self, times: torch.Tensor) -> torch.Tensor: + """Encode graph times after scaling the unit interval by 1000.""" + # Ensure input is [Batch, 1] + if times.ndim == 1: + times = times.unsqueeze(-1) + + times = times * 1000.0 + t_enc = self.encoder(times) + return self.mlp(t_enc) + + +class RBFEncoding(nn.Module): + """ + Trainable Radial Basis Function (RBF) encoding. + Both the centers and the widths (sigmas) of the Gaussians are learnable. + """ + + def __init__( + self, num_rbf: int = 16, rbf_dmax: float = 10.0, trainable: bool = True + ): + super().__init__() + self.num_rbf = num_rbf + self.rbf_dmax = rbf_dmax + + # 1. Initialize centers evenly spaced (same as before) + initial_centers = torch.linspace(0, rbf_dmax, num_rbf) + + # 2. Initialize sigmas (widths) + # The gap between centers is dmax / num_rbf. + # A good default sigma is often the gap size itself or slightly smaller. + initial_sigma = (rbf_dmax / num_rbf) * torch.ones(num_rbf) + + if trainable: + # Wrap in nn.Parameter to enable gradient descent + self.rbf_centers = nn.Parameter(initial_centers) + self.rbf_sigma = nn.Parameter(initial_sigma) + else: + # Keep them fixed if desired + self.register_buffer("rbf_centers", initial_centers) + self.register_buffer("rbf_sigma", initial_sigma) + + def forward(self, distances: torch.Tensor) -> torch.Tensor: + """Returns: [N, num_rbf]""" + if distances.ndim > 1: + distances = distances.squeeze(-1) + + # Broadcasting logic: + # distances: [N, 1] + # centers/sigmas: [num_rbf] + + # We calculate gamma dynamically based on the current learnable sigma + # Gamma = 1 / sigma^2 + # We use abs() or clamp() on sigma to ensure widths stay positive and non-zero + sigmas = torch.abs(self.rbf_sigma) + 1e-5 # Stability trick + gamma = 1.0 / (sigmas**2) + + # Formula: exp( -gamma * (x - mu)^2 ) + return torch.exp(-gamma * (distances.unsqueeze(-1) - self.rbf_centers) ** 2) + + +class RBFEmbedding(nn.Module): + """ + Radial Basis Function (RBF) embedding for distances with projection layer. + Wraps RBFEncoding with a learnable projection MLP. + + Structure: + Input Distances -> RBFEncoding -> Linear -> Norm -> Activation -> Dropout -> Output + + Input: Float Tensor [N] (distances) + Output: Float Tensor [N, out_dim] + """ + + def __init__( + self, + num_rbf: int = 16, + rbf_dmax: float = 10.0, + out_dim: int = None, + dropout: float = 0.0, + trainable: bool = True, + ): + """ + Args: + num_rbf: Number of RBF basis functions + rbf_dmax: Maximum distance for RBF + out_dim: Output dimension (if None, uses num_rbf) + dropout: Dropout probability + trainable: Whether RBF centers and widths receive gradients. + """ + super().__init__() + + # If out_dim isn't specified, keep dimensions constant + if out_dim is None: + out_dim = num_rbf + + # Store out_dim for easy access + self.out_dim = out_dim + + # RBF encoding layer + self.rbf_encoding = RBFEncoding( + num_rbf=num_rbf, rbf_dmax=rbf_dmax, trainable=trainable + ) + + # Simple projection block (matching Embedding class style) + self.projection = nn.Sequential( + nn.Linear(num_rbf, out_dim), + nn.LayerNorm(out_dim), + nn.GELU(), + nn.Dropout(dropout), + ) + + # Initialize weights + self._init_weights() + + def _init_weights(self): + """Xavier initialization for projection layers.""" + for module in self.projection.modules(): + if isinstance(module, nn.Linear): + nn.init.xavier_uniform_(module.weight) + if module.bias is not None: + nn.init.zeros_(module.bias) + + def forward(self, distances: torch.Tensor) -> torch.Tensor: + """ + Compute RBF embeddings for distances. + + Args: + distances: Tensor of shape [N] or [N, 1] containing distances + + Returns: + Tensor of shape [N, out_dim] containing RBF embeddings + """ + # Get RBF encoding + rbf_enc = self.rbf_encoding(distances) # [N, num_rbf] + + # Apply projection + return self.projection(rbf_enc) # [N, out_dim] diff --git a/geom/explicit_h/runtime/src/morph/model/layers/gmm.py b/geom/explicit_h/runtime/src/morph/model/layers/gmm.py new file mode 100644 index 0000000000000000000000000000000000000000..d64bcd8ca9f257052d2d5bde9543efe2459c0305 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/layers/gmm.py @@ -0,0 +1,173 @@ +""" +GMM utilities for birth-death flow matching. + +This module contains utilities for working with Gaussian Mixture Models (GMMs) +in the context of birth-death flow matching, including parameter extraction, +GMM creation, sampling, and loss computation. +""" + +import torch +from torch.distributions import Categorical, Independent, Normal + + +def get_typed_gmm_components(gmm_output): + """ + Build component distributions from the typed insertion head's outputs. + + Args: + gmm_output (dict): Parameters for B spawning nodes and K components: + - 'mu': [B, K, D] means in normalized coordinates + - 'sigma': [B, K] positive isotropic scales + - 'pi': [B, K] normalized mixture weights + - 'a_probs': [B, K, N_atom_types] atom-type probabilities + - 'c_probs': [B, K, N_charge_types] charge probabilities + + Returns: + tuple: + - mixture_weights (Categorical): Batch shape [B, 1]. Event shape []. + - x_dist (Independent Normal): Batch shape [B, 1, K]. Event shape [D]. + - a_dist (Categorical): atom-type dist. Batch shape [B, 1, K]. Event shape + []. + - c_dist (Categorical): charge-type dist. Batch shape [B, 1, K]. Event shape + []. + """ + + # 1. Unpack and handle single-batch case (if necessary) + mu = gmm_output["mu"] # [B, K, D] + sigma = gmm_output["sigma"] # [B, K] + pi = gmm_output["pi"] # [B, K] + a_probs = gmm_output["a_probs"] # [B, K, T] + c_probs = gmm_output["c_probs"] # [B, K, T] + + # If the input was unbatched (K, D), add batch dim to make it (1, K, D) + if mu.dim() == 2: + mu = mu.unsqueeze(0) + sigma = sigma.unsqueeze(0) + pi = pi.unsqueeze(0) + a_probs = a_probs.unsqueeze(0) + c_probs = c_probs.unsqueeze(0) + + # 2. Reshape for Broadcasting: (B, ...) -> (B, 1, ...) + # This prepares the distributions to be evaluated against N_samples + # (broadcasting B, 1, K against B, N, K) + + # --- Mixture Weights --- + # Shape: [B, K] -> [B, 1, K] + # The insertion head already applies softmax to the mixture weights. + # Cast to fp32 + renormalize so bf16 softmax rounding doesn't trip the + # simplex tolerance enforced by ``Categorical(validate_args=True)``. + pi32 = pi.float() + pi32 = pi32 / pi32.sum(dim=-1, keepdim=True).clamp_min(1e-12) + mixture_weights = Categorical(probs=pi32.unsqueeze(1)) + + # --- Spatial Distribution --- + # Means: [B, K, D] -> [B, 1, K, D] + mu_expanded = mu.unsqueeze(1) + + # Sigmas: [B, K] -> [B, 1, K, 1] + # Note: We append a trailing 1 so it broadcasts against the D dimension of Mu. + # Since sigma is isotropic, it applies to x, y, and z equally. + sigma_expanded = sigma.unsqueeze(1).unsqueeze(-1) + + # Create Normal [B, 1, K, D] -> Wrap in Independent to sum log-probs over D + x_dist = Independent(Normal(mu_expanded, sigma_expanded), 1) + + # --- Type Distribution --- + # Shape: [B, K, T] -> [B, 1, K, T] + # Same fp32 + renormalize trick as the mixture weights above. + a32 = a_probs.float() + a32 = a32 / a32.sum(dim=-1, keepdim=True).clamp_min(1e-12) + c32 = c_probs.float() + c32 = c32 / c32.sum(dim=-1, keepdim=True).clamp_min(1e-12) + a_dist = Categorical(probs=a32.unsqueeze(1)) + c_dist = Categorical(probs=c32.unsqueeze(1)) + + return mixture_weights, x_dist, a_dist, c_dist + + +def sample_from_typed_gmm( + gmm_params, num_samples, K=10, D=1, N_a=4, N_c=4, squeeze_output=False +): + """ + Sample from the joint GMM created from predicted parameters. + + This function manually implements the 3-step sampling process: + 1. Sample component indices m ~ p(m) + 2. Gather parameters for p(x|m) and p(c|m) + 3. Sample types c ~ p(c|m) and locations x ~ p(x|m) + + Args: + gmm_params (dict): Parameters accepted by ``get_typed_gmm_components``. + num_samples (int): Number of samples to draw. + K (int): Compatibility argument; component count comes from the tensors. + D (int): Dimension of the data. + N_a (int): Number of discrete atom types. + N_c (int): Number of discrete charge types. + squeeze_output (bool): Remove a singleton leading batch dimension. + + Returns: + tuple: + - Locations: [B, num_samples, D]. + - Atom types: [B, num_samples]. + - Charges: [B, num_samples]. + The leading B dimension is removed when requested and B equals one. + """ + # --- 1. Get GMM Distributions --- + # mixture_weights: batch_shape=[B, 1] + # spatial_dist: batch_shape=[B, 1, K] + # type_dist: batch_shape=[B, 1, K] + mixture_weights, x_dist, a_dist, c_dist = get_typed_gmm_components(gmm_params) + + # --- 2. Sample Component Indices --- + # mixture_weights.sample() prepends sample_shape to batch_shape + # Shape: (num_samples, B, 1) + component_indices = mixture_weights.sample((num_samples,)) + + # Reshape to (B, num_samples) for gathering + component_indices = component_indices.permute(1, 0, 2).squeeze(-1) + + # --- 3. Gather Parameters for Chosen Components --- + + # Get all component parameters, squeezing out the '1' dim + # (B, 1, K, D) -> (B, K, D) + means_all = x_dist.base_dist.loc.squeeze(1) + scales_all = x_dist.base_dist.scale.squeeze(1) + # (B, 1, K, N_types) -> (B, K, N_types) + logits_all_a = a_dist.logits.squeeze(1) + logits_all_c = c_dist.logits.squeeze(1) + + # Create expanded indices for gathering + # (B, num_samples) -> (B, num_samples, 1) + idx = component_indices.unsqueeze(-1) + + # (B, num_samples, 1) -> (B, num_samples, D) + idx_locs = idx.expand(-1, -1, D) + # (B, num_samples, 1) -> (B, num_samples, N_types) + idx_a = idx.expand(-1, -1, N_a) + idx_c = idx.expand(-1, -1, N_c) + + # Gather the parameters for the sampled components + # We gather from dim 1 (the K dim) + # torch.gather(input, dim, index) + chosen_means = torch.gather(means_all, 1, idx_locs) + chosen_scales = torch.gather(scales_all, 1, idx_locs) + chosen_logits_a = torch.gather(logits_all_a, 1, idx_a) + chosen_logits_c = torch.gather(logits_all_c, 1, idx_c) + + # --- 4. Sample from Chosen Components --- + # Sample locations: x ~ p(x|m*) + # Shape: (B, num_samples, D) + sampled_x = Normal(chosen_means, chosen_scales).sample() + + # Sample types: c ~ p(c|m*) + # Shape: (B, num_samples) + sampled_a = Categorical(logits=chosen_logits_a).sample() + sampled_c = Categorical(logits=chosen_logits_c).sample() + + # --- 5. Handle non-batched output --- + if squeeze_output: + sampled_x = sampled_x.squeeze(0) + sampled_a = sampled_a.squeeze(0) + sampled_c = sampled_c.squeeze(0) + + return sampled_x, sampled_a, sampled_c diff --git a/geom/explicit_h/runtime/src/morph/model/layers/heads.py b/geom/explicit_h/runtime/src/morph/model/layers/heads.py new file mode 100644 index 0000000000000000000000000000000000000000..45ff2ae7907244eaa16c644305ce79c9d743a799 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/layers/heads.py @@ -0,0 +1,780 @@ +"""Prediction heads for the molecular DiT. + +Shared SwiGLU adapters feed compact task projections. Inserted--inserted bonds +use an undirected decoder that observes both endpoints and both spawn contexts. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import hydra +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch_geometric.utils import scatter + +if TYPE_CHECKING: + from omegaconf import DictConfig + + from morph.dataset.core.insertion_data import InsertedAtoms + + +def _small_output_init(linear: nn.Linear, std: float) -> None: + if std < 0: + raise ValueError(f"output init std must be non-negative, got {std}") + if std == 0: + nn.init.zeros_(linear.weight) + else: + nn.init.normal_(linear.weight, mean=0.0, std=std) + if linear.bias is not None: + nn.init.zeros_(linear.bias) + + +def _logit(probability: float) -> float: + if not 0.0 < probability < 1.0: + raise ValueError( + "initial_probability must lie strictly between zero and one, " + f"got {probability}" + ) + return math.log(probability) - math.log1p(-probability) + + +def _inverse_softplus(rate: float) -> float: + if rate <= 0: + raise ValueError(f"initial_rate must be positive, got {rate}") + # log(expm1(x)) is the inverse of softplus. Avoid overflow for large x. + return rate if rate > 20.0 else math.log(math.expm1(rate)) + + +def _set_categorical_bias(linear: nn.Linear, probabilities: torch.Tensor) -> None: + probabilities = torch.as_tensor( + probabilities, device=linear.bias.device, dtype=linear.bias.dtype + ).flatten() + if probabilities.numel() != linear.out_features: + raise ValueError( + f"categorical prior has {probabilities.numel()} entries, expected " + f"{linear.out_features}" + ) + log_prior = probabilities.clamp_min(1e-8).log() + # Softmax is invariant to a shared constant. Centering avoids an unnecessary + # large common bias for vocabularies with very small probabilities. + linear.bias.data.copy_(log_prior - log_prior.mean()) + + +class SwiGLUReadoutAdapter(nn.Module): + """Pre-normalized, width-reducing SwiGLU feature adapter.""" + + def __init__(self, input_dim: int, output_dim: int): + super().__init__() + if input_dim <= 0 or output_dim <= 0: + raise ValueError( + f"adapter dimensions must be positive, got {input_dim}->{output_dim}" + ) + self.input_dim = input_dim + self.output_dim = output_dim + self.norm = nn.RMSNorm(input_dim, eps=1e-6) + self.gate_value = nn.Linear(input_dim, 2 * output_dim) + nn.init.xavier_uniform_(self.gate_value.weight) + nn.init.zeros_(self.gate_value.bias) + + def forward(self, features: torch.Tensor) -> torch.Tensor: + """Normalize features and apply a SiLU-gated readout adapter.""" + gate, value = self.gate_value(self.norm(features)).chunk(2, dim=-1) + return F.silu(gate) * value + + +class MultiHeadModule(nn.Module): + """Shared readout adapters with task-specific projections. + + Each head config retains ``input_dim`` and ``output_dim`` and additionally + selects an adapter ``group``. Heads of the same level/group reuse one adapter. + ``self.heads`` intentionally remains a ModuleDict of the final task projections: + shared training code uses ``heads[name].parameters()`` for DDP-safe dummies. + """ + + _LEVEL_CONFIG_KEYS = { + "node": "node_heads", + "graph": "graph_heads", + "edge": "edge_heads", + } + + def __init__( + self, + heads_configs: DictConfig, + output_init_std: float = 1e-3, + independent_adapters: bool = False, + ): + super().__init__() + self.output_init_std = float(output_init_std) + self.independent_adapters = bool(independent_adapters) + self.graph_aggregation = heads_configs.get("graph_aggregation", "mean") + self.adapters = nn.ModuleDict() + self.heads = nn.ModuleDict() + self.head_types: dict[str, str] = {} + self.head_adapter_keys: dict[str, str] = {} + self.has_graph_heads = False + self.has_edge_heads = False + + adapter_specs: dict[str, tuple[int, int]] = {} + for level, config_key in self._LEVEL_CONFIG_KEYS.items(): + level_configs = heads_configs.get(config_key) + if level_configs is None: + continue + if level == "graph": + self.has_graph_heads = True + elif level == "edge": + self.has_edge_heads = True + + for name, config in level_configs.items(): + input_dim = int(config.input_dim) + output_dim = int(config.output_dim) + adapter_dim = int(config.adapter_dim) + group = ( + name + if self.independent_adapters + else str(config.get("group", "default")) + ) + adapter_key = f"{level}_{group}" + spec = (input_dim, adapter_dim) + if adapter_key in adapter_specs and adapter_specs[adapter_key] != spec: + raise ValueError( + f"heads sharing adapter {adapter_key!r} disagree on " + "dimensions: " + f"{adapter_specs[adapter_key]} versus {spec}" + ) + adapter_specs[adapter_key] = spec + + projection = nn.Linear(adapter_dim, output_dim) + _small_output_init(projection, self.output_init_std) + if "initial_probability" in config: + nn.init.constant_( + projection.bias, _logit(float(config.initial_probability)) + ) + elif "initial_rate" in config: + nn.init.constant_( + projection.bias, _inverse_softplus(float(config.initial_rate)) + ) + + self.heads[name] = projection + self.head_types[name] = level + self.head_adapter_keys[name] = adapter_key + + for key, (input_dim, output_dim) in adapter_specs.items(): + self.adapters[key] = SwiGLUReadoutAdapter(input_dim, output_dim) + self.rate_heads = tuple(name for name in self.heads if "rate" in name) + self.edge_heads = tuple( + name for name, level in self.head_types.items() if level == "edge" + ) + + def initialize_class_priors( + self, + *, + atom: torch.Tensor | None = None, + charge: torch.Tensor | None = None, + edge: torch.Tensor | None = None, + ) -> None: + """Initialize categorical output biases from token probabilities.""" + priors = { + "atom_type_head": atom, + "charge_head": charge, + "edge_type_head": edge, + } + for name, probabilities in priors.items(): + if probabilities is not None and name in self.heads: + _set_categorical_bias(self.heads[name], probabilities) + + def _graph_features(self, h: torch.Tensor, batch: torch.Tensor) -> torch.Tensor: + if batch is None: + raise ValueError("batch is required when graph heads are configured") + num_graphs = int(batch.max()) + 1 if batch.numel() else 0 + if self.graph_aggregation == "mean": + return scatter(h, batch, dim=0, dim_size=num_graphs, reduce="mean") + if self.graph_aggregation == "sum": + return scatter(h, batch, dim=0, dim_size=num_graphs, reduce="sum") + raise ValueError(f"Unknown graph aggregation: {self.graph_aggregation}") + + def forward( + self, + h: torch.Tensor, + batch: torch.Tensor | None, + edge_attr: torch.Tensor | None = None, + full_to_triu: torch.Tensor | None = None, + ) -> dict[str, torch.Tensor]: + """Apply configured node, graph, and edge prediction heads.""" + sources: dict[str, torch.Tensor] = {"node": h} + if self.has_graph_heads: + sources["graph"] = self._graph_features(h, batch) + if self.has_edge_heads: + if edge_attr is None: + raise ValueError("edge_attr is required when edge heads are configured") + sources["edge"] = edge_attr + + adapter_cache: dict[str, torch.Tensor] = {} + outputs: dict[str, torch.Tensor] = {} + for name, projection in self.heads.items(): + adapter_key = self.head_adapter_keys[name] + if adapter_key not in adapter_cache: + level = self.head_types[name] + adapter_cache[adapter_key] = self.adapters[adapter_key](sources[level]) + outputs[name] = projection(adapter_cache[adapter_key]) + + for name in self.rate_heads: + outputs[name] = F.softplus(outputs[name]) + if full_to_triu is not None: + for name in self.edge_heads: + outputs[name] = outputs[name][full_to_triu] + return outputs + + +class UnimodalInsertionHead(nn.Module): + """Shared-adapter position/type head for the nearest pending insertion.""" + + def __init__( + self, + hidden_dim: int, + N_a: int, + N_c: int, + readout_dim: int, + output_init_std: float = 1e-3, + independent_adapters: bool = False, + ): + super().__init__() + self.N_a = int(N_a) + self.N_c = int(N_c) + self.independent_adapters = bool(independent_adapters) + if self.independent_adapters: + self.adapters = nn.ModuleDict( + { + name: SwiGLUReadoutAdapter(hidden_dim, readout_dim) + for name in ("atom", "charge", "offset") + } + ) + else: + self.adapter = SwiGLUReadoutAdapter(hidden_dim, readout_dim) + self.atom_projection = nn.Linear(readout_dim, self.N_a) + self.charge_projection = nn.Linear(readout_dim, self.N_c) + self.offset_projection = nn.Linear(readout_dim, 3) + _small_output_init(self.atom_projection, output_init_std) + _small_output_init(self.charge_projection, output_init_std) + # A small nonzero output lets an independent offset adapter receive + # gradients immediately while keeping the initial mean near the spawn. + _small_output_init(self.offset_projection, output_init_std) + + def initialize_class_priors( + self, + atom: torch.Tensor | None = None, + charge: torch.Tensor | None = None, + ) -> None: + """Initialize inserted-atom and charge biases from token priors.""" + if atom is not None: + _set_categorical_bias(self.atom_projection, atom) + if charge is not None: + _set_categorical_bias(self.charge_projection, charge) + + def forward( + self, + h: torch.Tensor, + x: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + ) -> dict[str, torch.Tensor]: + """Predict one insertion location and categorical logits per node.""" + del edge_index + if self.independent_adapters: + atom_features = self.adapters["atom"](h) + charge_features = self.adapters["charge"](h) + offset_features = self.adapters["offset"](h) + else: + features = self.adapter(h) + atom_features = charge_features = offset_features = features + return { + "mu": x + self.offset_projection(offset_features), + "a_logits": self.atom_projection(atom_features), + "c_logits": self.charge_projection(charge_features), + } + + +class GMMInsertionHead(nn.Module): + """Compact, numerically bounded mixture readout for insertion content. + + Cartesian component means are residual offsets from the spawning atom. A + shared normalized adapter feeds independent projections for mixture weights, scales, + categorical content and component offsets. In particular, scales are + bounded in log space: this prevents both variance collapse (exploding NLL) + and an arbitrarily broad component that can cheaply absorb several targets. + """ + + def __init__( + self, + hidden_dim: int, + num_components: int, + N_a: int, + N_c: int, + readout_dim: int | None = None, + output_init_std: float = 1e-3, + sigma_min: float = 0.05, + sigma_max: float = 2.0, + sigma_init: float = 0.5, + ): + super().__init__() + if readout_dim is None: + readout_dim = hidden_dim + if not 0.0 < sigma_min < sigma_init < sigma_max: + raise ValueError( + "GMM scales must satisfy 0 < sigma_min < sigma_init < " + f"sigma_max, got {sigma_min}, {sigma_init}, {sigma_max}" + ) + + self.num_components = int(num_components) + self.N_a = int(N_a) + self.N_c = int(N_c) + self.sigma_min = float(sigma_min) + self.sigma_max = float(sigma_max) + + self.adapter = SwiGLUReadoutAdapter(hidden_dim, readout_dim) + self.mixture_projection = nn.Linear(readout_dim, self.num_components) + self.scale_projection = nn.Linear(readout_dim, self.num_components) + self.atom_projection = nn.Linear(readout_dim, self.num_components * self.N_a) + self.charge_projection = nn.Linear(readout_dim, self.num_components * self.N_c) + self.offset_projection = nn.Linear(readout_dim, self.num_components * 3) + + # Uniform component weights, exact categorical marginals and a fixed, + # useful initial scale make the first NLL finite and predictable. Small + # independent offset weights break component symmetry without initially + # displacing the means much. + _small_output_init(self.mixture_projection, 0.0) + _small_output_init(self.scale_projection, 0.0) + _small_output_init(self.atom_projection, 0.0) + _small_output_init(self.charge_projection, 0.0) + _small_output_init(self.offset_projection, output_init_std) + + log_min = math.log(self.sigma_min) + log_max = math.log(self.sigma_max) + init_fraction = (math.log(sigma_init) - log_min) / (log_max - log_min) + nn.init.constant_(self.scale_projection.bias, _logit(init_fraction)) + + def initialize_class_priors( + self, + atom: torch.Tensor | None = None, + charge: torch.Tensor | None = None, + ) -> None: + """Initialize every component with the empirical categorical marginals.""" + if atom is not None: + bias = self.atom_projection.bias + atom = torch.as_tensor(atom, device=bias.device, dtype=bias.dtype) + if atom.numel() != self.N_a: + raise ValueError( + f"atom prior has {atom.numel()} entries, expected {self.N_a}" + ) + atom_logits = atom.clamp_min(1e-8).log() + atom_logits = atom_logits - atom_logits.mean() + bias.data.copy_(atom_logits.repeat(self.num_components)) + if charge is not None: + bias = self.charge_projection.bias + charge = torch.as_tensor(charge, device=bias.device, dtype=bias.dtype) + if charge.numel() != self.N_c: + raise ValueError( + f"charge prior has {charge.numel()} entries, expected {self.N_c}" + ) + charge_logits = charge.clamp_min(1e-8).log() + charge_logits = charge_logits - charge_logits.mean() + bias.data.copy_(charge_logits.repeat(self.num_components)) + + def forward( + self, + h: torch.Tensor, + x: torch.Tensor, + edge_index: tuple[torch.Tensor, torch.Tensor], + ) -> dict[str, torch.Tensor]: + """Predict typed Gaussian mixture parameters for each spawning node.""" + del edge_index + n_nodes = h.shape[0] + features = self.adapter(h) + scale_fraction = torch.sigmoid(self.scale_projection(features)) + log_sigma = math.log(self.sigma_min) + scale_fraction * ( + math.log(self.sigma_max) - math.log(self.sigma_min) + ) + return { + "pi": F.softmax(self.mixture_projection(features), dim=-1), + "mu": x.unsqueeze(1) + + self.offset_projection(features).view(n_nodes, self.num_components, 3), + "sigma": log_sigma.exp(), + "a_probs": F.softmax( + self.atom_projection(features).view( + n_nodes, self.num_components, self.N_a + ), + dim=-1, + ), + "c_probs": F.softmax( + self.charge_projection(features).view( + n_nodes, self.num_components, self.N_c + ), + dim=-1, + ), + } + + +class InsertionEdgeHead(nn.Module): + """Compact insertion-edge head with a symmetric inserted-pair branch. + + Inserted-to-existing bonds retain their meaningful role asymmetry. For a bond + between two simultaneously inserted atoms, both endpoints are encoded with the + same function and combined through sum/absolute-difference features. + """ + + def __init__( + self, + hidden_dim: int, + n_edge_types: int, + n_atom_types: int, + n_charge_types: int, + readout_dim: int, + rbf_embedding_args: DictConfig | None = None, + output_init_std: float = 1e-3, + ): + super().__init__() + if rbf_embedding_args is None: + raise ValueError("rbf_embedding_args is required") + self.hidden_dim = hidden_dim + self.n_edge_types = n_edge_types + self.n_atom_types = n_atom_types + self.n_charge_types = n_charge_types + self.rbf_embedding = hydra.utils.instantiate(rbf_embedding_args) + self._rbf_has_dropout = float(rbf_embedding_args.get("dropout", 0.0)) > 0 + + dist_feat_dim = int( + rbf_embedding_args.get("out_dim", rbf_embedding_args.get("num_rbf", 16)) + ) + inserted_attr_dim = n_atom_types + n_charge_types + existing_input_dim = ( + 2 * hidden_dim + 2 * dist_feat_dim + n_atom_types + inserted_attr_dim + ) + self.edge_mlp = self._make_decoder( + existing_input_dim, readout_dim, n_edge_types, output_init_std + ) + + endpoint_input_dim = hidden_dim + dist_feat_dim + inserted_attr_dim + self.inserted_endpoint = nn.Sequential( + nn.LayerNorm(endpoint_input_dim), + nn.Linear(endpoint_input_dim, readout_dim), + nn.SiLU(), + ) + pair_input_dim = 2 * readout_dim + dist_feat_dim + self.inserted_pair_decoder = self._make_decoder( + pair_input_dim, readout_dim, n_edge_types, output_init_std + ) + nn.init.xavier_uniform_(self.inserted_endpoint[1].weight) + nn.init.zeros_(self.inserted_endpoint[1].bias) + + @staticmethod + def _make_decoder( + input_dim: int, + hidden_dim: int, + output_dim: int, + output_init_std: float, + ) -> nn.Sequential: + decoder = nn.Sequential( + nn.LayerNorm(input_dim), + nn.Linear(input_dim, hidden_dim), + nn.SiLU(), + nn.Linear(hidden_dim, output_dim), + ) + nn.init.kaiming_normal_(decoder[1].weight, nonlinearity="relu") + nn.init.zeros_(decoder[1].bias) + _small_output_init(decoder[-1], output_init_std) + return decoder + + def initialize_edge_prior(self, probabilities: torch.Tensor) -> None: + """Initialize both insertion-bond decoders from bond probabilities.""" + _set_categorical_bias(self.edge_mlp[-1], probabilities) + _set_categorical_bias(self.inserted_pair_decoder[-1], probabilities) + + def _inserted_endpoint_features( + self, + h: torch.Tensor, + x: torch.Tensor, + spawn_idx: torch.Tensor, + inserted: InsertedAtoms, + ) -> torch.Tensor: + atom = F.one_hot(inserted.a.long(), num_classes=self.n_atom_types).to( + device=h.device, dtype=h.dtype + ) + charge = F.one_hot(inserted.c.long(), num_classes=self.n_charge_types).to( + device=h.device, dtype=h.dtype + ) + distance = (inserted.x.detach() - x.detach()[spawn_idx]).norm(dim=-1) + distance_features = self.rbf_embedding(distance.clamp_min(1e-6)) + return self.inserted_endpoint( + torch.cat([h[spawn_idx], distance_features, atom, charge], dim=-1) + ) + + def forward_inserted_to_inserted( + self, + h: torch.Tensor, + x: torch.Tensor, + spawn_src_idx: torch.Tensor, + spawn_dst_idx: torch.Tensor, + src: InsertedAtoms, + dst: InsertedAtoms, + ) -> torch.Tensor: + """Predict bond logits between pairs of future atoms.""" + if spawn_src_idx.numel() == 0: + return torch.empty((0, self.n_edge_types), device=h.device, dtype=h.dtype) + if spawn_src_idx.shape != spawn_dst_idx.shape: + raise ValueError( + "spawn_src_idx and spawn_dst_idx must have identical shape, got " + f"{tuple(spawn_src_idx.shape)} and {tuple(spawn_dst_idx.shape)}" + ) + + src_features = self._inserted_endpoint_features(h, x, spawn_src_idx, src) + dst_features = self._inserted_endpoint_features(h, x, spawn_dst_idx, dst) + scale = math.sqrt(0.5) + pair = torch.cat( + [ + scale * (src_features + dst_features), + scale * (src_features - dst_features).abs(), + self.rbf_embedding( + (src.x.detach() - dst.x.detach()).norm(dim=-1).clamp_min(1e-6) + ), + ], + dim=-1, + ) + return self.inserted_pair_decoder(pair) + + def _prepare_edge_features( + self, + h_spawn: torch.Tensor, + h_existing: torch.Tensor, + spawn_pos: torch.Tensor, + existing_pos: torch.Tensor, + existing_atom_idx: torch.Tensor, + ins_pos: torch.Tensor, + ins_a: torch.Tensor, + ins_c: torch.Tensor, + insertion_index: torch.Tensor | None = None, + ) -> torch.Tensor: + """Build edge-MLP features for insertion edge prediction.""" + ins_atom_type = F.one_hot(ins_a.long(), num_classes=self.n_atom_types).to( + dtype=h_spawn.dtype, device=h_spawn.device + ) + ins_charge_type = F.one_hot(ins_c.long(), num_classes=self.n_charge_types).to( + dtype=h_spawn.dtype, device=h_spawn.device + ) + + dist_to_spawn = torch.norm(ins_pos - spawn_pos, dim=-1).clamp(min=1e-6) + if insertion_index is not None: + # At inference the spawn-side features are constant for all candidate + # existing endpoints. Embed them once per insertion, then expand. + dist_spawn_features = self.rbf_embedding(dist_to_spawn)[insertion_index] + h_spawn = h_spawn[insertion_index] + ins_atom_type = ins_atom_type[insertion_index] + ins_charge_type = ins_charge_type[insertion_index] + ins_pos = ins_pos[insertion_index] + + dist_to_existing = torch.norm(ins_pos - existing_pos, dim=-1).clamp(min=1e-6) + if insertion_index is None and not (self.training and self._rbf_has_dropout): + distance_features = self.rbf_embedding( + torch.cat([dist_to_spawn, dist_to_existing]) + ) + dist_spawn_features, dist_existing_features = distance_features.chunk(2) + else: + if insertion_index is None: + dist_spawn_features = self.rbf_embedding(dist_to_spawn) + dist_existing_features = self.rbf_embedding(dist_to_existing) + + existing_atom_type = F.one_hot( + existing_atom_idx.long(), num_classes=self.n_atom_types + ).to(dtype=h_spawn.dtype, device=h_spawn.device) + + return torch.cat( + [ + h_spawn, + h_existing, + dist_spawn_features, + existing_atom_type, + dist_existing_features, + ins_atom_type, + ins_charge_type, + ], + dim=-1, + ) + + def forward( + self, + h: torch.Tensor, + x: torch.Tensor, + node_atom_types: torch.Tensor, + spawn_node_idx: torch.Tensor, + existing_node_idx: torch.Tensor, + ins: InsertedAtoms, + ) -> torch.Tensor: + """Predict bond logits from future atoms to existing atoms.""" + if spawn_node_idx.numel() == 0: + return torch.empty((0, self.n_edge_types), device=h.device, dtype=h.dtype) + + # Use fixed insertion attributes to decouple edge prediction from GMM sampling. + x_detached = x.detach() + edge_features = self._prepare_edge_features( + h_spawn=h[spawn_node_idx], + h_existing=h[existing_node_idx], + spawn_pos=x_detached[spawn_node_idx], + existing_pos=x_detached[existing_node_idx], + existing_atom_idx=node_atom_types[existing_node_idx], + ins_pos=ins.x.detach(), + ins_a=ins.a, + ins_c=ins.c, + ) + + edge_logits = self.edge_mlp(edge_features) + + return edge_logits + + @staticmethod + def _insertion_edge_pairs( + insertion_indices: torch.Tensor, batch: torch.Tensor, node_counts: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Pair every insertion with every node sharing its graph. + + ``batch`` must be graph-contiguous, as every :class:`ChemBatch` is. + Returns ``(spawn_idx, existing_idx, local)`` where ``spawn_idx`` / + ``existing_idx`` are global node indices and ``local`` indexes the + per-insertion tensors. Row-major over insertions then nodes, matching the + original per-insertion loop order (downstream code relies on it). + """ + insertion_graphs = batch[insertion_indices] + pair_counts = node_counts[insertion_graphs] + num_pairs = int(pair_counts.sum()) + local = torch.arange( + insertion_indices.numel(), device=batch.device + ).repeat_interleave(pair_counts, output_size=num_pairs) + graph_offsets = node_counts.cumsum(0) - node_counts + pair_offsets = pair_counts.cumsum(0) - pair_counts + within_graph = ( + torch.arange(num_pairs, device=batch.device) - pair_offsets[local] + ) + existing_idx = graph_offsets[insertion_graphs[local]] + within_graph + return insertion_indices[local], existing_idx, local + + def predict_inserted_to_existing( + self, + h: torch.Tensor, + x: torch.Tensor, + node_atom_types: torch.Tensor, + batch: torch.Tensor, + insertion_indices: torch.Tensor, + node_counts: torch.Tensor, + ins: InsertedAtoms, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """ + Predict edges for all insertions to all other nodes in the same graph. + + This is used during inference to get edge predictions. + + Args: + h: [N, hidden_dim] - Node features + x: [N, 3] - Node positions + node_atom_types: [N] - Atom type index for each current-state node + batch: [N] - Batch assignment + insertion_indices: [N_ins] - Selected spawning node indices. + node_counts: [B] - Current node counts before deletion. + ins: Inserted-atom features aligned with ``insertion_indices``. + + Returns: + spawn_idx: [E_ins] - Spawn node indices + existing_idx: [E_ins] - Existing endpoint node indices + edge_logits: [E_ins, n_edge_types] - Edge type logits + """ + device = h.device + + if insertion_indices.numel() == 0: + return ( + torch.empty(0, dtype=torch.long, device=device), + torch.empty(0, dtype=torch.long, device=device), + torch.empty(0, self.n_edge_types, device=device), + ) + + # Pair each insertion with every node in its graph (including the spawn + # node). ``local`` indexes the per-insertion inserted-atom tensors. + spawn_idx, existing_idx, local = self._insertion_edge_pairs( + insertion_indices, batch, node_counts + ) + + # Compute insertion-constant features once rather than once for every + # candidate existing endpoint. + x_detached = x.detach() + edge_features = self._prepare_edge_features( + h_spawn=h[insertion_indices], + h_existing=h[existing_idx], + spawn_pos=x_detached[insertion_indices], + existing_pos=x_detached[existing_idx], + existing_atom_idx=node_atom_types[existing_idx], + ins_pos=ins.x.detach(), + ins_a=ins.a, + ins_c=ins.c, + insertion_index=local, + ) + edge_logits = self.edge_mlp(edge_features) + + return spawn_idx, existing_idx, edge_logits + + +class InsertionEdgePredictorMixin: + """Prediction glue shared by DiT models with insertion-edge heads.""" + + heads: MultiHeadModule + insertion_head: nn.Module + ins_edge_head: InsertionEdgeHead + + def _assemble_outputs( + self, + h: torch.Tensor, + x_out: torch.Tensor, + e_out_triu: torch.Tensor, + full_to_triu: torch.Tensor, + x: torch.Tensor, + batch: torch.Tensor, + edge_index_tuple: tuple[torch.Tensor, torch.Tensor], + ) -> dict[str, object]: + out_dict = self.heads(h, batch, e_out_triu, full_to_triu=full_to_triu) + out_dict["pos_head"] = x_out + out_dict["insertion_head"] = self.insertion_head(h, x, edge_index_tuple) + out_dict["h_latent"] = h + return out_dict + + def predict_inserted_to_existing_edges( + self, + mols_t, + out_dict: dict[str, object], + spawn_node_idx: torch.Tensor, + existing_node_idx: torch.Tensor, + ins: InsertedAtoms, + ) -> torch.Tensor: + """Decode future-to-current bonds using the cached node latents.""" + return self.ins_edge_head( + h=out_dict["h_latent"], + x=mols_t.x, + node_atom_types=mols_t.a, + spawn_node_idx=spawn_node_idx, + existing_node_idx=existing_node_idx, + ins=ins, + ) + + def predict_inserted_to_inserted_edges( + self, + mols_t, + out_dict: dict[str, object], + spawn_src_idx: torch.Tensor, + spawn_dst_idx: torch.Tensor, + src: InsertedAtoms, + dst: InsertedAtoms, + ) -> torch.Tensor: + """Decode future-to-future bonds using the cached node latents.""" + return self.ins_edge_head.forward_inserted_to_inserted( + h=out_dict["h_latent"], + x=mols_t.x, + spawn_src_idx=spawn_src_idx, + spawn_dst_idx=spawn_dst_idx, + src=src, + dst=dst, + ) diff --git a/geom/explicit_h/runtime/src/morph/model/layers/kernels.py b/geom/explicit_h/runtime/src/morph/model/layers/kernels.py new file mode 100644 index 0000000000000000000000000000000000000000..592fbbe70160531c527c5eea0dd6dce1a90b397c --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/layers/kernels.py @@ -0,0 +1,375 @@ +# Triton imports are deferred so CPU-only installations can import the model. +# ruff: noqa: PLC0415 + +"""Fused CUDA operations shared by the model layers. + +Keep Triton definitions, registration and eligibility checks here; ordinary +attention and backbone code use these helpers with native PyTorch fallbacks. +Triton is imported only when the corresponding CUDA implementation is loaded. +""" + +from __future__ import annotations + +import os +from functools import cache + +import torch +import torch.nn as nn + + +@cache +def _load_fused_qkv_packing(): + """Normalize Q/K and write the padded attention layout in one CUDA launch.""" + import triton + import triton.language as tl + from torch.library import triton_op, wrap_triton + + @triton.jit + def pack_kernel( + QKV, + QW, + KW, + OFFSETS, + OUT, + N, + HEADS: tl.constexpr, + D: tl.constexpr, + EPS: tl.constexpr, + S0: tl.constexpr, + S1: tl.constexpr, + S2: tl.constexpr, + S3: tl.constexpr, + BLOCK: tl.constexpr, + ): + graph = tl.program_id(0) + row = tl.program_id(1) * 8 + tl.arange(0, 8) + head = tl.program_id(2) + start = tl.load(OFFSETS + graph) + end = tl.load(OFFSETS + graph + 1) + d = tl.arange(0, BLOCK) + valid = (start + row[:, None] < end) & (d[None, :] < D) + offset = (start + row[:, None]) * S0 + head * S2 + d[None, :] * S3 + q = tl.load(QKV + offset, valid, other=0).to(tl.float32) + k = tl.load(QKV + offset + S1, valid, other=0).to(tl.float32) + v = tl.load(QKV + offset + 2 * S1, valid, other=0) + q = q * tl.rsqrt(tl.sum(q * q, 1) / D + EPS)[:, None] + k = k * tl.rsqrt(tl.sum(k * k, 1) / D + EPS)[:, None] + q = q * tl.load(QW + d, d < D, other=0).to(tl.float32)[None, :] + k = k * tl.load(KW + d, d < D, other=0).to(tl.float32)[None, :] + out = ((graph * N + row[:, None]) * 3 * HEADS + head) * D + d[None, :] + store = (row[:, None] < N) & (d[None, :] < D) + tl.store(OUT + out, q, store) + tl.store(OUT + out + HEADS * D, k, store) + tl.store(OUT + out + 2 * HEADS * D, v, store) + + @triton_op("morph::pack_normalized_qkv", mutates_args={}) + def pack( + qkv: torch.Tensor, + q_weight: torch.Tensor, + k_weight: torch.Tensor, + offsets: torch.Tensor, + max_seqlen: int, + eps: float, + ) -> torch.Tensor: + batch_size = offsets.numel() - 1 + heads, dim = qkv.shape[2:] + out = qkv.new_empty((batch_size, max_seqlen, 3, heads, dim)) + wrap_triton(pack_kernel)[(batch_size, triton.cdiv(max_seqlen, 8), heads)]( + qkv, + q_weight, + k_weight, + offsets, + out, + max_seqlen, + heads, + dim, + eps, + *qkv.stride(), + triton.next_power_of_2(dim), + num_warps=4, + enable_fp_fusion=False, + ) + return out + + return pack + + +_pack_normalized_qkv = _load_fused_qkv_packing() if torch.cuda.is_available() else None + + +def pack_normalized_qkv(qkv, offsets, max_seqlen, q_norm, k_norm): + """Return fused padded QKV, or ``None`` when native packing is required. + + The caller must supply graph-contiguous packed QKV and the corresponding + cumulative lengths. CPU, autograd, and unsupported normalization layouts + use the ordinary PyTorch path in attention.py. + """ + supported = ( + _pack_normalized_qkv is not None + and qkv.is_cuda + and not torch.is_grad_enabled() + and qkv.dtype in (torch.float32, torch.bfloat16) + and isinstance(q_norm, nn.RMSNorm) + and isinstance(k_norm, nn.RMSNorm) + and q_norm.weight is not None + and k_norm.weight is not None + and q_norm.weight.dtype == torch.float32 + and k_norm.weight.dtype == torch.float32 + and q_norm.normalized_shape == k_norm.normalized_shape == (qkv.shape[-1],) + and q_norm.weight.is_contiguous() + and k_norm.weight.is_contiguous() + and q_norm.eps is not None + and q_norm.eps == k_norm.eps + ) + if not supported: + return None + return _pack_normalized_qkv( + qkv, q_norm.weight, k_norm.weight, offsets, max_seqlen, q_norm.eps + ) + + +def graph_broadcast(values, batch_idx, offsets=None): + return values[batch_idx] + + +@cache +def _load_fused_graph_broadcast(): + """Load the first-order CUDA graph-condition reduction once. + + Keep Triton optional for native indexing and CPU imports. The reduction + uses FP32 accumulation with a different order from native indexing. + """ + import triton + import triton.language as tl + from torch.library import triton_op, wrap_triton + + @triton.jit + def _sum_graph_rows( + grad, + offsets, + output, + width: tl.constexpr, + row_stride: tl.constexpr, + col_stride: tl.constexpr, + BLOCK_N: tl.constexpr, + BLOCK_D: tl.constexpr, + ): + graph = tl.program_id(0) + cols = tl.program_id(1) * BLOCK_D + tl.arange(0, BLOCK_D) + start = tl.load(offsets + graph) + end = tl.load(offsets + graph + 1) + rows = start + tl.arange(0, BLOCK_N) + accum = tl.full((BLOCK_N, BLOCK_D), 0, tl.float32) + # Fixed-size tiles cover arbitrary graph lengths without atom bucketing or + # specializing the compiled model for each maximum atom count. + for tile_start in range(start, end, BLOCK_N): + rows = tile_start + tl.arange(0, BLOCK_N) + values = tl.load( + grad + rows[:, None] * row_stride + cols[None, :] * col_stride, + (rows[:, None] < end) & (cols[None, :] < width), + other=0, + ) + accum += values.to(tl.float32) + total = tl.sum(accum, axis=0) + tl.store(output + graph * width + cols, total, cols < width) + + @triton_op("morph::sum_graph_rows", mutates_args={}) + def sum_graph_rows(grad: torch.Tensor, offsets: torch.Tensor) -> torch.Tensor: + out = torch.empty( + (offsets.numel() - 1, grad.shape[1]), device=grad.device, dtype=grad.dtype + ) + wrap_triton(_sum_graph_rows)[(out.shape[0], triton.cdiv(grad.shape[1], 64))]( + grad, + offsets, + out, + grad.shape[1], + grad.stride(0), + grad.stride(1), + 32, + 64, + ) + return out + + class TritonBroadcast(torch.autograd.Function): + @staticmethod + def forward(ctx, values, batch_idx, offsets): + ctx.save_for_backward(offsets) + return values[batch_idx] + + @staticmethod + def backward(ctx, grad): + (offsets,) = ctx.saved_tensors + return sum_graph_rows(grad, offsets), None, None + + def graph_broadcast(values, batch_idx, offsets=None): + if ( + offsets is None + or not values.is_cuda + or not torch.is_grad_enabled() + or values.dtype not in (torch.float32, torch.bfloat16) + ): + return values[batch_idx] + return TritonBroadcast.apply(values, batch_idx, offsets) + + return graph_broadcast + + +if os.environ.get("MORPH_FUSED_BROADCAST", "1") == "1" and torch.cuda.is_available(): + graph_broadcast = _load_fused_graph_broadcast() + + +@cache +def _load_edge_normalization(): + """Build symmetric pair features and normalize without materializing a cat.""" + import triton + import triton.language as tl + from torch.library import triton_op, wrap_triton + + @triton.jit + def kernel( + P, + SRC, + DST, + EDGE, + RBF, + W, + B, + OUT, + E, + PS0: tl.constexpr, + PS1: tl.constexpr, + SS: tl.constexpr, + DS: tl.constexpr, + ES0: tl.constexpr, + ES1: tl.constexpr, + RS0: tl.constexpr, + RS1: tl.constexpr, + PD: tl.constexpr, + ED: tl.constexpr, + RD: tl.constexpr, + EPS: tl.constexpr, + BLOCK: tl.constexpr, + ): + row = tl.program_id(0) * 4 + tl.arange(0, 4) + col = tl.arange(0, BLOCK) + src = tl.load(SRC + row * SS, row < E, other=0) + dst = tl.load(DST + row * DS, row < E, other=0) + pair_col = col % PD + pair_mask = (row[:, None] < E) & (col[None, :] < 2 * PD) + a = tl.load( + P + src[:, None] * PS0 + pair_col[None, :] * PS1, pair_mask, other=0 + ) + b = tl.load( + P + dst[:, None] * PS0 + pair_col[None, :] * PS1, pair_mask, other=0 + ) + pair = tl.where( + col[None, :] < PD, + a.to(tl.float32) + b.to(tl.float32), + tl.abs(a.to(tl.float32) - b.to(tl.float32)), + ) + # Match the native pair arithmetic's intermediate BF16 rounding. + pair = ( + (pair.to(a.dtype).to(tl.float32) * 0.7071067811865476) + .to(a.dtype) + .to(tl.float32) + ) + edge = tl.load( + EDGE + row[:, None] * ES0 + (col[None, :] - 2 * PD) * ES1, + (row[:, None] < E) + & (col[None, :] >= 2 * PD) + & (col[None, :] < 2 * PD + ED), + other=0, + ).to(tl.float32) + rbf = tl.load( + RBF + row[:, None] * RS0 + (col[None, :] - 2 * PD - ED) * RS1, + (row[:, None] < E) + & (col[None, :] >= 2 * PD + ED) + & (col[None, :] < 2 * PD + ED + RD), + other=0, + ).to(tl.float32) + width = 2 * PD + ED + RD + value = tl.where( + col[None, :] < 2 * PD, pair, tl.where(col[None, :] < 2 * PD + ED, edge, rbf) + ) + value = tl.where(col[None, :] < width, value, 0.0) + mean = tl.sum(value, 1) / width + centered = tl.where(col[None, :] < width, value - mean[:, None], 0.0) + var = tl.sum(centered * centered, 1) / width + norm = centered * tl.rsqrt(var + EPS)[:, None] + weight = tl.load(W + col, col < width, other=0).to(tl.float32) + bias = tl.load(B + col, col < width, other=0).to(tl.float32) + tl.store( + OUT + row[:, None] * width + col[None, :], + norm * weight + bias, + (row[:, None] < E) & (col[None, :] < width), + ) + + @triton_op("morph::edge_normalization", mutates_args={}) + def normalize( + p: torch.Tensor, + src: torch.Tensor, + dst: torch.Tensor, + edge: torch.Tensor, + rbf: torch.Tensor, + w: torch.Tensor, + b: torch.Tensor, + eps: float, + ) -> torch.Tensor: + width = 2 * p.shape[1] + edge.shape[1] + rbf.shape[1] + # CUDA autocast runs LayerNorm in FP32; the following linear casts it. + out = torch.empty((src.numel(), width), dtype=torch.float32, device=p.device) + wrap_triton(kernel)[(triton.cdiv(src.numel(), 4),)]( + p, + src, + dst, + edge, + rbf, + w, + b, + out, + src.numel(), + *p.stride(), + src.stride(0), + dst.stride(0), + *edge.stride(), + *rbf.stride(), + p.shape[1], + edge.shape[1], + rbf.shape[1], + eps, + triton.next_power_of_2(width), + num_warps=4, + enable_fp_fusion=False, + ) + return out + + return normalize + + +_edge_normalization = _load_edge_normalization() if torch.cuda.is_available() else None + + +def normalized_edge_features(p, edge_index, edge, distance, norm): + """Return fused CUDA inference features, or ``None`` for the native path.""" + if ( + _edge_normalization is None + or not p.is_cuda + or torch.is_grad_enabled() + or any( + x.dtype not in (torch.float32, torch.bfloat16) for x in (p, edge, distance) + ) + or not isinstance(norm, nn.LayerNorm) + or norm.weight is None + or norm.bias is None + or norm.weight.dtype != torch.float32 + or norm.bias.dtype != torch.float32 + or not norm.weight.is_contiguous() + or not norm.bias.is_contiguous() + or norm.normalized_shape + != (2 * p.shape[1] + edge.shape[1] + distance.shape[1],) + or (p.dtype != torch.float32 and not torch.is_autocast_enabled("cuda")) + ): + return None + return _edge_normalization( + p, *edge_index, edge, distance, norm.weight, norm.bias, norm.eps + ) diff --git a/geom/explicit_h/runtime/src/morph/model/layers/lora.py b/geom/explicit_h/runtime/src/morph/model/layers/lora.py new file mode 100644 index 0000000000000000000000000000000000000000..784a0da934778ebc5a23ba49bfd690e8dffc619a --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/layers/lora.py @@ -0,0 +1,182 @@ +"""Low-rank adapters for DiT linear projections.""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import torch +import torch.nn as nn +import torch.nn.functional as F + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class LoRALinear(nn.Linear): + """A linear layer with a trainable low-rank residual. + + Keeping ``weight`` and ``bias`` directly on the layer preserves the state + dict layout of :class:`torch.nn.Linear`, so ordinary DiT checkpoints load + without key rewriting. The residual starts at exactly zero. + """ + + def __init__( + self, + in_features: int, + out_features: int, + *, + rank: int, + alpha: float, + dropout: float = 0.0, + bias: bool = True, + device: torch.device | str | None = None, + dtype: torch.dtype | None = None, + ): + if rank <= 0: + raise ValueError(f"LoRA rank must be positive, got {rank}") + if alpha <= 0: + raise ValueError(f"LoRA alpha must be positive, got {alpha}") + if not 0.0 <= dropout < 1.0: + raise ValueError(f"LoRA dropout must be in [0, 1), got {dropout}") + super().__init__( + in_features, + out_features, + bias=bias, + device=device, + dtype=dtype, + ) + self.rank = int(rank) + self.alpha = float(alpha) + self.scaling = self.alpha / self.rank + self.lora_dropout = nn.Dropout(dropout) + self.lora_A = nn.Parameter( + torch.empty(self.rank, in_features, device=device, dtype=dtype) + ) + self.lora_B = nn.Parameter( + torch.empty(out_features, self.rank, device=device, dtype=dtype) + ) + self._merged = False + self.reset_lora_parameters() + + @classmethod + def from_linear( + cls, + linear: nn.Linear, + *, + rank: int, + alpha: float, + dropout: float = 0.0, + freeze_base: bool = True, + ) -> LoRALinear: + """Wrap ``linear`` without copying or renaming its base parameters.""" + adapter = cls( + linear.in_features, + linear.out_features, + rank=rank, + alpha=alpha, + dropout=dropout, + bias=linear.bias is not None, + device=linear.weight.device, + dtype=linear.weight.dtype, + ) + adapter.weight = linear.weight + adapter.bias = linear.bias + if freeze_base: + adapter.weight.requires_grad_(False) + if adapter.bias is not None: + adapter.bias.requires_grad_(False) + return adapter + + def reset_lora_parameters(self) -> None: + """Initialize the adapter so its initial residual is exactly zero.""" + nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5)) + nn.init.zeros_(self.lora_B) + + def lora_delta(self) -> torch.Tensor: + """Return the scaled low-rank weight update in base-weight layout.""" + return (self.lora_B @ self.lora_A) * self.scaling + + @torch.no_grad() + def merge_lora_(self) -> LoRALinear: + """Fold the adapter into the base weight for inference.""" + if not self._merged: + self.weight.add_(self.lora_delta().to(dtype=self.weight.dtype)) + self._merged = True + return self + + @torch.no_grad() + def unmerge_lora_(self) -> LoRALinear: + """Restore the separate base and adapter weights.""" + if self._merged: + self.weight.sub_(self.lora_delta().to(dtype=self.weight.dtype)) + self._merged = False + return self + + def _save_to_state_dict(self, destination, prefix, keep_vars): + # Checkpoints always store the unmerged base weight plus the two adapter + # matrices. This makes save/load independent of inference merge state. + super()._save_to_state_dict(destination, prefix, keep_vars) + if self._merged: + weight = self.weight - self.lora_delta().to(dtype=self.weight.dtype) + destination[f"{prefix}weight"] = weight if keep_vars else weight.detach() + + def _load_from_state_dict( + self, + state_dict, + prefix, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ): + self.unmerge_lora_() + super()._load_from_state_dict( + state_dict, + prefix, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + """Apply the base projection and the unmerged low-rank residual.""" + output = F.linear(x, self.weight, self.bias) + if self._merged: + return output + update = F.linear(F.linear(self.lora_dropout(x), self.lora_A), self.lora_B) + return output + update * self.scaling + + +def iter_lora_layers(module: nn.Module) -> Iterator[LoRALinear]: + """Yield all LoRA layers below ``module`` exactly once.""" + for child in module.modules(): + if isinstance(child, LoRALinear): + yield child + + +def set_lora_trainable(module: nn.Module, trainable: bool = True) -> int: + """Set adapter gradients and return the number of adapter parameters.""" + count = 0 + for layer in iter_lora_layers(module): + layer.lora_A.requires_grad_(trainable) + layer.lora_B.requires_grad_(trainable) + count += layer.lora_A.numel() + layer.lora_B.numel() + return count + + +def merge_lora_(module: nn.Module) -> nn.Module: + """Merge every low-rank adapter below ``module``.""" + for layer in iter_lora_layers(module): + layer.merge_lora_() + return module + + +def unmerge_lora_(module: nn.Module) -> nn.Module: + """Unmerge every low-rank adapter below ``module``.""" + for layer in iter_lora_layers(module): + layer.unmerge_lora_() + return module diff --git a/geom/explicit_h/runtime/src/morph/model/lightning/__init__.py b/geom/explicit_h/runtime/src/morph/model/lightning/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..40c82d6620249d9e0eed505b47c19d01ca8a1fd0 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/lightning/__init__.py @@ -0,0 +1,7 @@ +"""PyTorch-Lightning training modules. + +:mod:`base` holds :class:`LightningModuleRates`, assembled from per-concern +mixins (:mod:`_setup`, :mod:`_training`, :mod:`_validation`, :mod:`_sampling`, +:mod:`_optimizer`). The repository's models all use the DiT backbone. +Task-specific finetuning modules live in :mod:`morph.model.finetune`. +""" diff --git a/geom/explicit_h/runtime/src/morph/model/lightning/_optimizer.py b/geom/explicit_h/runtime/src/morph/model/lightning/_optimizer.py new file mode 100644 index 0000000000000000000000000000000000000000..f4182a65333d9762218383c04a238c19cb645f3b --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/lightning/_optimizer.py @@ -0,0 +1,476 @@ +"""Optimizer construction, routing, stepping, and gradient diagnostics.""" + +from __future__ import annotations + +from contextlib import nullcontext + +import hydra +import torch +import torch.distributed as dist +from lightning.pytorch.utilities import grad_norm +from omegaconf import OmegaConf +from torch.optim._muon import _adjust_lr, _zeropower_via_newtonschulz + +from morph._vendor.muon import MuonWithAuxAdam, SingleDeviceMuonWithAuxAdam + + +class CudaGraphMuon(torch.optim.Muon): + """Replay native momentum/orthogonalization kernels with fixed input buffers. + + The model's variable atom counts do not change its parameter shapes. Copy + each new gradient into a stable buffer, replay the native Muon operations, + then apply the current scheduler learning rate outside the graph. The + optimizer state dict remains identical to torch.optim.Muon. CPU and mixed + device parameter sets use the native implementation directly. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._update_graphs = {} + + def load_state_dict(self, state_dict): + # Loading replaces momentum tensors; captured graphs own old addresses. + self._update_graphs.clear() + return super().load_state_dict(state_dict) + + @staticmethod + def _capture(grads, buffers, group): + static_grads = [grad.clone() for grad in grads] + backup = [buffer.clone() for buffer in buffers] + + def compute_updates(): + updates = [] + for grad, buf in zip(static_grads, buffers, strict=True): + buf.lerp_(grad, 1 - group["momentum"]) + update = grad.lerp(buf, group["momentum"]) if group["nesterov"] else buf + updates.append( + _zeropower_via_newtonschulz( + update, + group["ns_coefficients"], + group["ns_steps"], + group["eps"], + ) + ) + return updates + + stream = torch.cuda.Stream(device=grads[0].device) + stream.wait_stream(torch.cuda.current_stream()) + # Warm library handles and allocations before stream capture. Restore + # momentum afterwards so warm-up never counts as a training update. + with torch.cuda.stream(stream): + compute_updates() + compute_updates() + torch.cuda.current_stream().wait_stream(stream) + graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(graph, stream=stream): + updates = compute_updates() + torch._foreach_copy_(buffers, backup) + return graph, static_grads, updates + + @torch.no_grad() + def step(self, closure=None): + devices = {p.device for group in self.param_groups for p in group["params"]} + if len(devices) != 1 or next(iter(devices)).type != "cuda": + return super().step(closure) + device = next(iter(devices)) + loss = None + if closure is not None: + with torch.enable_grad(): + loss = closure() + + with torch.cuda.device(device): + for gi, group in enumerate(self.param_groups): + params, grads, buffers = [], [], [] + self._init_group(group, params, grads, buffers) + if not params: + continue + # Gradient participation, layout and recurrence settings may + # change, while LR and weight decay can change without capture. + key = ( + tuple(id(p) for p in params), + tuple(id(b) for b in buffers), + tuple((g.shape, g.stride(), g.dtype, g.device) for g in grads), + group["momentum"], + group["nesterov"], + tuple(group["ns_coefficients"]), + group["ns_steps"], + group["eps"], + ) + cached = self._update_graphs.get(gi) + if cached is None or cached[0] != key: + self._update_graphs.pop(gi, None) + cached = (key, *self._capture(grads, buffers, group)) + self._update_graphs[gi] = cached + _, graph, static_grads, updates = cached + torch._foreach_copy_(static_grads, grads) + graph.replay() + for param, update in zip(params, updates, strict=True): + param.mul_(1 - group["lr"] * group["weight_decay"]) + param.add_( + update, + alpha=-_adjust_lr( + group["lr"], group["adjust_lr_fn"], param.shape + ), + ) + return loss + + +def gradient_accumulation_plan( + batch_idx: int, steps: int, is_last_batch: bool +) -> tuple[bool, bool, int]: + """Return ``(zero_first, step_now, loss_divisor)`` for one microbatch.""" + if steps <= 0: + raise ValueError("gradient accumulation steps must be positive") + offset = batch_idx % steps + step_now = offset + 1 == steps or is_last_batch + divisor = offset + 1 if is_last_batch and offset + 1 < steps else steps + return offset == 0, step_now, divisor + + +class OptimizerMixin: + """Own all automatic and native Muon/AdamW optimization behavior.""" + + _AUXILIARY_MATRIX_PREFIXES = ( + "embedding_backbone.", + "heads.", + "insertion_head.", + "ins_edge_head.", + # The context encoder's input feature projections play the same role as + # ``embedding_backbone``. Its attention/MLP blocks remain on Muon. + "xattn_encoder.pos_proj.", + "xattn_encoder.token_proj.", + ) + _AUXILIARY_MATRIX_PATHS = ( + ".pair_bias.atom_pair_embedding.", + ".lora_A", + ".lora_B", + ) + + def _setup_optimization(self) -> None: + """Select Lightning's automatic or native multi-optimizer path.""" + implementation = self.optimizer_config.get("implementation", "torch") + if implementation not in {"torch", "external"}: + raise ValueError( + "optimizer implementation must be 'torch' or 'external', got " + f"{implementation!r}" + ) + # A Hydra target and the legacy combined implementation each expose a + # single optimizer, so Lightning can own their stepping natively. The + # documented torch Muon + AdamW split requires manual optimization. + self.automatic_optimization = ( + "_target_" in self.optimizer_config.optimizer + or implementation == "external" + ) + self.manual_gradient_accumulation_steps = 1 + + def _optimizer_update_step(self) -> int: + """Return completed model updates.""" + return self.global_step + + def _should_log_grad_norms(self) -> bool: + every = self.grad_norm_log_interval + return every > 0 and (self._optimizer_update_step() % every) == 0 + + @classmethod + def _use_muon(cls, name, parameter) -> bool: + """Route hidden 2D matrices to Muon and auxiliary tensors to AdamW.""" + # ``torch.compile`` wraps the model and prefixes every parameter name. + name = name.removeprefix("_orig_mod.") + is_auxiliary = name.startswith(cls._AUXILIARY_MATRIX_PREFIXES) or any( + path in name for path in cls._AUXILIARY_MATRIX_PATHS + ) + return parameter.ndim == 2 and not is_auxiliary + + @staticmethod + def _embedding_parameter_ids(model) -> set[int]: + """Find lookup tables by module type rather than extension-specific names.""" + return { + id(parameter) + for module in model.modules() + if isinstance(module, torch.nn.Embedding) + for parameter in module.parameters(recurse=False) + } + + def configure_gradient_clipping( + self, + optimizer, + gradient_clip_val=None, + gradient_clip_algorithm=None, + ): + """Apply module-level clipping settings for automatic optimizers.""" + clip_val = self.optimizer_config.get("gradient_clip_val", gradient_clip_val) + algorithm = self.optimizer_config.get( + "gradient_clip_algorithm", gradient_clip_algorithm + ) + self.clip_gradients( + optimizer, + gradient_clip_val=clip_val, + gradient_clip_algorithm=algorithm, + ) + + def configure_optimizers(self): + """Build one configured optimizer or native Muon plus auxiliary AdamW.""" + opt_cfg = self.optimizer_config.optimizer + params = [ + parameter + for parameter in self.model.parameters() + if parameter.requires_grad + ] + learnable_loss_weights = ( + self.loss_weight_wrapper.use_learnable + and self.loss_weight_wrapper.learnable_wrapper is not None + ) + if learnable_loss_weights: + params.extend(self.loss_weight_wrapper.learnable_wrapper.parameters()) + + if "_target_" in opt_cfg: + optimizer = hydra.utils.instantiate(opt_cfg, params=params) + self._optimizer_group_names = ["all"] + scheduler = self._make_scheduler(optimizer) + return { + "optimizer": optimizer, + "lr_scheduler": { + "scheduler": scheduler, + "interval": self.optimizer_config.interval, + "monitor": self.optimizer_config.monitor, + }, + "monitor": self.optimizer_config.monitor, + } + + muon_params, adam_params = [], [] + embedding_parameter_ids = self._embedding_parameter_ids(self.model) + for name, parameter in self.model.named_parameters(): + if not parameter.requires_grad: + continue + is_embedding = id(parameter) in embedding_parameter_ids + use_muon = self._use_muon(name, parameter) and not is_embedding + target = muon_params if use_muon else adam_params + target.append(parameter) + if learnable_loss_weights: + adam_params.extend(self.loss_weight_wrapper.learnable_wrapper.parameters()) + + implementation = self.optimizer_config.get("implementation", "torch") + aux_cfg = self.optimizer_config.aux_optimizer + if implementation == "external": + named_groups = [] + if adam_params: + named_groups.append( + ( + "adam_aux", + { + "params": adam_params, + "lr": aux_cfg.lr, + "betas": tuple(aux_cfg.betas), + "eps": aux_cfg.get("eps", 1e-10), + "weight_decay": aux_cfg.weight_decay, + "use_muon": False, + }, + ) + ) + if muon_params: + named_groups.append( + ( + "muon", + { + "params": muon_params, + "lr": opt_cfg.lr, + "momentum": opt_cfg.momentum, + "weight_decay": opt_cfg.weight_decay, + "use_muon": True, + }, + ) + ) + if not named_groups: + raise RuntimeError("The model has no trainable parameters") + optimizer_cls = ( + MuonWithAuxAdam + if dist.is_available() and dist.is_initialized() + else SingleDeviceMuonWithAuxAdam + ) + optimizer = optimizer_cls([group for _, group in named_groups]) + self._optimizer_group_names = [name for name, _ in named_groups] + scheduler = self._make_scheduler(optimizer) + return { + "optimizer": optimizer, + "lr_scheduler": { + "scheduler": scheduler, + "interval": self.optimizer_config.interval, + "monitor": self.optimizer_config.monitor, + }, + "monitor": self.optimizer_config.monitor, + } + + optimizers = [] + optimizer_names = [] + if muon_params: + execution = opt_cfg.get("execution", "native") + if execution not in {"native", "cuda_graph"}: + raise ValueError("Muon execution must be native or cuda_graph") + optimizer_cls = torch.optim.Muon if execution == "native" else CudaGraphMuon + optimizers.append( + optimizer_cls( + muon_params, + lr=opt_cfg.lr, + momentum=opt_cfg.momentum, + weight_decay=opt_cfg.weight_decay, + nesterov=opt_cfg.get("nesterov", True), + ns_steps=opt_cfg.get("ns_steps", 5), + eps=opt_cfg.get("eps", 1e-7), + adjust_lr_fn=opt_cfg.get("adjust_lr_fn", "original"), + ) + ) + optimizer_names.append("muon") + + if adam_params: + optimizers.append( + torch.optim.AdamW( + adam_params, + lr=aux_cfg.lr, + betas=tuple(aux_cfg.betas), + eps=aux_cfg.get("eps", 1e-10), + weight_decay=aux_cfg.weight_decay, + ) + ) + optimizer_names.append("adam_aux") + if not optimizers: + raise RuntimeError("The model has no trainable parameters") + + self._optimizer_group_names_by_id = { + id(optimizer): [name] + for optimizer, name in zip(optimizers, optimizer_names, strict=True) + } + return optimizers, [self._make_scheduler(optimizer) for optimizer in optimizers] + + def _make_scheduler(self, optimizer): + scheduler_cfg = dict( + OmegaConf.to_container(self.optimizer_config.scheduler, resolve=True) + ) + scheduler_cfg["optimizer"] = optimizer + return hydra.utils.instantiate(scheduler_cfg) + + def _manual_training_step(self, batch, batch_idx): + """Accumulate gradients and step all native optimizers together.""" + optimizers = self.optimizers() + if not isinstance(optimizers, (list, tuple)): + optimizers = (optimizers,) + zero_first, step_now, loss_divisor = gradient_accumulation_plan( + batch_idx, + self.manual_gradient_accumulation_steps, + self.trainer.is_last_batch, + ) + if zero_first: + for optimizer in optimizers: + optimizer.zero_grad(set_to_none=True) + + loss = self.shared_step(batch, batch_idx) + if step_now: + self._maybe_log_per_loss_grad_norms() + self.log("loss/train", loss.detach(), prog_bar=True, logger=True) + + block_sync = getattr(self.trainer.strategy, "block_backward_sync", nullcontext) + sync_context = nullcontext() if step_now else block_sync() + with sync_context: + self.manual_backward(loss / loss_divisor) + if not step_now: + return loss + + # The two native optimizers form one model update. Step the first + # through Lightning so precision hooks and the manual-loop progress + # tracker run once, then step the remaining native optimizer(s) through + # the same strategy/precision path without incrementing global_step. + optimizers[0].step() + for optimizer in optimizers[1:]: + raw_optimizer = getattr(optimizer, "optimizer", None) + if raw_optimizer is None: # lightweight test harness / direct call + optimizer.step() + else: + self.trainer.strategy.optimizer_step( + raw_optimizer, + lambda: None, + ) + schedulers = self.lr_schedulers() + if not isinstance(schedulers, (list, tuple)): + schedulers = (schedulers,) + for scheduler in schedulers: + scheduler.step() + self._update_ema() + return loss + + def on_before_optimizer_step(self, optimizer): + """Log gradient norms and clip the shared manual gradient once.""" + if self.automatic_optimization: + self._log_optimizer_grad_norms(optimizer) + return + + # Both native optimizers form one logical update. Log both branches and + # clip the full gradient vector once, immediately before the first step. + if optimizer is not self.trainer.optimizers[0]: + return + self._log_optimizer_grad_norms(optimizer) + if self._should_log_grad_norms(): + for other_optimizer in self.trainer.optimizers[1:]: + self._log_param_group_grad_norms(other_optimizer) + self._clip_all_gradients() + + def _log_optimizer_grad_norms(self, optimizer) -> None: + if not self._should_log_grad_norms(): + return + # Do not log per-parameter norms: their data-dependent key sets can + # differ across DDP ranks and deadlock metric synchronization. + norms = grad_norm(self, norm_type=2) + total = norms.get("grad_2.0_norm_total") + if total is not None: + self.log("grad_2.0_norm_total", total, on_step=True, on_epoch=False) + self._log_param_group_grad_norms(optimizer) + + def _log_param_group_grad_norms(self, optimizer): + """Log one stable, unclipped L2 gradient norm per parameter group.""" + names_by_id = getattr(self, "_optimizer_group_names_by_id", {}) + group_names = names_by_id.get( + id(optimizer), getattr(self, "_optimizer_group_names", None) + ) + entries: dict[str, torch.Tensor] = {} + for index, group in enumerate(optimizer.param_groups): + name = ( + group_names[index] + if group_names is not None and index < len(group_names) + else f"group{index}" + ) + sq_sum: torch.Tensor | None = None + for parameter in group["params"]: + if parameter.grad is None: + continue + grad_sq = parameter.grad.detach().norm(2) ** 2 + sq_sum = grad_sq if sq_sum is None else sq_sum + grad_sq + entries[f"grad_2.0_norm_pg/{name}"] = ( + torch.zeros((), device=self.device) if sq_sum is None else sq_sum.sqrt() + ) + if entries: + self.log_dict(entries, on_step=True, on_epoch=False) + + def _clip_all_gradients(self) -> None: + clip_val = float(self.optimizer_config.get("gradient_clip_val", 0.0)) + if clip_val <= 0: + return + parameters = ( + parameter for parameter in self.parameters() if parameter.grad is not None + ) + algorithm = self.optimizer_config.get("gradient_clip_algorithm", "norm") + if algorithm == "value": + torch.nn.utils.clip_grad_value_(parameters, clip_val) + else: + torch.nn.utils.clip_grad_norm_(parameters, clip_val) + + def optimizer_step( + self, + epoch, + batch_idx, + optimizer, + optimizer_closure, + on_tpu=False, + using_native_amp=False, + using_lbfgs=False, + ): + """Run an automatic optimizer step and update EMA exactly once.""" + optimizer.step(closure=optimizer_closure) + self._update_ema() diff --git a/geom/explicit_h/runtime/src/morph/model/lightning/_sampling.py b/geom/explicit_h/runtime/src/morph/model/lightning/_sampling.py new file mode 100644 index 0000000000000000000000000000000000000000..9e4d74d43a5221e803b1fda0449ae770988a44d8 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/lightning/_sampling.py @@ -0,0 +1,393 @@ +"""Inference: prediction and trajectory sampling. + +Mixin split out of :class:`morph.model.lightning.base.LightningModuleRates`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import torch + +from morph.dataset.core.atomic_data import ( + COFRAMED_CONTEXT_KEYS, + batch_from_flat, + batch_translate, +) +from morph.dataset.core.representation import neutral_charge_index +from morph.flow_matching.integration import IntegrationPredictions +from morph.flow_matching.sampling_step import SamplingStepTrace +from morph.utils import rdkit_utils as morphRD + +if TYPE_CHECKING: + from collections.abc import Callable + + +def _sample_logits(logits: torch.Tensor) -> torch.Tensor: + """Sample categorical logits in fp32 without a distribution wrapper.""" + return torch.multinomial(torch.softmax(logits.float(), dim=-1), 1).squeeze(1) + + +@dataclass +class _OutputFrame: + """Restore physical units and, for fixed scaffolds, the input coordinate frame.""" + + coordinate_std: float + translation: torch.Tensor | None + + @classmethod + def from_batch(cls, mol, coordinate_std, *, restore_input_frame): + mean = mol.remove_com() + return cls( + coordinate_std, mean.detach().clone() if restore_input_frame else None + ) + + def recenter(self, mol): + mean = mol.remove_com() + if self.translation is not None: + self.translation.add_(mean.detach()) + + def restore(self, mol): + if self.translation is not None: + batch_translate(mol, self.translation) + mol.x.mul_(self.coordinate_std) + for key in COFRAMED_CONTEXT_KEYS: + context = getattr(mol, key, None) + if context is not None: + context.positions.mul_(self.coordinate_std) + return mol + + def snapshot(self, mol): + return self.restore(mol.clone()).to_inplace("cpu", non_blocking=False) + + +def _sampling_interval(step_sizes, t_start): + """Select a schedule suffix, snapping its start to the next grid point.""" + if t_start <= 0.0: + return 0.0, step_sizes + cumulative = torch.cumsum( + torch.tensor([0.0] + [float(s) for s in step_sizes]), dim=0 + ) + start = int((cumulative >= float(t_start)).nonzero()[0].item()) + return float(cumulative[start].item()), step_sizes[start:] + + +class SamplingMixin: + def _apply_inference_edit_masks( + self, + mol_t, + x1_pred: torch.Tensor, + do_sub_a_probs: torch.Tensor, + do_sub_e_probs: torch.Tensor, + do_del_probs: torch.Tensor, + num_ins_pred: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Optionally pin clean-state predictions before integration. + + Conditional subclasses override this to preserve their scaffold. + """ + return x1_pred, do_sub_a_probs, do_sub_e_probs, do_del_probs, num_ins_pred + + def predict_step(self, batch, batch_idx): + return_traj = bool(getattr(self, "predict_return_traj", False)) + + gen_mols = self.sample( + batch, + batch_idx, + return_traj=return_traj, + ) + if not return_traj: + # Transfer once per batch, then retain the established list-of- + # frames interface with one final frame per attempted molecule. + final_mols = gen_mols.to_inplace("cpu", non_blocking=False).to_data_list() + gen_mols = [[mol] for mol in final_mols] + + # In non-topology modes, runtime edges labeled are not chemical + # bonds. Skip the validity pipeline and return the raw samples. + if not self.representation.requires_topology: + return { + "all_mols": list(gen_mols), + "is_valid": [False] * len(gen_mols), + "valid_mols": [], + "invalid_mols": list(gen_mols), + "invalid_mols_rdkit": [], + } + + # do quick validity check of the generated molecules + # take the last state of the trajectory and check validity + last_mols_rdkit = [ + i[-1].to_rdkit_mol( + self.vocab.atom_tokens, self.vocab.edge_tokens, self.vocab.charge_tokens + ) + for i in gen_mols + ] + mol_is_valid = [] + for mol in last_mols_rdkit: + if mol is None: + mol_is_valid.append(False) + continue + try: + mol_is_valid.append( + morphRD.mol_is_valid(mol, allow_charged=self.allow_charged) + ) + except Exception as e: + print(f"Error checking validity of molecule: {e}") + mol_is_valid.append(False) + + valid_mols = [ + i for i, valid in zip(gen_mols, mol_is_valid, strict=True) if valid + ] + # Keep trajectories for invalid molecules so failures can be inspected. + invalid_mols = [ + i for i, valid in zip(gen_mols, mol_is_valid, strict=True) if not valid + ] + invalid_mols_rdkit = [ + i + for i, valid in zip(last_mols_rdkit, mol_is_valid, strict=True) + if not valid + ] + + # ``all_mols`` / ``is_valid`` keep the dataloader batch order. The + # valid/invalid split re-orders molecules, so consumers that join + # per-molecule outputs must read the batch-ordered keys. + return { + "all_mols": list(gen_mols), + "is_valid": mol_is_valid, + "valid_mols": valid_mols, + "invalid_mols": invalid_mols, + "invalid_mols_rdkit": invalid_mols_rdkit, + } + + def _integrate_step(self, mol_t, predictions: IntegrationPredictions, **kwargs): + """Run one integration step on native Morph graph batches.""" + return self.integrator.integrate_step_gnn( + mol_t=mol_t, predictions=predictions, **kwargs + ) + + def _decode_predictions(self, mol_t, preds, *, requires_topology, force_charge_idx): + """Sample clean endpoint tokens, then apply representation and task masks.""" + # Extract predictions + x1_pred = preds["pos_head"] # (N_total, D) + + a_pred = _sample_logits(preds["atom_type_head"]) + c_pred = _sample_logits(preds["charge_head"]) + + # NOTE: predictions are for full adj matrix. + # NOTE: Will take triu and resymmetrize in integration step + e_pred = _sample_logits(preds["edge_type_head"]) + + insertion_pred = preds["insertion_head"] + + # Extract scalar decision logits; integration consumes probabilities. + do_sub_a_probs = torch.sigmoid(preds["do_sub_a_head"].view(-1)) + do_sub_e_probs = torch.sigmoid(preds["do_sub_e_head"].view(-1)) + do_del_probs = torch.sigmoid(preds["do_del_head"].view(-1)) + + # Channels absent from the representation retain their inert tokens. + if not requires_topology: + e_pred.fill_(0) # all + do_sub_e_probs.zero_() # never substitute an edge + if force_charge_idx is not None: + c_pred.fill_(force_charge_idx) + + # Extract insertion rate prediction (number of insertions per node) + num_ins_pred = preds["ins_rate_head"].view(-1) + + # Subclass hook: scaffold fine-tuning hard-zeros del / sub-a / sub-e + # (scaffold-scaffold) probs at scaffold positions and optionally pins + # their clean coordinate target. This must run before mol_1_pred is built. + x1_pred, do_sub_a_probs, do_sub_e_probs, do_del_probs, num_ins_pred = ( + self._apply_inference_edit_masks( + mol_t, + x1_pred, + do_sub_a_probs, + do_sub_e_probs, + do_del_probs, + num_ins_pred, + ) + ) + + # Clean-target prediction over mol_t's current topology. + mol_1_pred = batch_from_flat( + positions=x1_pred, + atom_types=a_pred, + charge_types=c_pred, + edge_types=e_pred, + edge_index=mol_t.edge_index, + batch_idx=mol_t.batch, + num_graphs=mol_t.num_graphs, + _already_sorted=True, + _node_counts=mol_t.num_nodes_per_graph, + _edge_counts=mol_t.num_edges_per_graph, + ) + + h_latent = preds["h_latent"] + + return IntegrationPredictions( + mol_1_pred=mol_1_pred, + do_sub_a_probs=do_sub_a_probs, + do_sub_e_probs=do_sub_e_probs, + do_del_probs=do_del_probs, + num_ins_pred=num_ins_pred, + insertion_preds=insertion_pred, + h_latent=h_latent, + ) + + def sample( + self, + batch, + batch_idx, + return_traj: bool = False, + t_start: float = 0.0, + sampling_step_callback: Callable[[SamplingStepTrace], None] | None = None, + position_update: Callable | None = None, + ): + """Inference step for flow matching. + + Args: + batch: Tuple ``(mol_t, mol_1)`` from the dataloader. + batch_idx: Batch index. + return_traj: Return per-molecule trajectories instead of the final state. + t_start: If > 0, integrate only ``[t_start, 1]`` (snapped to a + schedule time point) starting from ``batch[0]`` as the state at + ``t_start``. Used by the renoise self-correction ablation. + sampling_step_callback: Optional callback receiving a + ``SamplingStepTrace`` after each integration step. It is only + called when supplied, so normal inference has no trace overhead. + position_update: Optional coordinate update forwarded to the integrator. + + Returns: + If ``return_traj`` is False: a ``ChemBatch`` with the final state. + If ``return_traj`` is True: a per-molecule list of trajectory frames. + + Sampling stays centered internally. When kept positions are frozen, the + accumulated translations are restored on output so kept coordinates and + attached geometric context remain in the input frame. + """ + model = self._get_model() + compiled_model = getattr( + model, "_compiled_call_impl", None + ) is not None or hasattr(model, "_orig_mod") + if self.vocab.atom_tokens is None: + raise ValueError( + "vocab.atom_tokens must be supplied when constructing " + "the sampling module." + ) + + # Get batch size from the batch (assuming it's similar to training) + mol_t, _mol_1 = batch + + batch_size = mol_t.batch_size + + output_frame = _OutputFrame.from_batch( + mol_t, + self.distributions.coordinate_std, + restore_input_frame=bool( + getattr(self, "_freeze_keep_positions", False) + and getattr(mol_t, "keep_mask", None) is not None + ), + ) + + t0, step_sizes = _sampling_interval(self.integrator.get_time_steps(), t_start) + t = torch.full((batch_size,), t0, device=self.device) + + # Trajectory storage (only used when return_traj=True). + # Frames live on CPU to avoid linear GPU growth over the integration loop. + mol_traj = [output_frame.snapshot(mol_t)] if return_traj else None + + # Per-trajectory conditioning that is invariant across integration steps + # (e.g. scaffold tokens encoded from the fixed start molecule) is + # computed once here and threaded into every step, + # instead of the model re-encoding it each step. Models without extra + # conditioning don't define the hook -> empty dict -> unchanged behaviour. + cond_kwargs: dict = {} + _precompute = getattr(model, "precompute_conditioning", None) + if _precompute is not None: + cond_kwargs = _precompute(mol_t) + + requires_topology = self.representation.requires_topology + requires_charges = self.representation.requires_charges + ins_edge_head = ( + getattr(model, "ins_edge_head", None) if requires_topology else None + ) + force_charge_idx = ( + None if requires_charges else neutral_charge_index(self.vocab) + ) + + integration_extensions = {} + if sampling_step_callback is not None: + integration_extensions["return_step_trace"] = True + if position_update is not None: + integration_extensions["position_update"] = position_update + + # Integration loop: integrate from t=0 to t=1 + for step_index, step_size in enumerate(step_sizes): + is_final = step_index == len(step_sizes) - 1 + state_before = mol_t + + # Resolve the dynamic graph size before entering Dynamo, as on the + # training path. Reading its CUDA scalar inside the model breaks + # compilation after topology changes invalidate the cached size. + if compiled_model: + cond_kwargs["max_seqlen"] = mol_t.max_seqlen + preds = model(mol_t, t.view(-1, 1), **cond_kwargs) + + predictions = self._decode_predictions( + mol_t, + preds, + requires_topology=requires_topology, + force_charge_idx=force_charge_idx, + ) + + # Integrate one step (edge prediction happens inside if head is provided) + step_result = self._integrate_step( + mol_t=mol_t, + predictions=predictions, + t=t, + dt=step_size, + is_final=is_final, + ins_edge_head=ins_edge_head, + force_charge_idx=force_charge_idx, + **integration_extensions, + ) + if sampling_step_callback is None: + mol_t = step_result + integration_trace = None + else: + mol_t, integration_trace = step_result + + # Release decoded predictions before the next forward pass. + del predictions + output_frame.recenter(mol_t) + + if sampling_step_callback is not None: + sampling_step_callback( + SamplingStepTrace( + mol_t=state_before, + mol_t_next=mol_t, + t=t, + dt=step_size, + preds=preds, + integration=integration_trace, + is_final=is_final, + ) + ) + del preds, state_before, integration_trace, step_result + + # Update time forward + # Number of graphs stays constant (batch_size) + t = t + step_size + + # Save state to trajectory on CPU; non-traj path only keeps the final frame. + if return_traj: + mol_traj.append(output_frame.snapshot(mol_t)) + + if return_traj: + # rectify the trajectory such that we get a traj for each molecule + traj_lists = [mol_traj_i.to_data_list() for mol_traj_i in mol_traj] + + return [list(frames) for frames in zip(*traj_lists, strict=True)] + + # The final integration state is local and can be scaled in place. + return output_frame.restore(mol_t) diff --git a/geom/explicit_h/runtime/src/morph/model/lightning/_setup.py b/geom/explicit_h/runtime/src/morph/model/lightning/_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..93f6248f6cc35915a8b97146198784d2386a4cb2 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/lightning/_setup.py @@ -0,0 +1,146 @@ +# Private compiler configuration is loaded only when compilation is enabled. +# ruff: noqa: PLC0415 + +"""Model setup: compile, EMA, forward. + +Mixin split out of :class:`morph.model.lightning.base.LightningModuleRates`. +""" + +import os + +import torch + +from morph.model.layers.attention import fix_efficient_attention_bias_metadata +from morph.utils.checkpoints import canonicalize_state_dict, state_dict_for_module + + +class SetupMixin: + def compile(self): + """Compile current and EMA models in place, unless MORPH_COMPILE=0. + + Both entry points use this setup. Compilation is lazy: training and + inference acquire the appropriate graphs when first executed. In-place + compilation keeps module identity, parameter references and state keys. + """ + if self.is_compiled: + return + if os.environ.get("MORPH_COMPILE", "1") == "0": + print("Model compilation disabled (MORPH_COMPILE=0).") + return + print("Compiling current and EMA models (lazy; MORPH_COMPILE=0 disables).") + fix_efficient_attention_bias_metadata() + # PyTorch 2.9's DDP graph partitioner can lose FakeTensor metadata on + # recompilation after validation. Keep this workaround opt-in. + import torch._dynamo.config as dynamo_config + + if os.environ.get("MORPH_COMPILE_DDP_OPTIMIZER") == "0": + dynamo_config.optimize_ddp = False + print("DDP compiler graph splitting disabled.") + if os.environ.get("MORPH_COMPILE_FALLBACK") == "1": + dynamo_config.suppress_errors = True + print("Compiler errors will warn and fall back to eager execution.") + # Per-loss grad-norm logging calls torch.autograd.grad(..., retain_graph=True) + # before the main backward. torch.compile's donated-buffer optimization frees + # activations after the first backward and rejects any subsequent one, so + # disable it when grad-norm tracking is active. + if self.log_grad_norms_every_n_steps > 0: + import torch._functorch.config as _functorch_config + + _functorch_config.donated_buffer = False + for model in (self.model, self.model_ema): + if model is not None: + model.compile(dynamic=True) + self.is_compiled = True + + def on_save_checkpoint(self, checkpoint): + """Save portable parameter names independent of ``torch.compile``.""" + state_dict = checkpoint.get("state_dict") + if state_dict is not None: + checkpoint["state_dict"] = canonicalize_state_dict(state_dict) + + def on_load_checkpoint(self, checkpoint): + """Adapt eager or compiled checkpoint keys to the current module.""" + state_dict = checkpoint.get("state_dict") + if state_dict is not None: + checkpoint["state_dict"] = state_dict_for_module(state_dict, self) + # Lightning logs a microbatch counter. Rebase it when a resumed manual + # run changes microbatch size/accumulation, preserving the update axis. + if not getattr(self, "automatic_optimization", True): + loop_state = ( + checkpoint.get("loops", {}) + .get("fit_loop", {}) + .get("epoch_loop.state_dict") + ) + if loop_state is not None and "global_step" in checkpoint: + loop_state["_batches_that_stepped"] = ( + checkpoint["global_step"] * self.manual_gradient_accumulation_steps + ) + + def _get_model(self): + """Return model or model_ema for inference based on use_ema_for_eval.""" + return ( + self.model_ema + if self.use_ema_for_eval and self.model_ema is not None + else self.model + ) + + def _update_ema(self): + """Update EMA parameters: ema = decay * ema + (1 - decay) * model.""" + # In automatic optimization this hook runs just before Lightning marks + # the optimizer step complete; manual optimization has already marked + # both optimizer branches complete. Convert both cases to the same + # one-based count of logical model updates. + update_step = self._optimizer_update_step() + if self.automatic_optimization: + update_step += 1 + + ema_decay = 0.0 + if update_step > self.ema_warmup_steps: + ema_decay = float(self.ema_decay) + if ema_decay and self.ema_decay_scheduler is not None: + ema_decay = self.ema_decay_scheduler.get_decay( + current_epoch=self.current_epoch, + ) + with torch.no_grad(): + ema_tensors = getattr(self, "_ema_tensors", None) + if ema_tensors is None: + ema_params = tuple(self.model_ema.parameters()) + model_params = tuple(self.model.parameters()) + ema_buffers = tuple(self.model_ema.buffers()) + model_buffers = tuple(self.model.buffers()) + if len(ema_params) != len(model_params): + raise RuntimeError("EMA and training model parameter sets differ") + if len(ema_buffers) != len(model_buffers): + raise RuntimeError("EMA and training model buffer sets differ") + ema_tensors = ( + ema_params, + model_params, + ema_buffers, + model_buffers, + ) + self._ema_tensors = ema_tensors + ema_params, model_params, ema_buffers, model_buffers = ema_tensors + # One grouped multi-tensor recurrence instead of two launches per + # parameter. A representative CPU transformer mix was ~22% faster; + # CUDA benefits more from eliminating the Python/kernel-launch loop. + if ema_decay == 0.0: + torch._foreach_copy_(ema_params, model_params) + else: + torch._foreach_lerp_(ema_params, model_params, 1.0 - ema_decay) + # Buffers are state rather than trainable weights; keep them exact, + # including integer counters that cannot be averaged. + if ema_buffers: + torch._foreach_copy_(ema_buffers, model_buffers) + + self.log( + "ema/decay", + ema_decay, + on_step=True, + on_epoch=False, + prog_bar=False, + logger=True, + ) + + def forward(self, *args, **kwargs): + """Forward through the trainable model.""" + return self.model(*args, **kwargs) diff --git a/geom/explicit_h/runtime/src/morph/model/lightning/_training.py b/geom/explicit_h/runtime/src/morph/model/lightning/_training.py new file mode 100644 index 0000000000000000000000000000000000000000..d31d946176e89289430efdd00afa66d1b4e08d9f --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/lightning/_training.py @@ -0,0 +1,513 @@ +"""Training: the shared flow-matching loss step. + +Mixin split out of :class:`morph.model.lightning.base.LightningModuleRates`. +""" + +import torch +import torch.nn.functional as F + +from morph.dataset.core.insertion_data import InsertedAtoms +from morph.model.losses.functional import ( + class_loss, + do_action_loss, + rate_loss, + typed_gmm_loss, + unimodal_insertion_loss, + zero_dependency, +) +from morph.utils.graph_math import unsorted_segment_mean_and_count +from morph.utils.loss_accumulation import LossTerm + + +def _zero_module_dependency(module) -> torch.Tensor: + """Keep every parameter in DDP's autograd graph without full reductions.""" + return sum(zero_dependency(parameter) for parameter in module.parameters()) + + +class TrainingMixin: + def _node_loss_exclusion_mask(self, mols_t) -> torch.Tensor | None: + """Optional per-node bool mask: True = drop this node from + discrete non-deletion and substitution losses. + + Default ``None`` keeps all nodes. + """ + return None + + def _position_loss_exclusion_mask(self, mols_t) -> torch.Tensor | None: + """Optional per-node bool mask: True = drop this node from x loss.""" + return None + + def shared_step(self, batch, batch_idx): + """Predict endpoint states and accumulate named per-graph losses.""" + if self.vocab.atom_tokens is None: + raise ValueError( + "vocab.atom_tokens must be supplied when constructing " + "the training module." + ) + + mols_t, mols_1, ins_targets, t = batch + self.loss_accumulator.begin_batch() + + # Compute max_seqlen eagerly and pass it in. + max_seqlen = mols_t.max_seqlen + + preds = self.model( + mols_t, + t.view(-1, 1), + max_seqlen=max_seqlen, + ) + + # Mask for nodes scheduled for deletion, reused for x, c, and a losses. + # Deletion nodes have their target type/charge frozen to the prior sample, so + # supervising them would bias predictions towards the prior distribution. + to_delete_mask = mols_t.lambda_del > 0.0 + position_mask = ~to_delete_mask + non_del_mask = position_mask + + # Optional per-node exclusion (e.g. scaffold atoms in fine-tuning). + # ``exclusion_mask`` is True at nodes that should NOT contribute to discrete + # non-deletion / substitution losses; defaults to ``None``. + exclusion_mask = self._node_loss_exclusion_mask(mols_t) + if exclusion_mask is not None: + exclusion_mask = exclusion_mask.bool() + non_del_mask = non_del_mask & ~exclusion_mask + + position_exclusion_mask = self._position_loss_exclusion_mask(mols_t) + if position_exclusion_mask is not None: + position_mask = position_mask & ~position_exclusion_mask.bool() + + charge = self._charge_loss(preds["charge_head"], mols_1, mols_t, non_del_mask) + losses = self._substitution_losses( + preds, mols_t, mols_1, non_del_mask, to_delete_mask, exclusion_mask + ) + losses.update( + self._insertion_deletion_losses(preds, ins_targets, mols_t, non_del_mask) + ) + losses["x"] = self._position_loss( + preds["pos_head"], mols_1, mols_t, position_mask + ) + losses["c"] = charge + self.loss_accumulator.set_batch_losses(losses, t=t) + + # Keep on-device (detach only); Lightning offloads these at log time. + # A per-step .item() here forces a blocking GPU->CPU sync every step. + self.loss_accumulator.add_stats( + { + "n_ins": (mols_t.lambda_ins > 0.0).sum().detach().float(), + "n_del": (mols_t.lambda_del > 0.0).sum().detach().float(), + } + ) + + loss = self.loss_accumulator.total_loss() + self.log_dict(self.loss_accumulator.log_dict(), prog_bar=False, logger=True) + + # Keep every ins_edge_head parameter in the autograd graph for DDP. + loss = loss + _zero_module_dependency(self.model.ins_edge_head) + + return loss + + def _charge_loss(self, c_pred, mols_1, mols_t, non_del_mask): + """Per-graph charge classification loss (skipped in pointcloud mode).""" + # NOTE take loss on all non-deletion nodes + if self.representation.requires_charges: + c_loss, c_batch_mask = class_loss( + c_pred, + mols_1.c, + self.charge_token_weights, + non_del_mask, + mols_t.batch, + mols_t.num_graphs, + reduction="none", + ) + else: + # Pointcloud mode: c is the neutral dummy charge. Skip the loss + # entirely (no information content) but keep charge_head in the graph. + c_loss = torch.zeros( + mols_t.num_graphs, 1, device=self.device + ) + zero_dependency(c_pred) + c_batch_mask = torch.zeros( + mols_t.num_graphs, dtype=torch.bool, device=self.device + ) + return LossTerm(c_loss, c_batch_mask) + + def _substitution_losses( + self, preds, mols_t, mols_1, non_del_mask, to_delete_mask, exclusion_mask + ): + """Atom-type and edge-type substitution losses (do-action + class).""" + a_pred = preds["atom_type_head"] + e_pred = preds["edge_type_head"] + do_sub_a_head = preds["do_sub_a_head"] + do_sub_e_head = preds["do_sub_e_head"] + + # Atom substitutions + # NOTE take loss on all nodes + do_sub_a_loss = do_action_loss( + do_sub_a_head, + mols_t.lambda_a_sub, + mols_t.batch, + mols_t.num_graphs, + reduction="none", + ) + # NOTE take loss on all non-deletion & to-be-substituted nodes + # del nodes have lambda_a_sub = 0 anyway, this is just for clarity + to_sub_mask = (non_del_mask) & (mols_t.lambda_a_sub > 0.0) + sub_a_class_loss, sub_a_batch_mask = class_loss( + a_pred, + mols_1.a, + self.atom_type_weights, + to_sub_mask, + mols_t.batch, + mols_t.num_graphs, + reduction="none", + ) + # Edge substitutions + if self.representation.requires_topology: + # Interpolation creates the clean target with a clone of the current + # state's topology, and collation preserves that ordering. Select the + # shared upper triangle directly instead of sorting both edge sets. + edge_index = mols_t.edge_index + # Boolean gathers each perform a synchronizing nonzero on CUDA. + # Derive the shared index once for all six edge tensors. + upper = (edge_index[0] < edge_index[1]).nonzero(as_tuple=True)[0] + edge_index_triu = edge_index[:, upper] + e_batch_triu = mols_t.batch[edge_index_triu[0]] + e_pred_triu = e_pred[upper] + do_sub_e_head_triu = do_sub_e_head[upper] + e_target_triu = mols_1.e[upper] + do_sub_e_target_triu = mols_t.lambda_e_sub[upper] + + # Supervise edge classes only on substituted, non-deletion edges. + # Edges where either endpoint is scheduled for deletion are excluded: + # their targets are frozen to the source value, so supervising e_pred + # would train it to predict prior edge types — the wrong signal. + _src_idx, _dst_idx = edge_index_triu + non_del_edge_mask = ~to_delete_mask[_src_idx] & ~to_delete_mask[_dst_idx] + if exclusion_mask is not None: + # Exclude edges where BOTH endpoints are excluded (matches inference + # masking: scaffold-scaffold edges can never change). + non_del_edge_mask = non_del_edge_mask & ~( + exclusion_mask[_src_idx] & exclusion_mask[_dst_idx] + ) + + do_sub_e_loss = do_action_loss( + do_sub_e_head_triu, + do_sub_e_target_triu, + e_batch_triu, + mols_t.num_graphs, + reduction="none", + ) + + to_sub_edge_mask = (non_del_edge_mask) & (do_sub_e_target_triu > 0.0) + sub_e_class_loss, sub_e_batch_mask = class_loss( + e_pred_triu, + e_target_triu, + self.edge_token_weights, + to_sub_edge_mask, + e_batch_triu, + mols_t.num_graphs, + reduction="none", + ) + else: + # Non-topology modes: runtime edges carry the inert token + # and lambda_e_sub is identically zero, so the supervised edge-loss + # path is both informationless and a DDP risk (the class_loss + # short-circuit would drop edge_type_head from the autograd graph). + # Use explicit graph-preserving dummies on the head outputs instead. + do_sub_e_loss = torch.zeros( + mols_t.num_graphs, 1, device=self.device + ) + zero_dependency(do_sub_e_head) + sub_e_class_loss = torch.zeros( + mols_t.num_graphs, 1, device=self.device + ) + zero_dependency(e_pred) + sub_e_batch_mask = torch.zeros( + mols_t.num_graphs, dtype=torch.bool, device=self.device + ) + + return { + "do_sub_a": LossTerm(do_sub_a_loss), + "sub_a_class": LossTerm(sub_a_class_loss, sub_a_batch_mask), + "do_sub_e": LossTerm(do_sub_e_loss), + "sub_e_class": LossTerm(sub_e_class_loss, sub_e_batch_mask), + } + + def _insertion_deletion_losses(self, preds, ins_targets, mols_t, non_del_mask): + """Combine deletion decisions, insertion counts, content, and bond losses.""" + do_del_loss = do_action_loss( + preds["do_del_head"], + mols_t.lambda_del, + mols_t.batch, + mols_t.num_graphs, + reduction="none", + ) + # Supervise insertion counts at every node, including zero-count nodes. + ins_loss_rate = rate_loss( + preds["ins_rate_head"], + mols_t.n_ins, + mols_t.batch, + mols_t.num_graphs, + reduction="none", + ) + if ins_targets.spawn_node_idx.numel() > 0: + state = self._insertion_content_loss( + preds["insertion_head"], ins_targets, mols_t + ) + edges = self._insertion_edge_losses( + preds, ins_targets, mols_t, non_del_mask + ) + else: + empty = torch.zeros(mols_t.num_graphs, 1, device=self.device) + mask = torch.zeros(mols_t.num_graphs, dtype=torch.bool, device=self.device) + state = LossTerm( + empty + _zero_module_dependency(self.model.insertion_head), mask + ) + edges = {"ins_e": LossTerm(empty, mask), "ins_e_ii": LossTerm(empty, mask)} + return { + "do_del": LossTerm(do_del_loss), + "ins_rate": LossTerm(ins_loss_rate), + "ins_state": state, + **edges, + } + + def _insertion_content_loss(self, insertion_pred, ins_targets, mols_t): + """Supervise nearest insertions or all mixture targets, then reduce by graph.""" + spawn_node_idx = ins_targets.spawn_node_idx + if self.insertion_head_mode == "unimodal": + # One-to-one supervision: each spawning node predicts only its + # single nearest pending insertion (no mixture marginalization). + node_idx = ins_targets.nearest_node_idx + ins_idx = ins_targets.nearest_ins_idx + uni_dict_pred = { + "mu": insertion_pred["mu"][node_idx], + "a_logits": insertion_pred["a_logits"][node_idx], + "c_logits": insertion_pred["c_logits"][node_idx], + } + + uni_loss, uni_components = unimodal_insertion_loss( + uni_dict_pred, + ins_targets.positions[ins_idx], + ins_targets.atom_types[ins_idx], + ins_targets.charge_types[ins_idx], + self.atom_type_weights, + self.charge_token_weights, + reduction="none", + include_c=self.representation.requires_charges, + return_components=True, + ) + + ins_loss_state = uni_loss.view(-1) + + # Diagnostic: log the per-component means (over the supervised + # nearest nodes) so we can see whether position or atom-type/ + # charge dominates the next-atom loss. These share the single + # ins_state weight; they are stats, not separately-weighted terms. + self.loss_accumulator.add_stat( + "ins_pos", uni_components["pos"].mean().detach() + ) + self.loss_accumulator.add_stat( + "ins_type", uni_components["type"].mean().detach() + ) + if "charge" in uni_components: + self.loss_accumulator.add_stat( + "ins_charge", uni_components["charge"].mean().detach() + ) + # Keep c_logits in the autograd graph for DDP when dropped. + if not self.representation.requires_charges: + ins_loss_state = ins_loss_state + zero_dependency( + uni_dict_pred["c_logits"] + ) + + # reduce the loss to per-graph level (key on the nearest nodes) + ins_loss_state, supervised_count = unsorted_segment_mean_and_count( + ins_loss_state.view(-1, 1), + mols_t.batch[node_idx], + mols_t.num_graphs, + ) + else: + # Each pending insertion is supervised at its assigned spawning node. + gmm_pred = { + "mu": insertion_pred["mu"][spawn_node_idx], + "sigma": insertion_pred["sigma"][spawn_node_idx], + "pi": insertion_pred["pi"][spawn_node_idx], + "a_probs": insertion_pred["a_probs"][spawn_node_idx], + "c_probs": insertion_pred["c_probs"][spawn_node_idx], + } + + gmm_loss, _ = typed_gmm_loss( + gmm_pred, + ins_targets.positions, + ins_targets.atom_types, + ins_targets.charge_types, + self.atom_type_weights, + self.charge_token_weights, + reduction="none", + include_c=self.representation.requires_charges, + ) + + ins_loss_state = gmm_loss.view(-1) + # Keep c_probs in the autograd graph for DDP when its term is dropped. + if not self.representation.requires_charges: + ins_loss_state = ins_loss_state + zero_dependency(gmm_pred["c_probs"]) + + # reduce the loss to per-graph level + ins_loss_state, supervised_count = unsorted_segment_mean_and_count( + ins_loss_state.view(-1, 1), + mols_t.batch[spawn_node_idx], + mols_t.num_graphs, + ) + + return LossTerm(ins_loss_state, supervised_count > 0) + + def _insertion_edge_losses(self, preds, ins_targets, mols_t, non_del_mask): + """Supervise bonds from future atoms to current atoms and to each other.""" + ins_loss_e = torch.zeros( + mols_t.num_graphs, 1, device=self.device, dtype=preds["ins_rate_head"].dtype + ) + ins_loss_e_ii = torch.zeros_like(ins_loss_e) + ins_e_batch_mask = torch.zeros( + mols_t.num_graphs, dtype=torch.bool, device=self.device + ) + ins_e_ii_batch_mask = torch.zeros_like(ins_e_batch_mask) + # Compute insertion edge loss if available + # Index spaces: + # - spawn/existing indices address current-state nodes in mols_t + # - local indices address inserted nodes in ins_targets (future nodes) + # - ins_edge_types provides integer edge-class supervision targets + spawn_idx = ins_targets.bonds_to_existing.spawn_idx + existing_idx = ins_targets.bonds_to_existing.existing_idx + + # Bundle the inserted-atom features once; gather per-edge subsets + # below by indexing instead of repeating x/a/c gathers. + ins_feats = InsertedAtoms( + ins_targets.positions, ins_targets.atom_types, ins_targets.charge_types + ) + + # Insertion-edge prediction is only meaningful when chemical bonds + # are part of the representation. In non-topology modes, skip the + # head call entirely and emit a parameter-sum dummy so DDP still + # sees ins_edge_head as participating. + if ( + self.representation.requires_topology + and spawn_idx.numel() > 0 + and existing_idx.numel() > 0 + ): + ins_edge_non_del = non_del_mask[spawn_idx] & non_del_mask[existing_idx] + # Get edge predictions using the insertion edge head. Empty + # supervision masks are handled tensor-natively by class_loss; + # branching on ``.any()`` here would synchronize the GPU. + ins_edge_logits = self.model.predict_inserted_to_existing_edges( + mols_t=mols_t, + out_dict=preds, + spawn_node_idx=spawn_idx, + existing_node_idx=existing_idx, + # Gather per-edge inserted attrs from canonical future nodes. + # NOTE predict edge types for future nodes, not current nodes + # NOTE then at inference we will adjust to current noise level + ins=ins_feats[ins_targets.bonds_to_existing.inserted_idx], + ) + if ins_edge_logits is not None and ins_edge_logits.numel() > 0: + # Reuse the shared class loss helper (same masking + + # per-graph averaging) to compute insertion edge loss. + ins_loss_e, ins_e_batch_mask = class_loss( + ins_edge_logits, + ins_targets.bonds_to_existing.bond_type, + self.edge_token_weights, + ins_edge_non_del, + mols_t.batch[spawn_idx], + mols_t.num_graphs, + reduction="none", + ) + # Compute ins -> ins edge loss (gated on topology for the same reason). + spawn_src_ii = ins_targets.bonds_between_inserted.spawn_src_idx + spawn_dst_ii = ins_targets.bonds_between_inserted.spawn_dst_idx + if self.representation.requires_topology and spawn_src_ii.numel() > 0: + ii_non_del = non_del_mask[spawn_src_ii] & non_del_mask[spawn_dst_ii] + edges_ii_src = ins_targets.bonds_between_inserted.src_idx + edges_ii_dst = ins_targets.bonds_between_inserted.dst_idx + predictor = self.model.predict_inserted_to_inserted_edges + ins_ii_logits = predictor( + mols_t=mols_t, + out_dict=preds, + spawn_src_idx=spawn_src_ii, + spawn_dst_idx=spawn_dst_ii, + src=ins_feats[edges_ii_src], + dst=ins_feats[edges_ii_dst], + ) + if ins_ii_logits is not None and ins_ii_logits.numel() > 0: + # Reuse the shared class loss helper (same masking + + # per-graph averaging) to compute ins->ins edge loss. + ins_loss_e_ii, ins_e_ii_batch_mask = class_loss( + ins_ii_logits, + ins_targets.bonds_between_inserted.bond_type, + self.edge_token_weights, + ii_non_del, + mols_t.batch[spawn_src_ii], + mols_t.num_graphs, + reduction="none", + ) + + return { + "ins_e": LossTerm(ins_loss_e, ins_e_batch_mask), + "ins_e_ii": LossTerm(ins_loss_e_ii, ins_e_ii_batch_mask), + } + + def _position_loss(self, x_pred, mols_1, mols_t, position_mask): + """Per-graph coordinate MSE over the selected supervised nodes.""" + # The graph-preserving dependency below handles an empty selection. + x_loss = F.mse_loss( + x_pred[position_mask], mols_1.x[position_mask], reduction="none" + ) + x_loss, supervised_count = unsorted_segment_mean_and_count( + x_loss, mols_t.batch[position_mask], mols_t.num_graphs + ) + x_batch_mask = supervised_count > 0 + + # Unconditionally retain the position output in the graph. This is a + # no-op for ordinary batches and covers the all-deleted edge case without + # a blocking device-to-host ``x_batch_mask.any()`` branch. + x_loss = x_loss + zero_dependency(x_pred) + + return LossTerm(x_loss, x_batch_mask) + + def _automatic_training_step(self, batch, batch_idx): + """Compute and log a loss for Lightning's automatic optimization.""" + loss = self.shared_step(batch, batch_idx) + self._maybe_log_per_loss_grad_norms() + self.log("loss/train", loss.detach(), prog_bar=True, logger=True) + return loss + + def _maybe_log_per_loss_grad_norms(self) -> None: + """Log per-loss-term gradient norms at the configured cadence. + + Runs *before* Lightning calls ``loss.backward()``, leveraging + ``retain_graph=True`` inside ``LossAccumulator.compute_grad_norms`` so + the autograd graph survives for the actual backward pass. The + ``.grad`` attributes on parameters are not touched, so this is safe to + interleave with DDP's reducer (which only fires on + ``loss.backward()``). + """ + every = self.log_grad_norms_every_n_steps + if every <= 0 or (self._optimizer_update_step() % every) != 0: + return + + norms = self.loss_accumulator.compute_grad_norms( + self.model.parameters(), + granularity=self.grad_norms_granularity, + use_weighted=False, + apply_component_weights=False, + ) + if not norms: + return + + component_keys = {k for keys in self.LOSS_GROUPS.values() for k in keys} + entries: dict[str, torch.Tensor] = {} + for key, val in norms.items(): + prefix = "grad_norm" if key in component_keys else "grad_norm_group" + entries[f"{prefix}/{key}"] = val.detach() + + self.log_dict( + entries, + on_step=True, + on_epoch=False, + prog_bar=False, + logger=True, + ) diff --git a/geom/explicit_h/runtime/src/morph/model/lightning/_validation.py b/geom/explicit_h/runtime/src/morph/model/lightning/_validation.py new file mode 100644 index 0000000000000000000000000000000000000000..760a4cb12e4c5e9175c3d5105947df70d8c7eeb2 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/lightning/_validation.py @@ -0,0 +1,263 @@ +"""Validation: per-batch and epoch-end metrics. + +Mixin split out of :class:`morph.model.lightning.base.LightningModuleRates`. +""" + +import os +from contextlib import ExitStack + +import torch +import torch.distributed as dist + +from morph.utils.metrics import ( + build_marginal_plots, + calc_atom_stabilities, + calc_metrics_, +) +from morph.utils.metrics.tensor import compute_batch_geometry + + +class ValidationMixin: + def on_validation_epoch_start(self): + # ``seed_everything`` deliberately gives every DDP process the same base + # seed. That is correct for model initialization, but an unconditional + # validation dataset otherwise draws the same priors on every rank and + # turns 4,096 requested samples into four copies of 1,024. Salt the + # validation RNG by rank; keeping the seed fixed across epochs also makes + # changes in the validation curve attributable to the model, not priors. + self._training_rng_state = torch.random.get_rng_state() + self._training_cuda_rng_states = ( + torch.cuda.get_rng_state_all() if torch.cuda.is_available() else None + ) + base_seed = int(os.environ.get("PL_GLOBAL_SEED", "0")) + torch.manual_seed(base_seed + self.global_rank) + + self.model_ema.eval() + # Tensor-style collections accumulate raw counts/histograms across the + # epoch so the reported metric is computed from the full epoch's atoms + # (not a per-batch rate averaged with the wrong weighting). + if self.distribution_metrics is not None: + self.distribution_metrics.reset() + if self.batch_metrics is not None: + self.batch_metrics.reset() + if self.stability_metrics is not None: + self.stability_metrics.reset() + if self.metrics is not None: + self.metrics.reset() + self._validation_rdkit_mols = [] + + def validation_step(self, batch, batch_idx): + batched_mols = self.sample(batch, batch_idx, return_traj=False) + + # Tensor / distribution metrics consume the batch directly and work in + # every representation, so they always run — no early return. Only the + # RDKit-mol chemistry metrics below require real topology. + if self.batch_metrics is not None and len(self.batch_metrics) > 0: + self.batch_metrics.update(compute_batch_geometry(batched_mols)) + if self.distribution_metrics is not None and len(self.distribution_metrics) > 0: + self.distribution_metrics.update(batched_mols) + + if not self.representation.requires_topology: + del batched_mols + return + + # RDKit is CPU-only. Transfer each batch tensor once before splitting; + # converting GPU graph views one molecule at a time would synchronize + # every coordinate/type/edge ``.cpu()`` call separately. + mols = batched_mols.to_inplace("cpu", non_blocking=False).to_data_list() + del batched_mols + + # List of RDKit molecules or None + rdkit_mols = [] + for mol in mols: + try: + rdkit_mols.append( + mol.to_rdkit_mol( + self.vocab.atom_tokens, + self.vocab.edge_tokens, + self.vocab.charge_tokens, + ) + ) + except Exception as e: + print(f"Error converting molecule to RDKit: {e}") + rdkit_mols.append(None) + + del mols + self._validation_rdkit_mols.extend(rdkit_mols) + + # RDKit per-atom stability — fed precomputed bools (not RDKit mols). + # Accumulate only here; compute/log/reset happens in + # on_validation_epoch_end so the rate is weighted by atom count across + # the full epoch, not molecule count per batch. + if self.stability_metrics is not None and len(self.stability_metrics) > 0: + stabilities = [] + for mol in rdkit_mols: + if mol is None: + continue + try: + stabilities.append(calc_atom_stabilities(mol)) + except Exception: + continue + if stabilities: + self.stability_metrics.update(stabilities) + + def on_validation_epoch_end(self): + # Python/list-backed chemistry metrics cannot use torchmetrics' DDP + # state reduction. Gather the complete molecule population, compute it + # once on rank zero, then broadcast the small scalar result dictionary + # so every rank exposes identical callback metrics. + if self.representation.requires_topology: + local_mols = self._validation_rdkit_mols + if dist.is_available() and dist.is_initialized(): + gathered = ( + [None] * dist.get_world_size() if dist.get_rank() == 0 else None + ) + dist.gather_object(local_mols, gathered, dst=0) + rdkit_mols = ( + [mol for rank_mols in gathered for mol in rank_mols] + if gathered is not None + else [] + ) + else: + rdkit_mols = local_mols + + eval_metrics = {} + if self.trainer.is_global_zero: + try: + eval_metrics = calc_metrics_(rdkit_mols, self.metrics) + except Exception as e: + print(f"Error calculating chemistry metrics: {e}") + if dist.is_available() and dist.is_initialized(): + payload = [eval_metrics] + dist.broadcast_object_list(payload, src=0) + eval_metrics = payload[0] + if eval_metrics: + self.log_dict( + {f"val/{key}": value for key, value in eval_metrics.items()}, + on_step=False, + on_epoch=True, + prog_bar=False, + logger=True, + sync_dist=False, + ) + self._validation_rdkit_mols = [] + + # RDKit per-atom stability — accumulated across the full epoch in + # validation_step; compute the atom-weighted rate once here. + if self.stability_metrics is not None and len(self.stability_metrics) > 0: + try: + stab_results = self.stability_metrics.compute() + self.log_dict( + { + f"val/{k}": (v.detach() if isinstance(v, torch.Tensor) else v) + for k, v in stab_results.items() + }, + on_step=False, + on_epoch=True, + prog_bar=False, + logger=True, + sync_dist=False, + ) + except Exception as e: + print(f"Error in stability-metric epoch-end logging: {e}") + finally: + self.stability_metrics.reset() + + # Tensor-based metrics run in every representation; logged here so the + # values land in the same wandb step as everything else. + if self.batch_metrics is not None and len(self.batch_metrics) > 0: + try: + # Sync once for both scalar computation and histogram plots. + # Every rank must enter each collective context; only rank zero + # reads the synchronized state to render figures. + with ExitStack() as stack: + for m in self.batch_metrics.values(): + stack.enter_context(m.sync_context()) + + batch_results = self.batch_metrics.compute() + self.log_dict( + { + f"val/batch/{key}": ( + v.detach() if isinstance(v, torch.Tensor) else v + ) + for key, v in batch_results.items() + }, + on_step=False, + on_epoch=True, + prog_bar=False, + logger=True, + sync_dist=False, + ) + + if self.trainer.is_global_zero and hasattr( + self.logger, "log_image" + ): + from morph.utils.metrics.plotting import ( # noqa: PLC0415 + build_batch_marginal_plots, + ) + + figs = build_batch_marginal_plots( + self.batch_metrics, + atom_tokens=list(self.vocab.atom_tokens), + ) + for name, fig in figs.items(): + try: + self.logger.log_image(key=name, images=[fig]) + except Exception as e: + print(f"Error logging {name}: {e}") + if figs: + import matplotlib.pyplot as plt # noqa: PLC0415 + + for fig in figs.values(): + plt.close(fig) + except Exception as e: + print(f"Error in batch-metric epoch-end logging: {e}") + finally: + self.batch_metrics.reset() + + # Distribution KLs are tensor-native and accumulate in every + # representation; edge/charge KLs are only registered when meaningful + # (see init_metrics), so an empty/absent collection just no-ops. + if self.distribution_metrics is None or len(self.distribution_metrics) == 0: + self._restore_training_rng() + return + + try: + # Sync once for both pooled KLs and their marginal plots. + with ExitStack() as stack: + for m in self.distribution_metrics.values(): + stack.enter_context(m.sync_context()) + + dist_results = self.distribution_metrics.compute() + self.log_dict( + { + f"val/{key}": (v.detach() if isinstance(v, torch.Tensor) else v) + for key, v in dist_results.items() + }, + on_step=False, + on_epoch=True, + prog_bar=False, + logger=True, + sync_dist=False, + ) + + if self.trainer.is_global_zero and hasattr(self.logger, "log_image"): + figures = build_marginal_plots(self.distribution_metrics) + for name, fig in figures.items(): + self.logger.log_image(key=f"val/marginals/{name}", images=[fig]) + if figures: + import matplotlib.pyplot as plt # noqa: PLC0415 + + for fig in figures.values(): + plt.close(fig) + except Exception as e: + print(f"Error in validation-epoch-end distribution logging: {e}") + finally: + self.distribution_metrics.reset() + self._restore_training_rng() + + def _restore_training_rng(self): + """Keep deterministic validation sampling from changing training RNG.""" + torch.random.set_rng_state(self._training_rng_state) + if self._training_cuda_rng_states is not None: + torch.cuda.set_rng_state_all(self._training_cuda_rng_states) diff --git a/geom/explicit_h/runtime/src/morph/model/lightning/base.py b/geom/explicit_h/runtime/src/morph/model/lightning/base.py new file mode 100644 index 0000000000000000000000000000000000000000..8e47fd5a045105016798dbd037468c6188f4dab8 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/lightning/base.py @@ -0,0 +1,220 @@ +import copy + +import hydra +import pytorch_lightning as pl +import torch +from omegaconf import DictConfig + +from morph.dataset.core.representation import Representation +from morph.dataset.core.vocab import Distributions, Vocab +from morph.model.lightning._optimizer import OptimizerMixin +from morph.model.lightning._sampling import SamplingMixin +from morph.model.lightning._setup import SetupMixin +from morph.model.lightning._training import TrainingMixin +from morph.model.lightning._validation import ValidationMixin +from morph.utils.edge_alignment import EDGE_ALIGNER +from morph.utils.loss_accumulation import ( + InverseSquaredTimeLossWeighting, + LossAccumulator, + UnifiedWeightedLoss, +) +from morph.utils.lr_schedulers import EMADecayScheduler +from morph.utils.metrics import MetricCollection + + +class LightningModuleRates( + SetupMixin, + OptimizerMixin, + TrainingMixin, + ValidationMixin, + SamplingMixin, + pl.LightningModule, +): + """Train endpoint predictions and edit posteriors for molecular flow matching. + + Deletion and substitution heads predict binary logits, supervised by the + positivity of numerical lambda targets. The insertion-count head predicts + the number of pending atoms per spawning node using Poisson NLL. A unimodal + or mixture head predicts their clean positions, atom types, and charges. + Insertion-edge training uses target inserted-atom features; sampling uses + predicted features before applying insertion noise. + + Position and charge heads predict the clean endpoint. The integrator combines + edit posteriors with schedule masses to advance the molecular state. + See ``src/morph/model/README.md`` for tensor and loss contracts. + """ + + def training_step(self, batch, batch_idx): + """Dispatch explicitly to automatic or manual optimizer stepping.""" + if self.automatic_optimization: + return self._automatic_training_step(batch, batch_idx) + return self._manual_training_step(batch, batch_idx) + + LOSS_GROUPS: dict[str, list[str]] = { + "sub": ["do_sub_a", "sub_a_class", "do_sub_e", "sub_e_class"], + "del": ["do_del"], + "ins": ["ins_rate", "ins_state", "ins_e", "ins_e_ii"], + "x": ["x"], + "c": ["c"], + } + + def __init__( + self, + model: DictConfig, + integrator: DictConfig, + loss_weights: DictConfig, + optimizer_config: DictConfig, + insertion_head_mode: str, + vocab: Vocab, + distributions: Distributions, + loss_weight_distributions: Distributions, + atom_type_weights: torch.Tensor, + edge_token_weights: torch.Tensor, + charge_token_weights: torch.Tensor, + time_dist: DictConfig, + metrics: MetricCollection, + stability_metrics: MetricCollection, + distribution_metrics: MetricCollection | None = None, + batch_metrics: MetricCollection | None = None, + ins_noise_scale: float = 0.25, + use_learnable_loss_weights: bool = False, + ema_decay: float = 0.999, + ema_warmup_steps: int = 5000, + ema_decay_scheduler: EMADecayScheduler | DictConfig | None = None, + use_ema_for_eval: bool = True, + use_time_weights: bool = False, + allow_charged: bool = False, + log_grad_norms_every_n_steps: int = 0, + grad_norm_log_interval: int = 500, + grad_norms_granularity: str = "component", + representation: str | Representation = Representation.MOLECULE, + ): + super().__init__() + + self.model = hydra.utils.instantiate(model) + + # vocab and distributions + self.vocab = vocab + self.distributions = distributions + self.loss_weight_distributions = loss_weight_distributions + + self.ins_noise_scale = ins_noise_scale + + # Whether charged species are considered chemically valid for the dataset + # (e.g. QM9 contains only neutral molecules; GEOM contains charged ones). + self.allow_charged = allow_charged + + # Controls mode-aware losses, validation metrics, and prediction cleanup. + self.representation = Representation(representation) + + # setup ema scheduler + self.ema_decay = float(ema_decay) + self.ema_warmup_steps = max(0, int(ema_warmup_steps)) + if isinstance(ema_decay_scheduler, DictConfig): + ema_decay_scheduler = hydra.utils.instantiate(ema_decay_scheduler) + self.ema_decay_scheduler = ema_decay_scheduler + self.use_ema_for_eval = use_ema_for_eval + + if insertion_head_mode not in {"unimodal", "gmm"}: + raise ValueError( + "insertion_head_mode must be 'unimodal' or 'gmm', got " + f"{insertion_head_mode!r}" + ) + self.insertion_head_mode = insertion_head_mode + self.time_dist = hydra.utils.instantiate(time_dist) + + self.integrator = hydra.utils.instantiate( + integrator, distributions=self.distributions + ) + + # weights for class losses + self.register_buffer("atom_type_weights", atom_type_weights) + self.register_buffer("edge_token_weights", edge_token_weights) + self.register_buffer("charge_token_weights", charge_token_weights) + + # metrics tracking for validation + self.metrics = metrics + self.stability_metrics = stability_metrics + # Distribution metrics accumulate over the entire validation epoch so we + # can log a single pooled KL and render marginal plots at epoch end. + self.distribution_metrics = distribution_metrics + # Tensor-based metrics for non-topology modes. Updated in validation_step + # and computed/logged in on_validation_epoch_end. + self.batch_metrics = batch_metrics + + # Exponential moving average of model parameters for stable inference + self.model_ema = copy.deepcopy(self.model) + for p in self.model_ema.parameters(): + p.requires_grad = False + self.model_ema.eval() + + # handling of edge utilities, especially for upper-triangular handling + self.edge_aligner = EDGE_ALIGNER + + # set up loss weighting for individual loss components + loss_weight_values = {k: float(v) for k, v in loss_weights.items()} + self.loss_weight_wrapper = UnifiedWeightedLoss( + manual_weights=loss_weight_values, + component_keys=list(loss_weight_values.keys()), + use_learnable=use_learnable_loss_weights, + ) + + # optionally weigh by interpolation time + if use_time_weights: + time_weights = { + "x": InverseSquaredTimeLossWeighting(clamp_max=100.0), + "c": lambda t: self.integrator.sub_schedule.rate(t).clamp(max=100.0), + "ins": lambda t: self.integrator.ins_schedule.rate(t).clamp(max=100.0), + "del": lambda t: self.integrator.del_schedule.rate(t).clamp(max=100.0), + "sub": lambda t: self.integrator.sub_schedule.rate(t).clamp(max=100.0), + } + else: + # always use time-weighting for x and c + time_weights = { + "x": InverseSquaredTimeLossWeighting(clamp_max=100.0), + "c": lambda t: self.integrator.sub_schedule.rate(t).clamp(max=100.0), + } + + # object to tie loss computation, weighting, and logging together + self.loss_accumulator = LossAccumulator( + self.loss_weight_wrapper, self.LOSS_GROUPS, time_weights + ) + + self.optimizer_config = optimizer_config + self._setup_optimization() + + if hasattr(self.model, "initialize_output_priors"): + # Dataset categorical marginals initialize the DiT prediction + # heads; keep the EMA copy exactly synchronized with that model. + self.model.initialize_output_priors(self.loss_weight_distributions) + self.model_ema.load_state_dict(self.model.state_dict()) + + # Per-loss gradient-norm logging. Each invocation runs one extra + # backward per reported term, so this is rate-limited via a step + # interval and is off by default. + self.log_grad_norms_every_n_steps = int(log_grad_norms_every_n_steps) + # Cadence for the cheap global + per-group grad-norm logging in + # on_before_optimizer_step (two full per-parameter .norm() passes). Pure + # diagnostics (gradient clipping is separate and always runs), so log them + # every N steps rather than every step. <=0 disables. global_step is + # DDP-synchronized, so every rank logs the same keys on the same steps. + self.grad_norm_log_interval = int(grad_norm_log_interval) + if grad_norms_granularity not in {"component", "group", "both"}: + raise ValueError( + "grad_norms_granularity must be one of component/group/both, " + f"got {grad_norms_granularity!r}" + ) + self.grad_norms_granularity = grad_norms_granularity + + self.is_compiled = False + + # lastly, save hyperparameters + self.save_hyperparameters( + ignore=[ + "ema_decay_scheduler", + "metrics", + "stability_metrics", + "distribution_metrics", + "batch_metrics", + ] + ) diff --git a/geom/explicit_h/runtime/src/morph/model/losses/__init__.py b/geom/explicit_h/runtime/src/morph/model/losses/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..92286d0d607b74160e4907c06028c11e27d9b7a8 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/losses/__init__.py @@ -0,0 +1,5 @@ +"""Stateless training losses. + +``functional`` implements insertion, action, rate, and class losses. Shared +records, weighting, and aggregation live in ``morph.utils.loss_accumulation``. +""" diff --git a/geom/explicit_h/runtime/src/morph/model/losses/functional.py b/geom/explicit_h/runtime/src/morph/model/losses/functional.py new file mode 100644 index 0000000000000000000000000000000000000000..c631817fe0d68a79eea1be16dcafc60fe7fdd097 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/model/losses/functional.py @@ -0,0 +1,327 @@ +import torch +import torch.nn.functional as F + +from morph.model.layers.gmm import get_typed_gmm_components +from morph.utils.graph_math import ( + unsorted_segment_mean, + unsorted_segment_mean_and_count, +) + + +def zero_dependency(value: torch.Tensor) -> torch.Tensor: + """Return scalar zero while retaining ``value`` in the autograd graph.""" + if value.numel() == 0: + return value.sum() * 0.0 + return value.reshape(-1)[0] * 0.0 + + +def reduce_loss(loss, reduction: str = "mean"): + if reduction == "mean": + return loss.mean() + elif reduction == "sum": + return loss.sum() + elif reduction == "none": + return loss + else: + raise ValueError(f"Invalid reduction: {reduction}") + + +def _type_log_prob_from_ce(target, dist, class_weights, name, N): + """Compute per-component type log-prob using PyTorch CE.""" + logits = dist.logits # [N, 1, K, T] + K = logits.shape[2] + num_classes = logits.shape[-1] + logits_flat = logits.reshape(-1, num_classes) + + if target.dim() == 1: + if target.shape[0] != N: + raise ValueError( + f"{name} hard targets must have shape [N], got {target.shape} for N={N}" + ) + target_idx = target.long() + target_idx_flat = target_idx.view(N, 1, 1).expand(N, 1, K).reshape(-1) + ce = F.cross_entropy( + logits_flat, + target_idx_flat, + weight=class_weights, + reduction="none", + ) + return -ce.view(N, 1, K) + + if target.dim() == 2: + if target.shape[0] != N: + raise ValueError( + f"{name} soft targets must have shape [N, T], " + f"got {target.shape} for N={N}" + ) + num_classes = dist.logits.shape[-1] + if target.shape[1] != num_classes: + raise ValueError( + f"{name} soft targets class dim mismatch: " + f"expected {num_classes}, got {target.shape[1]}" + ) + + target_probs = target.to(dtype=dist.logits.dtype) + target_probs = target_probs / target_probs.sum(dim=-1, keepdim=True).clamp_min( + 1e-8 + ) + target_probs_flat = ( + target_probs.view(N, 1, 1, num_classes) + .expand(N, 1, K, num_classes) + .reshape(-1, num_classes) + ) + ce = F.cross_entropy( + logits_flat, + target_probs_flat, + weight=class_weights, + reduction="none", + ) + return -ce.view(N, 1, K) + + raise ValueError( + f"{name} targets must be rank-1 indices or rank-2 " + f"probabilities, got rank {target.dim()}" + ) + + +def typed_gmm_loss( + gmm_output, + target_x, + target_a, + target_c, + class_weights_a, + class_weights_c, + reduction="mean", + include_c: bool = True, +): + """ + Computes the NLL loss using the get_typed_gmm_components helper. + + When ``include_c=False`` the joint log-probability omits the charge term + in representations without charges. The c sub-head's outputs + still need a DDP-safe dummy elsewhere — see the lightning module. + """ + + # Create component distributions. The helper treats the first dimension as + # batch; here that "batch" is the valid insertion targets, so returned + # distributions have batch_shape [N, 1, K]. + mix_dist, x_dist, a_dist, c_dist = get_typed_gmm_components(gmm_output) + + N = mix_dist.logits.shape[0] + D = target_x.shape[-1] + + # Spatial target: [N, D] -> [N, 1, 1, D], broadcasting against K components. + target_x = target_x.view(N, 1, 1, -1) + + # Per-component log-probabilities, all shaped [N, 1, K]. + log_prob_x = x_dist.log_prob(target_x) / D + log_prob_a = _type_log_prob_from_ce( + target_a, a_dist, class_weights_a, "target_a", N + ) + # The mixture distribution already stores normalized log pi for each component. + log_prob_mix = mix_dist.logits + + # Joint log-prob before marginalizing over mixture components. In non-charge + # modes, omit the c term rather than training on neutral dummy targets. + log_joint = log_prob_mix + log_prob_x + log_prob_a + if include_c: + log_prob_c = _type_log_prob_from_ce( + target_c, c_dist, class_weights_c, "target_c", N + ) + log_joint = log_joint + log_prob_c + + # Marginalize over components: log sum_k p(k, x, a[, c]). + log_likelihood = torch.logsumexp(log_joint, dim=-1) + + if reduction == "mean": + nll = -torch.mean(log_likelihood) + elif reduction == "sum": + nll = -torch.sum(log_likelihood) + elif reduction == "none": + nll = -log_likelihood + else: + raise ValueError(f"Invalid reduction: {reduction}") + + ones = torch.ones((N, 1, 1), device=target_x.device, dtype=target_x.dtype) + return nll, (ones, ones) + + +def _ce_per_node(logits, target, class_weights): + """Weighted cross-entropy per row, supporting hard (rank-1) or soft (rank-2) + targets. Mirrors the target handling in :func:`_type_log_prob_from_ce` but + for a single (non-mixture) distribution; returns one scalar per row. + """ + if target.dim() == 1: + return F.cross_entropy( + logits, target.long(), weight=class_weights, reduction="none" + ) + if target.dim() == 2: + probs = target.to(dtype=logits.dtype) + probs = probs / probs.sum(dim=-1, keepdim=True).clamp_min(1e-8) + return F.cross_entropy(logits, probs, weight=class_weights, reduction="none") + raise ValueError( + f"insertion type targets must be rank-1 indices or rank-2 probabilities, " + f"got rank {target.dim()}" + ) + + +def unimodal_insertion_loss( + uni_output, + target_x, + target_a, + target_c, + class_weights_a, + class_weights_c, + reduction="mean", + include_c: bool = True, + return_components: bool = False, +): + """Per-node insertion loss for the unimodal (nearest-insertion) head. + + Counterpart to :func:`typed_gmm_loss` for the ``mode == "unimodal"`` + path. ``uni_output`` carries ``mu`` ``[M, 3]``, ``a_logits`` ``[M, N_a]`` and + ``c_logits`` ``[M, N_c]``, already gathered at the ``M`` supervised nodes + (each node's single nearest pending insertion). Position is a fixed-variance + Gaussian (MSE), averaged over spatial dims so its scale matches the GMM + path's per-dimension log-prob (``/D``); atom type and charge are weighted CE. + + When ``include_c=False`` the charge term is dropped (non-charge modes); the + caller is responsible for keeping ``c_logits`` in the autograd graph for DDP. + + When ``return_components=True`` also returns a dict of the *unreduced* + per-node component tensors (``{"pos", "type"[, "charge"]}``) so the caller can + log them separately for diagnostics; they share the single ``ins_state`` weight. + """ + mu = uni_output["mu"] + a_logits = uni_output["a_logits"] + c_logits = uni_output["c_logits"] + D = target_x.shape[-1] + + # Fixed-variance Gaussian NLL == MSE up to constants; normalize by D so the + # spatial term is on the same per-dimension scale as the GMM log_prob_x / D. + loss_x = F.mse_loss(mu, target_x, reduction="none").sum(dim=-1) / D # [M] + loss_a = _ce_per_node(a_logits, target_a, class_weights_a) # [M] + + loss = loss_x + loss_a + components = {"pos": loss_x, "type": loss_a} + if include_c: + loss_c = _ce_per_node(c_logits, target_c, class_weights_c) + loss = loss + loss_c + components["charge"] = loss_c + + reduced = reduce_loss(loss, reduction) + if return_components: + return reduced, components + return reduced + + +def do_action_loss( + do_action_pred, + num_actions, + batch, + num_graphs, + reduction: str = "mean", +): + """Calculate the do action loss for the given do action predictions. + Is used for all actions (substitutions, deletions). + + Args: + do_action_pred: The predicted do action values. + num_actions: The number of actions for each node. + batch: The batch indices for each node. + num_graphs: The number of graphs in the batch. + reduction: "mean" for a scalar or "none" for per-graph values. + """ + do_action = num_actions > 0.0 + + # NOTE: we use BCEWithLogitsLoss for scalar logit predictions + # do_action_pred is shape (N,) with logits + do_action_loss = F.binary_cross_entropy_with_logits( + do_action_pred.view(-1), + do_action.float().view(-1), + reduction="none", + ) + do_action_loss = unsorted_segment_mean( + do_action_loss.view(-1, 1), batch, num_graphs + ) + + return reduce_loss(do_action_loss, reduction) + + +def rate_loss(rate_pred, num_actions, batch, num_graphs, reduction: str = "mean"): + """ + Calculate the Poisson NLL rate loss for insertion predictions. + + Args: + rate_pred: The predicted rate values, shape (N,) or (N, 1). + num_actions: The integer number of actions for each node. + batch: The batch indices for each node. + num_graphs: The number of graphs in the batch. + reduction: "mean" for a scalar or "none" for per-graph values. + """ + + # Apply Poisson NLL to ALL nodes, including those with zero insertions. + rate_loss = F.poisson_nll_loss( + rate_pred.view(-1), + num_actions.view(-1), + log_input=False, + reduction="none", + full=True, + ) + + # NOTE: first normalize by number of nodes / graphs + # Otherwise, nodes with more atoms will have more weight by design + rate_loss = unsorted_segment_mean(rate_loss.view(-1, 1), batch, num_graphs) + + return reduce_loss(rate_loss, reduction) + + +def class_loss( + class_pred, + class_target, + class_weights, + do_action_mask, + batch, + num_graphs, + reduction: str = "mean", +): + """ + Calculate the class loss. Is not applied to masked nodes / edges. + Concretely: + c: class_loss is applied to all non-del nodes. + a: class_loss is applied to all non-del & to-be-substituted nodes. + e: class_loss is applied to edges between non-del nodes that are substituted. + ins_e : class_loss is applied to all edges between ins & non-del nodes. + ins_e_ii: class_loss is applied to all edges between ins & ins nodes. + """ + # 1. Compute CE ONLY on the nodes that need modification. Indexing with a + # boolean mask runs an internal ``nonzero`` -- a device sync with a + # data-dependent output size -- so derive the index ONCE and reuse it for + # every gather instead of re-deriving it per tensor (class_pred / target / + # batch). No empty-mask branch is needed: cross_entropy over an empty + # selection returns an empty tensor and unsorted_segment_mean over it returns + # zeros (count is clamped to >=1), so the loss is naturally 0 -- without the + # host sync a Python-side ``do_action_mask.any()`` would force every call. + idx = do_action_mask.nonzero(as_tuple=True)[0] + masked_loss = F.cross_entropy( + class_pred[idx], + class_target[idx], + weight=class_weights, + reduction="none", + ) + + # 2. Pool the masked losses per graph. + cls_loss, supervised_count = unsorted_segment_mean_and_count( + masked_loss.view(-1, 1), batch[idx], num_graphs + ) + + # 3. Keep class_pred in the autograd graph unconditionally: DDP rejects + # parameters with grad=None after backward. For a non-empty mask the gathered + # CE already does this; the ``+ 0.0 * class_pred.sum()`` term also covers the + # empty-mask case (zero gradient, populated) without a data-dependent branch. + cls_loss = cls_loss + zero_dependency(class_pred) + + # 4. Determine which graphs had modifications. + batch_has_modified = supervised_count > 0 + + return reduce_loss(cls_loss, reduction), batch_has_modified diff --git a/geom/explicit_h/runtime/src/morph/utils/__init__.py b/geom/explicit_h/runtime/src/morph/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..29bbd812cde4782119dc27b0c2a7073a93ba50c9 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/__init__.py @@ -0,0 +1 @@ +"""Shared utilities: tensor/graph helpers, RDKit conversion, metrics, viz.""" diff --git a/geom/explicit_h/runtime/src/morph/utils/checkpoints.py b/geom/explicit_h/runtime/src/morph/utils/checkpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..4b6b6fd189f1ef5a8194e09c3104231f52b7f662 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/checkpoints.py @@ -0,0 +1,61 @@ +"""Checkpoint compatibility across eager and ``torch.compile`` modules.""" + +from __future__ import annotations + +from collections import OrderedDict +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping + + import torch + + +def canonical_checkpoint_key(key: str) -> str: + """Remove implementation-detail ``_orig_mod`` path components.""" + return ".".join(part for part in key.split(".") if part != "_orig_mod") + + +def canonicalize_state_dict( + state_dict: Mapping[str, torch.Tensor], +) -> OrderedDict[str, torch.Tensor]: + """Return a compile-independent state dict and reject ambiguous keys.""" + canonical: OrderedDict[str, torch.Tensor] = OrderedDict() + source_keys: dict[str, str] = {} + for key, value in state_dict.items(): + canonical_key = canonical_checkpoint_key(key) + if canonical_key in canonical and source_keys[canonical_key] != key: + raise RuntimeError( + "Checkpoint keys collide after removing _orig_mod: " + f"{source_keys[canonical_key]!r} and {key!r}" + ) + canonical[canonical_key] = value + source_keys[canonical_key] = key + metadata = getattr(state_dict, "_metadata", None) + if metadata is not None: + canonical._metadata = { + canonical_checkpoint_key(key): value for key, value in metadata.items() + } + return canonical + + +def state_dict_for_module( + state_dict: Mapping[str, torch.Tensor], module: torch.nn.Module +) -> OrderedDict[str, torch.Tensor]: + """Map canonical checkpoint keys onto an eager or compiled module.""" + canonical = canonicalize_state_dict(state_dict) + target_state = module.state_dict() + target_keys = {canonical_checkpoint_key(key): key for key in target_state} + adapted = OrderedDict( + (target_keys.get(key, key), value) for key, value in canonical.items() + ) + metadata = getattr(canonical, "_metadata", None) + target_metadata = getattr(target_state, "_metadata", {}) + if metadata is not None: + target_metadata_keys = { + canonical_checkpoint_key(key): key for key in target_metadata + } + adapted._metadata = { + target_metadata_keys.get(key, key): value for key, value in metadata.items() + } + return adapted diff --git a/geom/explicit_h/runtime/src/morph/utils/edge_alignment.py b/geom/explicit_h/runtime/src/morph/utils/edge_alignment.py new file mode 100644 index 0000000000000000000000000000000000000000..b15835716bd6e19e8cb2a17af2741ce9c88199b1 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/edge_alignment.py @@ -0,0 +1,136 @@ +"""Upper-triangle and full-symmetric edge alignment. + +Prefer the module-level ``EDGE_ALIGNER`` singleton in hot loops. +""" + +import torch +from torch_geometric.utils import sort_edge_index + + +class EdgeAligner: + """ + Helper class to handle edge indices and attributes. + Can be used to align edge indices and attributes between two groups of edges. + + The class is stateless, so prefer the module-level ``EDGE_ALIGNER`` singleton + over instantiating new objects in hot loops (e.g. per-batch integration or + per-molecule join ops) to avoid unnecessary Python allocations. + """ + + def __init__(self): + pass + + def get_canonical_upper_triangle_with_index( + self, edge_index, edge_attr, include_diag=False + ): + """Returns (edge_index, edge_attr) for the upper triangle.""" + # 1. Sort first to ensure canonical order + edge_index, edge_attr = sort_edge_index(edge_index, edge_attr) + row, col = edge_index + + # 2. Filter + mask = row <= col if include_diag else row < col + + # Return BOTH the filtered index and attributes + return edge_index[:, mask], edge_attr[mask] + + def _process_edge_group(self, edge_index, attrs): + """Sort one edge group once and return its upper-triangle attributes.""" + if not attrs: + return None, [] + canonical_index, sorted_attrs = sort_edge_index(edge_index, attrs) + row, col = canonical_index + mask = row < col + return canonical_index[:, mask], [attr[mask] for attr in sorted_attrs] + + def align_edges(self, source_group, target_group=None): + """ + Flexible handler for aligning edge attributes. + + Args: + source_group: Tuple of (edge_index, [attr1, attr2, ...]) + target_group: (Optional) Tuple of (edge_index, [attr1, attr2, ...]) + + Returns: + Flat tuple of all processed attributes from source then target. + """ + src_index, src_attrs = source_group + tgt_index, tgt_attrs = (None, None) if target_group is None else target_group + + # 1. Process Source Group + src_triu_index, src_triu_attrs = self._process_edge_group(src_index, src_attrs) + + # 2. Process Target Group (if exists) + tgt_triu_attrs = [] + if target_group: + tgt_triu_index, tgt_triu_attrs = self._process_edge_group( + tgt_index, tgt_attrs + ) + + # Return all attributes flattened + out = { + "edge_index": (src_triu_index, tgt_triu_index), + "edge_attr": tuple(src_triu_attrs + tgt_triu_attrs), + } + + else: + out = { + "edge_index": src_triu_index, + "edge_attr": src_triu_attrs, + } + return out + + def symmetrize_edges( + self, + edge_index_triu: torch.Tensor, + attrs: list[torch.Tensor], + num_nodes: int | None = None, + ) -> tuple[torch.Tensor, tuple[torch.Tensor, ...]]: + """ + Reverse: Upper Triangle -> Full Symmetric + Handles attributes efficiently by calculating the sort permutation once. + + ``num_nodes`` is the radix for the lexical sort key and only needs to + exceed the largest node index. Callers that already know the node count + (e.g. ``positions.shape[0]``) should pass it to skip a + ``full_edge_index.max().item()`` device sync in the hot integration loop. + """ + row, col = edge_index_triu + + # 1. Identify off-diagonal edges to mirror + mask = row < col + + # 2. Create the mirrored index + mirror_index = torch.stack([col[mask], row[mask]], dim=0) + full_edge_index = torch.cat([edge_index_triu, mirror_index], dim=1) + + # 3. Compute Sort Permutation ONCE + # Repeated sorts risk putting equal-key attributes out of sync. + # if the sort isn't stable. We compute indices explicitly. + # Simple Lexical Sort: row * max_cols + col + # (Or use PyTorch's stable sort on rows then cols) + if num_nodes is None: + num_nodes = int(full_edge_index.max().item()) + 1 + perm = (full_edge_index[0] * num_nodes + full_edge_index[1]).argsort() + + # Apply sort to index + full_edge_index_sorted = full_edge_index[:, perm] + + # 4. Process all attributes + full_attrs_sorted = [] + for attr in attrs: + # Mirror the attributes corresponding to the mask + mirror_attr = attr[mask] + full_attr = torch.cat([attr, mirror_attr], dim=0) + + # Apply the SAME permutation + full_attrs_sorted.append(full_attr[perm]) + + return full_edge_index_sorted, tuple(full_attrs_sorted) + + +# Stateless shared instance — reuse in hot paths instead of constructing new +# ``EdgeAligner()`` objects per batch / per molecule. + + +EDGE_ALIGNER = EdgeAligner() diff --git a/geom/explicit_h/runtime/src/morph/utils/graph_math.py b/geom/explicit_h/runtime/src/morph/utils/graph_math.py new file mode 100644 index 0000000000000000000000000000000000000000..a3cbce3098c507a30512fd13db3ab3b7784e406c --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/graph_math.py @@ -0,0 +1,181 @@ +"""Tensor and edge-index helpers: Kabsch alignment and segment aggregation.""" + +from functools import cache + +import torch +from torch_geometric.utils import to_dense_adj + + +def edge_types_to_symmetric(edge_index, edge_types, num_atoms): + """ + Convert edge types to a symmetric adjacency matrix. + edge_index: Shape (2, E) - edge indices + edge_types: Shape (E) - edge types + num_atoms: int - number of atoms in the graph + Returns: + adj_matrix: Shape (N, N) - symmetric adjacency matrix + """ + adj_matrix = to_dense_adj(edge_index, edge_attr=edge_types, max_num_nodes=num_atoms) + adj_matrix = adj_matrix.squeeze() + return adj_matrix + + +def rigid_alignment(x_0, x_1, pre_centered=False): + """ + See: https://en.wikipedia.org/wiki/Kabsch_algorithm + Alignment of two point clouds using the Kabsch algorithm. + Based on: https://gist.github.com/bougui505/e392a371f5bab095a3673ea6f4976cc8 + """ + d = x_0.shape[1] + assert x_0.shape == x_1.shape, "x_0 and x_1 must have the same shape" + + # remove COM from data and record initial COM + if pre_centered: + x_0_mean = x_0.new_zeros((1, d)) + x_1_mean = x_1.new_zeros((1, d)) + x_0_c = x_0 + x_1_c = x_1 + else: + x_0_mean = x_0.mean(dim=0, keepdim=True) + x_1_mean = x_1.mean(dim=0, keepdim=True) + x_0_c = x_0 - x_0_mean + x_1_c = x_1 - x_1_mean + + # Covariance matrix + H = x_0_c.T.mm(x_1_c) + U, _, V = torch.svd(H) + # Rotation matrix + R = V.mm(U.T) + # Translation vector + t = x_0.new_zeros((1, d)) if pre_centered else x_1_mean - R.mm(x_0_mean.T).T + + return R, t + + +def unsorted_segment_sum(data, segment_ids, num_segments): + """Sum ``data`` rows into ``num_segments`` buckets keyed by ``segment_ids``. + + Equivalent to a scatter-add over dim 0. Uses ``index_add_``, which takes a + 1-D index and broadcasts over the feature columns — avoiding the + ``unsqueeze().expand()`` index materialization a ``scatter_add_`` needs. + + Args: + data: Shape (N, K) - values to aggregate. + segment_ids: Shape (N,) - segment index in [0, num_segments) per row. + num_segments: int - number of output segments. + + Returns: + Shape (num_segments, K); empty segments are 0. + """ + result = data.new_zeros((num_segments, data.size(1))) + result.index_add_(0, segment_ids, data) + return result + + +def unsorted_segment_mean(data, segment_ids, num_segments): + """Mean of ``data`` rows per segment; empty segments yield 0. + + Counts via ``bincount`` (one 1-D op of shape ``(num_segments,)``) instead of + scattering a full ``ones_like(data)``. + + Args: + data: Shape (N, K) - values to aggregate. + segment_ids: Shape (N,) - segment index in [0, num_segments) per row. + num_segments: int - number of output segments. + + Returns: + Shape (num_segments, K); empty segments are 0. + """ + return unsorted_segment_mean_and_count(data, segment_ids, num_segments)[0] + + +def unsorted_segment_mean_and_count(data, segment_ids, num_segments): + """Return per-segment means and the un-clamped number of contributing rows.""" + result = data.new_zeros((num_segments, data.size(1))) + result.index_add_(0, segment_ids, data) + count = torch.bincount(segment_ids, minlength=num_segments) + return result / count.clamp(min=1).unsqueeze(1), count + + +def build_fully_connected_edge_index(n, device=None): + """Directed, no-self-loop edge_index of shape ``(2, N*(N-1))``. + + Canonical home for the fully-connected edge builder (consolidated from a + second, divergent copy that used to live in ``dataset.core.representation``). + Guards the ``n <= 1`` case explicitly; ``device=None`` yields a CPU tensor. + """ + if n <= 1: + return torch.empty((2, 0), dtype=torch.long, device=device) + rng = torch.arange(n, device=device) + src, dst = torch.meshgrid(rng, rng, indexing="ij") + mask = src != dst + return torch.stack([src[mask], dst[mask]], dim=0) + + +@cache +def _complete_graph_layout(num_nodes: int, device: torch.device): + edge_index = build_fully_connected_edge_index(num_nodes, device=device) + triu_rows, triu_cols = torch.triu_indices( + num_nodes, num_nodes, offset=1, device=device + ) + row, col = edge_index + i, j = torch.minimum(row, col), torch.maximum(row, col) + full_to_triu = (i * (2 * num_nodes - i - 1)) // 2 + (j - i - 1) + return edge_index, triu_rows, triu_cols, full_to_triu + + +def complete_graph_layout(num_nodes: int, device=None): + """Return cached complete edges, upper-triangle rows/cols, and their mapping.""" + device = torch.device("cpu") if device is None else torch.device(device) + return _complete_graph_layout(num_nodes, device) + + +def batched_full_to_triu( + edge_index: torch.Tensor | tuple[torch.Tensor, torch.Tensor], + batch: torch.Tensor, + node_counts: torch.Tensor, +) -> torch.Tensor: + """Map complete directed edges to their batched upper triangles. + + Graph nodes must be contiguous and each graph's edges must be complete and + directed. Edge storage order is arbitrary. The returned indices let + ``edge_attr_triu[full_to_triu]`` restore full symmetric attributes without + rebuilding or sorting ``edge_index``. + """ + rows, cols = edge_index + graph = batch[rows] + node_starts = node_counts.cumsum(0) - node_counts + local_rows = rows - node_starts[graph] + local_cols = cols - node_starts[graph] + lower = torch.minimum(local_rows, local_cols) + upper = torch.maximum(local_rows, local_cols) + widths = node_counts[graph] + + triu_counts = node_counts * (node_counts - 1) // 2 + triu_starts = triu_counts.cumsum(0) - triu_counts + within_graph = lower * (2 * widths - lower - 1) // 2 + upper - lower - 1 + return triu_starts[graph] + within_graph + + +def batched_triu_edge_layout( + edge_index: torch.Tensor | tuple[torch.Tensor, torch.Tensor], + batch: torch.Tensor, + node_counts: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + """Return one representative edge per pair and the full-to-triu mapping. + + This is shape-stable under ``torch.compile``: it avoids boolean indexing, + whose output length depends on tensor data, by selecting the first of each + pair of symmetric directed edges with an integer scatter reduction. + """ + full_to_triu = batched_full_to_triu(edge_index, batch, node_counts) + num_edges = edge_index[0].numel() + triu_edge_positions = full_to_triu.new_full((num_edges // 2,), num_edges) + triu_edge_positions.scatter_reduce_( + 0, + full_to_triu, + torch.arange(num_edges, device=edge_index[0].device), + reduce="amin", + include_self=True, + ) + return triu_edge_positions, full_to_triu diff --git a/geom/explicit_h/runtime/src/morph/utils/loss_accumulation.py b/geom/explicit_h/runtime/src/morph/utils/loss_accumulation.py new file mode 100644 index 0000000000000000000000000000000000000000..d4c8523585949eb02ae953c3597541216c9a0389 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/loss_accumulation.py @@ -0,0 +1,376 @@ +"""Per-graph loss records, time/component weights, and batch aggregation.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import torch +import torch.nn as nn + + +@dataclass(frozen=True) +class LossTerm: + """Per-graph loss values and the graphs included in their final mean. + + ``values`` has shape ``[B, ...]``; ``graph_mask`` is an optional boolean + tensor of shape ``[B]``. Empty supervision uses zero values and a false + mask, retaining any required autograd dependencies in ``values``. + """ + + values: torch.Tensor + graph_mask: torch.Tensor | None = None + + def __post_init__(self) -> None: + """Validate the batch dimension and optional boolean graph mask.""" + if self.values.ndim == 0: + raise ValueError("LossTerm requires per-graph values, not a scalar") + if self.graph_mask is not None: + if self.graph_mask.dtype != torch.bool: + raise ValueError("LossTerm graph_mask must be boolean") + if self.graph_mask.shape != self.values.shape[:1]: + raise ValueError("LossTerm graph_mask must have one entry per graph") + + +class InverseSquaredTimeLossWeighting: + """Clipped inverse-square weighting near the flow endpoint.""" + + def __init__(self, clamp_max: float = 10.0): + self.clamp_max = float(clamp_max) + + def __call__(self, t: torch.Tensor) -> torch.Tensor: + """Return clipped inverse-square weights for graph times.""" + w = 1.0 / ((1.0 - t + 1e-6) ** 2) + return torch.clamp(w, max=self.clamp_max) + + +class LearnableWeightedLoss(nn.Module): + """Weight component losses using learned Kendall-Gal log variances.""" + + def __init__(self, initial_weights, component_keys): + """Create Kendall-Gal log-variance weights from heuristic weights.""" + super().__init__() + self.component_keys = component_keys + s_values = [-math.log(initial_weights[key]) for key in component_keys] + self.s = nn.Parameter(torch.tensor(s_values, dtype=torch.float32)) + + def forward(self, losses_dict): + """Apply learned uncertainty weights to the available losses.""" + weights = torch.exp(-self.s) + total_loss = 0 + for i, key in enumerate(self.component_keys): + if key in losses_dict: + s_i = self.s[i] + total_loss += weights[i] * losses_dict[key] + 0.5 * s_i + return total_loss + + +class UnifiedWeightedLoss(nn.Module): + """Apply either fixed component weights or learned uncertainty weights.""" + + def __init__(self, manual_weights, component_keys, use_learnable=False): + super().__init__() + self.use_learnable = use_learnable + self.component_keys = component_keys + + if use_learnable: + eps = 1e-6 + initial_weights = { + key: max(float(manual_weights[key]), eps) + if manual_weights[key] > 0 + else eps + for key in component_keys + } + self.learnable_wrapper = LearnableWeightedLoss( + initial_weights, component_keys + ) + else: + self.register_buffer( + "manual_weights", + torch.tensor( + [manual_weights[key] for key in component_keys], + dtype=torch.float32, + ), + ) + self.learnable_wrapper = None + + def forward(self, losses_dict): + """Return the weighted sum of available loss components.""" + if self.use_learnable and self.learnable_wrapper is not None: + return self.learnable_wrapper(losses_dict) + total_loss = 0 + for i, key in enumerate(self.component_keys): + if key in losses_dict: + total_loss += self.manual_weights[i] * losses_dict[key] + return total_loss + + def get_weights(self): + """Return current component weights as Python floats.""" + weights = self._weight_vector().cpu().tolist() + return dict(zip(self.component_keys, weights, strict=True)) + + def _weight_vector(self) -> torch.Tensor: + """Return detached component weights on their owning device.""" + with torch.no_grad(): + if self.use_learnable and self.learnable_wrapper is not None: + return torch.exp(-self.learnable_wrapper.s) + return self.manual_weights + + def get_weight_tensors(self): + """Return component-weight views on their owning device.""" + return dict(zip(self.component_keys, self._weight_vector(), strict=True)) + + +class LossAccumulator: + """Per-step helper that collects raw losses, applies time and component weights, + and builds a uniform log dict separating pure losses from fully weighted losses. + + Args: + weight_module: ``UnifiedWeightedLoss`` instance that owns the component weights. + groups: Mapping from group name to list of component keys. + time_weight_modules: Mapping from GROUP NAME to a time-weighting callable. + """ + + def __init__( + self, + weight_module, + groups: dict[str, list[str]], + time_weight_modules: dict[str, callable] | None = None, + ): + self._weight_module = weight_module + self._groups = groups + self._time_weight_modules = time_weight_modules or {} + + # Reverse mapping: component_key -> group_name for fast lookups + self._key_to_group = { + key: group for group, keys in groups.items() for key in keys + } + + # Track pure and time-weighted losses separately + self._raw_losses: dict[str, torch.Tensor] = {} + self._tw_losses: dict[str, torch.Tensor] = {} + + self._stats: dict[str, torch.Tensor | float] = {} + self._current_time_weights: dict[str, torch.Tensor | float] = {} + + # ── Population ─────────────────────────────────────────────────── + + def begin_batch(self) -> None: + """Discard diagnostics left by the previous batch.""" + self._stats.clear() + + def set_batch_losses( + self, + batch_losses: dict[str, LossTerm], + t: torch.Tensor | None = None, + ): + """Apply per-graph time weights and masks, then reduce each named term.""" + self._raw_losses.clear() + self._tw_losses.clear() + self._current_time_weights.clear() + + # Precompute time-weight tensors + group_tw_tensors = {} + if t is not None: + for group, tw_module in self._time_weight_modules.items(): + tw = tw_module(t) + group_tw_tensors[group] = tw + # Keep on-device (detach only); .item() would force a per-step + # GPU->CPU sync. Lightning offloads it lazily at log time. + self._current_time_weights[group] = tw.mean().detach() + + for key, term in batch_losses.items(): + loss_b = term.values + if t is not None and loss_b.shape[0] != t.shape[0]: + raise ValueError(f"Loss {key!r} must have one row per graph time") + # 1. Apply time-weights to create a weighted copy + if t is not None: + group = self._key_to_group.get(key) + if group is not None and group in group_tw_tensors: + tw = group_tw_tensors[group] + target_shape = [-1] + [1] * (loss_b.ndim - 1) + loss_tw_b = loss_b * tw.view(*target_shape) + else: + loss_tw_b = loss_b + else: + loss_tw_b = loss_b + + # 2. Apply masking if provided + mask = term.graph_mask + if mask is not None: + mask_f = mask.reshape(-1, *([1] * (loss_b.ndim - 1))).to(loss_b.dtype) + per_row = loss_b.numel() // loss_b.shape[0] + denom = (mask.sum() * per_row).clamp(min=1) + self._raw_losses[key] = (loss_b * mask_f).sum() / denom + self._tw_losses[key] = (loss_tw_b * mask_f).sum() / denom + else: + self._raw_losses[key] = loss_b.mean() + self._tw_losses[key] = loss_tw_b.mean() + + def set_losses(self, losses: dict[str, torch.Tensor]): + """Fallback for pre-reduced scalar losses.""" + self._raw_losses = losses + self._tw_losses = losses.copy() + self._current_time_weights.clear() + + def add_stat(self, key: str, value): + """Record a diagnostic value without adding it to the objective.""" + self._stats[key] = value + + def add_stats(self, stats: dict): + """Record diagnostic values without adding them to the objective.""" + self._stats.update(stats) + + # ── Gradient diagnostics ───────────────────────────────────────── + + def compute_grad_norms( + self, + parameters, + *, + granularity: str = "component", + norm_type: float = 2.0, + use_weighted: bool = False, + apply_component_weights: bool = False, + ) -> dict[str, torch.Tensor]: + """Per-loss-term gradient norms w.r.t. ``parameters``. + + For each loss term (or group), computes ``torch.autograd.grad`` against + ``parameters`` with ``retain_graph=True`` so the graph remains intact + for the subsequent ``loss.backward()`` call. The ``.grad`` attribute of + each parameter is *not* mutated. + + Cost scales linearly with the number of terms reported (one extra + backward each), so callers should rate-limit invocation. + + Args: + parameters: Iterable of parameters to differentiate against. + granularity: ``"component"`` for per-key norms, ``"group"`` for + per-group sums of raw losses, or ``"both"``. + norm_type: p-norm used to reduce gradient tensors (default L2). + use_weighted: If ``True``, differentiate the time-weighted loss + ``self._tw_losses[k]``; otherwise use the raw loss + ``self._raw_losses[k]``. + apply_component_weights: If ``True``, multiply each term by its + current component weight (matches what ``total_loss`` actually + backprops). Useful to gauge effective gradient contribution. + + Returns: + Mapping ``{key_or_group: scalar grad-norm tensor}``. Keys for + losses that don't depend on any parameter (or whose graph was + detached) report ``0.0``. + """ + if granularity not in {"component", "group", "both"}: + raise ValueError( + f"granularity must be one of component/group/both, got {granularity!r}" + ) + + params = [p for p in parameters if p.requires_grad] + losses = self._tw_losses if use_weighted else self._raw_losses + + if apply_component_weights: + current_weights = self._weight_module.get_weight_tensors() + losses = { + k: float(current_weights.get(k, 1.0)) * v for k, v in losses.items() + } + + terms: dict[str, torch.Tensor] = {} + if granularity in ("component", "both"): + terms.update(losses) + if granularity in ("group", "both"): + for group, keys in self._groups.items(): + summands = [losses[k] for k in keys if k in losses] + if not summands: + continue + terms[f"__group__{group}"] = sum(summands) + + norms: dict[str, torch.Tensor] = {} + if not params or not terms: + return norms + + zero = next(iter(losses.values())).new_zeros(()) + for key, loss in terms.items(): + out_key = ( + key.removeprefix("__group__") if key.startswith("__group__") else key + ) + if not (isinstance(loss, torch.Tensor) and loss.requires_grad): + norms[out_key] = zero + continue + grads = torch.autograd.grad( + loss, + params, + retain_graph=True, + allow_unused=True, + create_graph=False, + ) + present = [g.detach() for g in grads if g is not None] + if not present: + norms[out_key] = zero + continue + stacked = torch.stack([g.norm(norm_type) ** norm_type for g in present]) + norms[out_key] = stacked.sum() ** (1.0 / norm_type) + + return norms + + # ── Outputs ────────────────────────────────────────────────────── + + def total_loss(self) -> torch.Tensor: + """Apply component weights to time-weighted losses for backpropagation.""" + return self._weight_module(self._tw_losses) + + def log_dict(self) -> dict[str, torch.Tensor | float]: + """Builds a uniform log dict separating pure and fully weighted losses.""" + entries: dict[str, torch.Tensor | float] = {} + + current_weights = self._weight_module.get_weight_tensors() + + total_pure = 0.0 + total_fully_weighted = 0.0 + + # Keep values on-device (detach only); Lightning offloads/reduces them + # lazily. A per-step .cpu() here forces a blocking D2H sync per component. + # 1. Log individual components + for key in self._raw_losses: + pure_val = self._raw_losses[key].detach() + + # Use current_weights here + weight_k = current_weights.get(key, 1.0) + fully_weighted_val = (weight_k * self._tw_losses[key]).detach() + + entries[f"loss/{key}"] = pure_val + entries[f"loss_weighted/{key}"] = fully_weighted_val + + total_pure += pure_val + total_fully_weighted += fully_weighted_val + + # 2. Log group sums + for group, keys in self._groups.items(): + pure_terms = [ + self._raw_losses[k].detach() for k in keys if k in self._raw_losses + ] + weighted_terms = [ + (current_weights.get(k, 1.0) * self._tw_losses[k]).detach() + for k in keys + if k in self._tw_losses + ] + + if pure_terms: + entries[f"loss/{group}"] = sum(pure_terms) + if weighted_terms: + entries[f"loss_weighted/{group}"] = sum(weighted_terms) + + # 3. Log totals + entries["loss/total"] = total_pure + entries["loss_weighted/total"] = total_fully_weighted + + # 4. Log stats, weights, and time-weights + for key, val in self._stats.items(): + entries[f"stats/{key}"] = val + + if getattr(self._weight_module, "use_learnable", False): + for k, w in current_weights.items(): + entries[f"weight/{k}"] = w + + for group, tw_val in self._current_time_weights.items(): + entries[f"time_weight/{group}"] = tw_val + + return entries diff --git a/geom/explicit_h/runtime/src/morph/utils/lr_schedulers.py b/geom/explicit_h/runtime/src/morph/utils/lr_schedulers.py new file mode 100644 index 0000000000000000000000000000000000000000..11f5b6ac9551480c8d4b539ba75614f9add1d887 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/lr_schedulers.py @@ -0,0 +1,157 @@ +import math + +from torch.optim.lr_scheduler import _LRScheduler + + +class LinearOneCycleLR(_LRScheduler): + """ + Linearly increase LR from initial_lr to max_lr over warmup_steps, + then keep LR constant at max_lr. + """ + + def __init__(self, optimizer, max_lr, warmup_steps, last_epoch=-1): + self.max_lr = max_lr + self.warmup_steps = warmup_steps + + # Save initial LRs of param groups + self.base_lrs = [pg["lr"] for pg in optimizer.param_groups] + + super().__init__(optimizer, last_epoch) + + def get_lr(self): + """Return each group's rate during warmup or the constant phase.""" + # Step-based schedule + if self.last_epoch >= self.warmup_steps: + # Constant LR phase + return [self.max_lr for _ in self.optimizer.param_groups] + + # Linear warmup phase + warmup_ratio = (self.last_epoch + 1) / self.warmup_steps + return [ + base_lr + warmup_ratio * (self.max_lr - base_lr) + for base_lr in self.base_lrs + ] + + +class CosineWarmupLR(_LRScheduler): + """ + Linearly increases LR from initial_lr to max_lr over warmup_steps, + then decays to min_lr using a cosine schedule over the remaining steps. + """ + + def __init__( + self, optimizer, max_lr, warmup_steps, total_steps, min_lr=1e-6, last_epoch=-1 + ): + self.max_lr = max_lr + self.min_lr = min_lr + self.warmup_steps = warmup_steps + self.total_steps = total_steps + + # Save initial LRs of param groups + self.base_lrs = [pg["lr"] for pg in optimizer.param_groups] + + super().__init__(optimizer, last_epoch) + + def get_lr(self): + """Return each group's rate during warmup or cosine decay.""" + # 1. Linear Warmup Phase + if self.last_epoch < self.warmup_steps: + alpha = self.last_epoch / self.warmup_steps + return [ + base_lr + alpha * (self.max_lr - base_lr) for base_lr in self.base_lrs + ] + + # 2. Cosine Decay Phase + # Calculate progress from 0.0 to 1.0 within the decay phase + progress = (self.last_epoch - self.warmup_steps) / ( + self.total_steps - self.warmup_steps + ) + progress = min(progress, 1.0) # Clip to ensure we don't go past end + + cosine_decay = 0.5 * (1 + math.cos(math.pi * progress)) + + return [ + self.min_lr + (self.max_lr - self.min_lr) * cosine_decay + for _ in self.base_lrs + ] + + +class MultiGroupCosineWarmupLR(_LRScheduler): + """ + Linearly increases LR from 0 to each group's base_lr over warmup_steps, + then decays to a fraction of the base_lr using a cosine schedule. + """ + + def __init__( + self, optimizer, warmup_steps, total_steps, min_lr_fraction=0.1, last_epoch=-1 + ): + self.warmup_steps = warmup_steps + self.total_steps = total_steps + self.min_lr_fraction = ( + min_lr_fraction # e.g., 0.1 means decay to 10% of base_lr + ) + super().__init__(optimizer, last_epoch) + + def get_lr(self): + """Apply warmup or cosine decay to each group's base rate.""" + # 1. Linear Warmup Phase + if self.last_epoch < self.warmup_steps: + alpha = (self.last_epoch + 1) / max(1, self.warmup_steps) + return [base_lr * alpha for base_lr in self.base_lrs] + + # 2. Cosine Decay Phase + progress = (self.last_epoch - self.warmup_steps) / max( + 1, self.total_steps - self.warmup_steps + ) + progress = min(progress, 1.0) # Clip to ensure we don't go past end + + cosine_decay = 0.5 * (1 + math.cos(math.pi * progress)) + + # Calculate the multiplier scale (between min_lr_fraction and 1.0) + multiplier = self.min_lr_fraction + (1.0 - self.min_lr_fraction) * cosine_decay + + return [base_lr * multiplier for base_lr in self.base_lrs] + + +class EMADecayScheduler: + """EMA decay scheduler with hold + fast ramp + slow ramp phases.""" + + def __init__( + self, + initial_decay: float, + mid_decay: float, + final_decay: float, + warmup_epochs: int, + fast_ramp_epochs: int, + total_epochs: int, + ): + self.initial_decay = float(initial_decay) + self.mid_decay = float(mid_decay) + self.final_decay = float(final_decay) + self.warmup_epochs = max(0, int(warmup_epochs)) + self.fast_ramp_epochs = max(0, int(fast_ramp_epochs)) + self.total_epochs = int(total_epochs) + + def get_decay(self, current_epoch: int) -> float: + """Return EMA decay for the given epoch within total training epochs.""" + if self.total_epochs <= 1: + return float(self.final_decay) + + current_epoch = min(max(0, int(current_epoch)), self.total_epochs - 1) + last_epoch = float(self.total_epochs - 1) + warmup = float(min(self.warmup_epochs, self.total_epochs - 1)) + fast_end = float( + min(self.warmup_epochs + self.fast_ramp_epochs, self.total_epochs - 1) + ) + + if current_epoch < warmup: + return float(self.initial_decay) + + if current_epoch < fast_end: + progress = (float(current_epoch) - warmup) / max(fast_end - warmup, 1e-8) + return float( + self.initial_decay + progress * (self.mid_decay - self.initial_decay) + ) + + progress = (float(current_epoch) - fast_end) / max(last_epoch - fast_end, 1e-8) + return float(self.mid_decay + progress * (self.final_decay - self.mid_decay)) diff --git a/geom/explicit_h/runtime/src/morph/utils/metrics/__init__.py b/geom/explicit_h/runtime/src/morph/utils/metrics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6f7b65dd9bceb97f8944f4c1eef7f08d4ed5ddfd --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/metrics/__init__.py @@ -0,0 +1,164 @@ +"""Code adapted from SemlaFlow. + +Package layout (split by dependency): +- ``tensor`` : geometric tensor metrics + bin edges/accumulators (no RDKit) +- ``distribution``: KL distribution metrics, batch-fed (no RDKit) +- ``chemistry`` : RDKit-mol metrics + shared pool + SEMLA + PoseBusters +- ``plotting`` : GT-vs-generated marginal plots + +Public surface re-exported here so ``from morph.utils.metrics import X`` +keeps working unchanged. +""" + +import torch +from torchmetrics import MetricCollection + +from morph.utils.metrics.chemistry import ( + AtomStability, + Diversity, + MoleculeStability, + Novelty, + SEMLAAtomStability, + SEMLAMoleculeStability, + SEMLAValidity, + Uniqueness, + Validity, + calc_atom_stabilities, + calc_posebusters_metrics, +) +from morph.utils.metrics.distribution import ( + AtomCountKL, + AtomTypeKL, + ChargeTypeKL, + EdgeTypeKL, +) +from morph.utils.metrics.plotting import build_marginal_plots +from morph.utils.metrics.tensor import build_batch_metrics + +# Public surface consumed elsewhere via ``from morph.utils.metrics import X``. +__all__ = [ + "MetricCollection", + "build_marginal_plots", + "calc_atom_stabilities", + "calc_metrics_", + "init_metrics", +] + + +def init_metrics( + train_smiles: list[str] | None = None, + target_n_atoms_distribution: torch.Tensor | None = None, + atom_type_distribution: torch.Tensor | None = None, + edge_type_distribution: torch.Tensor | None = None, + charge_type_distribution: torch.Tensor | None = None, + atom_tokens: list[str] | None = None, + edge_tokens: list[str] | None = None, + charge_tokens: list[str] | None = None, + allow_charged: bool = False, + distributions=None, + representation=None, + train_smiles_are_canonical: bool = False, +): + # Chemistry metrics consume the molecule population that validation has + # already gathered onto rank zero. Their compute calls must therefore not + # enter TorchMetrics collectives that the other ranks cannot join. + chemistry_metric_kwargs = {"sync_on_compute": False} + metrics = { + "validity": Validity(allow_charged=allow_charged, **chemistry_metric_kwargs), + "semla-validity": SEMLAValidity(**chemistry_metric_kwargs), + "semla-atom-stability": SEMLAAtomStability(**chemistry_metric_kwargs), + "semla-molecule-stability": SEMLAMoleculeStability(**chemistry_metric_kwargs), + "uniqueness": Uniqueness(**chemistry_metric_kwargs), + "diversity": Diversity(**chemistry_metric_kwargs), + **( + { + "novelty": Novelty( + train_smiles, + already_canonical=train_smiles_are_canonical, + **chemistry_metric_kwargs, + ) + } + if train_smiles is not None + else {} + ), + } + + # Distribution metrics: tensor-native KLs computed straight from the batch, + # so they run in every representation. Kept in their own collection so they + # accumulate across an entire validation epoch (rather than per batch) and + # drive the ground-truth-vs-generated marginal plots. Edge/charge KLs are + # only registered when the representation actually carries that field — + # otherwise they are degenerate (all / neutral by projection) and + # would log a meaningless 0. + want_edges = representation is None or representation.requires_topology + want_charges = representation is None or representation.requires_charges + distribution_metrics: dict = {} + tensor_metric_kwargs = {"sync_on_compute": False} + if target_n_atoms_distribution is not None: + distribution_metrics["atom_count_dist_kl"] = AtomCountKL( + target_n_atoms_distribution, **tensor_metric_kwargs + ) + if atom_type_distribution is not None and atom_tokens is not None: + distribution_metrics["atom_type_dist_kl"] = AtomTypeKL( + atom_type_distribution, labels=atom_tokens, **tensor_metric_kwargs + ) + if edge_type_distribution is not None and edge_tokens is not None and want_edges: + distribution_metrics["edge_type_dist_kl"] = EdgeTypeKL( + edge_type_distribution, labels=edge_tokens, **tensor_metric_kwargs + ) + if ( + charge_type_distribution is not None + and charge_tokens is not None + and want_charges + ): + distribution_metrics["charge_type_dist_kl"] = ChargeTypeKL( + charge_type_distribution, labels=charge_tokens, **tensor_metric_kwargs + ) + + stability_metrics = { + "rdkit-atom-stability": AtomStability(), + "rdkit-molecule-stability": MoleculeStability(), + } + + metrics = MetricCollection(metrics, compute_groups=False) + stability_metrics = MetricCollection(stability_metrics, compute_groups=False) + distribution_metrics = MetricCollection(distribution_metrics, compute_groups=False) + + # Geometric batch metrics — built from training-set target stats stored in + # Distributions. Run in every representation. The collection may be empty if + # Distributions lacks the geometric target stats. + batch_metrics_dict: dict = {} + if distributions is not None: + batch_metrics_dict = build_batch_metrics(distributions) + batch_metrics = MetricCollection(batch_metrics_dict, compute_groups=False) + + return metrics, stability_metrics, distribution_metrics, batch_metrics + + +def calc_metrics_(rdkit_mols, metrics) -> dict: + """Run an RDKit-mol metric collection over a list of mols and return the + scalar results. Stability lives in its own collection with a different + input contract (precomputed bools) — drive it separately. Atom-count KL + runs tensor-side as ``AtomCountKL``. + """ + # These molecules are gathered explicitly by validation. Compute each + # metric without torchmetrics' implicit DDP synchronization: list-backed + # states (uniqueness/diversity) cannot be reduced by torchmetrics, and a + # rank-zero-only compute would otherwise deadlock on tensor-backed metrics. + results = {} + for name, metric in metrics.items(): + metric.reset() + metric.update(rdkit_mols) + value = metric.compute() + results[name] = value.item() if isinstance(value, torch.Tensor) else value + try: + posebusters = calc_posebusters_metrics(rdkit_mols) + except Exception as exc: + # PoseBusters is diagnostic and must not discard the core chemistry + # metrics or abort a long training run if one structural check fails. + print(f"Error calculating PoseBusters metrics: {exc}") + else: + results.update( + {f"posebusters/{name}": value for name, value in posebusters.items()} + ) + return results diff --git a/geom/explicit_h/runtime/src/morph/utils/metrics/chemistry.py b/geom/explicit_h/runtime/src/morph/utils/metrics/chemistry.py new file mode 100644 index 0000000000000000000000000000000000000000..8ab3c2672b3eb53e8275712eccea0c38ccf3c8f3 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/metrics/chemistry.py @@ -0,0 +1,881 @@ +"""RDKit-mol metrics + the helpers they need (process pool, SEMLA valence +tables, PoseBusters). Everything in this file pulls RDKit; the tensor / +distribution side stays RDKit-free. +""" + +import faulthandler +import math +import multiprocessing as mp +import os +from concurrent.futures import ProcessPoolExecutor +from pathlib import Path + +import pandas as pd +import torch +from rdkit import Chem, DataStructs, RDLogger +from rdkit.Chem import AllChem +from torchmetrics import Metric + +from morph.utils import rdkit_utils as morphRD + +faulthandler.enable() +# Silence RDKit warnings (in addition to morph.utils.rdkit_utils) because +# this module may be imported first from some code paths. +RDLogger.DisableLog("rdApp.*") + + +# --------------------------------------------------------------------------- +# Base class +# --------------------------------------------------------------------------- + + +class GenerativeMetric(Metric): + """torchmetrics base for metrics consuming a list of RDKit mols.""" + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate statistics from a batch of RDKit molecules.""" + raise NotImplementedError + + def compute(self) -> torch.Tensor: + """Reduce the accumulated molecular statistics to a metric value.""" + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# Shared process pool for CPU-heavy RDKit work (optimise_mol / calc_energy). +# +# Why this exists: +# * MMFF optimisation + energy calc is Python/C serial per-molecule. +# * Running it on the main thread inside validation_step starves the +# TorchElastic rendezvous keep-alive thread and triggers +# RendezvousTimeoutError on long val cycles. +# * We use a "spawn" context so the children do not inherit the parent's +# CUDA context (fork + CUDA = undefined behavior). +# * The pool is lazy so simply importing this module does not pay the +# spawn cost; it starts on the first validation step. +# --------------------------------------------------------------------------- + +_RD_POOL: ProcessPoolExecutor | None = None +_RD_POOL_MAX_WORKERS = int(os.environ.get("MORPH_RD_POOL_WORKERS", "4")) +_PB_POOL: ProcessPoolExecutor | None = None +_PB_POOL_WORKERS = 0 + + +def _get_rdkit_pool() -> ProcessPoolExecutor: + global _RD_POOL # noqa: PLW0603 -- one reusable process-local worker pool + if _RD_POOL is None: + affinity = ( + len(os.sched_getaffinity(0)) + if hasattr(os, "sched_getaffinity") + else os.cpu_count() or 1 + ) + # Leave at least half the cores for DataLoader workers + rendezvous. + n = max(1, min(_RD_POOL_MAX_WORKERS, affinity // 2)) + _RD_POOL = ProcessPoolExecutor( + max_workers=n, + mp_context=mp.get_context("spawn"), + ) + return _RD_POOL + + +def _get_posebusters_pool(max_workers: int) -> ProcessPoolExecutor: + """Return a persistent spawn-safe pool for chunked PoseBusters checks.""" + global _PB_POOL, _PB_POOL_WORKERS # noqa: PLW0603 + if _PB_POOL is None or max_workers != _PB_POOL_WORKERS: + if _PB_POOL is not None: + _PB_POOL.shutdown(wait=True) + _PB_POOL = ProcessPoolExecutor( + max_workers=max_workers, + mp_context=mp.get_context("spawn"), + ) + _PB_POOL_WORKERS = max_workers + return _PB_POOL + + +def _pool_map(fn, items: list) -> list: + """Run ``fn`` over ``items`` in the shared RDKit pool. Falls back to a + plain list comprehension if the pool cannot be constructed (e.g. spawn is + unavailable) so metrics never break training. + """ + if not items: + return [] + try: + pool = _get_rdkit_pool() + n_workers = pool._max_workers # type: ignore[attr-defined] + chunk = max(1, len(items) // (n_workers * 4)) + return list(pool.map(fn, items, chunksize=chunk)) + except Exception: + return [fn(x) for x in items] + + +def _rd_optimise_mol(mol): + return morphRD.optimise_mol(mol) + + +# Validation runs MMFF optimisation up to 4x over an identical batch +# (opt-energy-validity + strain + strain-per-atom + opt-rmsd), each calling +# ``_pool_map(_rd_optimise_mol, ...)`` on the same ordered non-None mols. +# ``calc_metrics_`` feeds the *same* list object to every metric in the +# collection, so memoize one shared optimise pass per batch keyed on the list +# identity. A reference to ``mols`` is held while cached so its ``id`` cannot be +# recycled (size-1 cache); a different list object recomputes. Correctness does +# not depend on the cache hitting -- a miss simply recomputes. ``optimise_mol`` +# copies its input and the consumers (energy, conf_distance) are read-only, so +# sharing the optimised mols across metrics is safe. +_OPT_CACHE: dict = {"mols": None, "optimised": None} +_SMILES_CACHE: dict = {"mols": None, "smiles": None} + + +def _shared_optimise(mols: list) -> list: + """MMFF-optimise ``[m for m in mols if m is not None]`` once per batch, + shared across every optimise-using metric. Returns the optimised mols + aligned to that non-None subset (in original order; ``None`` where + optimisation failed). + """ + if _OPT_CACHE["mols"] is not mols: + non_none = [m for m in mols if m is not None] + _OPT_CACHE["mols"] = mols + _OPT_CACHE["optimised"] = _pool_map(_rd_optimise_mol, non_none) + return _OPT_CACHE["optimised"] + + +def _shared_canonical_smiles(mols: list) -> list[str | None]: + """Canonicalise one generated population once for uniqueness and novelty.""" + if _SMILES_CACHE["mols"] is not mols: + _SMILES_CACHE["mols"] = mols + _SMILES_CACHE["smiles"] = [ + None if mol is None else morphRD.smiles_from_mol(mol, canonical=True) + for mol in mols + ] + return _SMILES_CACHE["smiles"] + + +def _rd_calc_energy(mol): + return morphRD.calc_energy(mol) + + +def _rd_calc_energy_per_atom(mol): + return morphRD.calc_energy(mol, per_atom=True) + + +def _is_valid_float(num): + return num is not None and math.isfinite(float(num)) + + +def _canonical_smiles(smi: str) -> str | None: + mol = Chem.MolFromSmiles(smi) + if mol is None: + return None + return Chem.MolToSmiles(mol, canonical=True) + + +# --------------------------------------------------------------------------- +# Stability helpers +# --------------------------------------------------------------------------- + + +def calc_atom_stabilities(mol): + """Per-atom RDKit-sanity flags. ``True`` means *stable* (no problem + detected), ``False`` means the atom is involved in a + ``DetectChemistryProblems`` finding. Aligned so that downstream metrics + named ``*_stability`` report a *higher = better* number. + """ + stabilities = [True] * mol.GetNumAtoms() + for p in Chem.DetectChemistryProblems(mol): + if hasattr(p, "GetAtomIdx"): + stabilities[p.GetAtomIdx()] = False + return stabilities + + +SEMLA_ALLOWED_VALENCIES = { + "H": {0: 1, 1: 0, -1: 0}, + "C": {0: [3, 4], 1: 3, -1: 3}, + # QM9 contains N+ in forms such as NH+ and NH2+. + "N": {0: [2, 3], 1: [2, 3, 4], -1: 2}, + "O": {0: 2, 1: 3, -1: 1}, + "F": {0: 1, -1: 0}, + "B": 3, + "Al": 3, + "Si": 4, + "P": {0: [3, 5], 1: 4}, + "S": {0: [2, 6], 1: [2, 3], 2: 4, 3: 5, -1: 3}, + "Cl": 1, + "As": 3, + "Br": {0: 1, 1: 2}, + "I": 1, + "Hg": [1, 2], + "Bi": [3, 5], + "Se": [2, 4, 6], +} + + +def _SEMLA_is_valid_valence(valence, allowed, charge): + if isinstance(allowed, int): + return allowed == valence + if isinstance(allowed, list): + return valence in allowed + if isinstance(allowed, dict): + sub = allowed.get(charge) + if sub is None: + return False + return _SEMLA_is_valid_valence(valence, sub, charge) + return False + + +def SEMLA_mol_is_valid( + mol: Chem.rdchem.Mol, with_hs: bool = True, connected: bool = True +) -> bool: + """Whether the mol can be sanitised and, optionally, is fully connected.""" + if mol is None: + return False + mol_copy = Chem.Mol(mol) + if not with_hs: + mol_copy = Chem.RemoveAllHs(mol_copy) + try: + AllChem.SanitizeMol(mol_copy) + except Exception: + return False + return not (connected and len(AllChem.GetMolFrags(mol_copy)) != 1) + + +def SEMLA_calc_atom_stabilities(mol): + stabilities = [] + for atom in mol.GetAtoms(): + sym = atom.GetSymbol() + if sym not in SEMLA_ALLOWED_VALENCIES: + stabilities.append(False) + continue + stabilities.append( + _SEMLA_is_valid_valence( + atom.GetTotalValence(), + SEMLA_ALLOWED_VALENCIES[sym], + atom.GetFormalCharge(), + ) + ) + return stabilities + + +# --------------------------------------------------------------------------- +# PoseBusters +# --------------------------------------------------------------------------- + + +def _posebusters_config() -> dict: + """The reliable stock ``mol`` checks without energy/strain or flatness. + + Upstream ``config/mol.yml`` includes an ``energy_ratio`` entry without + ``chosen_binary_test_output``. It contributes no column when + ``full_report=False`` but can still execute, so outputless modules are + removed together with every energy-ratio entry. + + Energy relaxation dominates runtime and is deliberately outside this + structural plausibility check. PoseBusters' RDKit flatness implementation + can segfault on malformed generated structures; one such child terminates + the process pool and discards every metric. Keep the robust cheap checks + (sanity, connectivity, radicals, and distance geometry) in routine + validation. Energy/strain and flatness can be run as separate, explicitly + requested analyses on a prevalidated molecule set. + """ + # PoseBusters stays out of the ordinary metric import path. + import posebusters # noqa: PLC0415 + from yaml import safe_load # noqa: PLC0415 + + cfg_path = Path(posebusters.__file__).parent / "config" / "mol.yml" + cfg = safe_load(cfg_path.read_text(encoding="utf-8")) + + # We always call `bust(..., full_report=False)`, which returns only the + # columns named in `chosen_binary_test_output`. A module without that key + # therefore produces nothing while still running — drop it. Remove all + # explicitly unsupported modules in the second filter. + modules = [m for m in cfg.get("modules", []) if m.get("chosen_binary_test_output")] + modules = [ + m for m in modules if m.get("function") not in {"energy_ratio", "flatness"} + ] + cfg["modules"] = modules + return cfg + + +def _bust_posebusters_chunk(mols: list[Chem.rdchem.Mol]) -> pd.DataFrame: + """Run PoseBusters serially on one worker-owned molecule chunk.""" + from posebusters import PoseBusters # noqa: PLC0415 + + return PoseBusters(config=_posebusters_config(), max_workers=0).bust( + mols, None, None + ) + + +def calc_posebusters_metrics( + rdkit_mols: list[Chem.rdchem.Mol | None], + *, + max_workers: int | None = None, +) -> dict[str, float]: + """PoseBusters plausibility checks, reported with explicit denominators. + + ``rdkit_mols`` is the FULL generated list, ``None`` where the molecule could + not be built/sanitised. PoseBusters can only run on the non-``None`` subset, + so every rate has one of two bases and we report both plus their counts: + + * ``*_over_busted`` — denominator is the molecules PoseBusters evaluated + (the sanitisable subset). This is what the stock implementation reported, + and what Morph's published per-check table is on. + * ``*_over_generated`` — denominator is ``len(rdkit_mols)``, with + unsanitisable molecules counted as failures. This is the convention + TABASCO uses ("we save all outputs from the model, as opposed to only + valid molecules"). + + ``all_pass`` implements the headline definition — *a molecule is only + PoseBusters-valid if it passes every test*. A check that could not be + evaluated (NaN from an unavailable structural check, for example) counts + as a FAILURE, never as a pass: ``bool(float("nan"))`` is ``True`` in Python, + so a naive ``df.all(axis=1)`` would silently mark those molecules valid. + ``n_incomplete`` and the per-check ``*__n_nan`` keys expose exactly how many + molecules that affects, and ``all_pass_complete_only`` gives the rate over + molecules where every check did evaluate. + + ``max_workers=None`` (the default) uses up to eight allocation-bound CPU + workers. PoseBusters' own parallel path pickles its complete input dataframe + once for every molecule, which is quadratic in the population size. Instead, + each spawned worker receives one disjoint chunk and runs it in-process. + Pass ``0`` or a negative value to force in-process execution. + """ + n_generated = len(rdkit_mols) + valid_mols = [mol for mol in rdkit_mols if mol is not None] + n_busted = len(valid_mols) + if not valid_mols: + strict_validity = 0.0 if n_generated else float("nan") + return { + "validity": strict_validity, + "n_generated": float(n_generated), + "n_busted": 0.0, + "n_all_pass": 0.0, + "all_pass_over_generated": strict_validity, + } + + if max_workers is None: + affinity = ( + len(os.sched_getaffinity(0)) + if hasattr(os, "sched_getaffinity") + else os.cpu_count() or 1 + ) + max_workers = max(1, min(8, affinity)) + # Keep the heavy optional dependency and its CUDA probing out of ordinary + # training startup. The local config always excludes energy/strain. + from posebusters import PoseBusters # noqa: PLC0415 + + if max_workers > 0 and PoseBusters.__module__.startswith("posebusters."): + chunk_size = math.ceil(n_busted / max_workers) + chunks = [ + valid_mols[start : start + chunk_size] + for start in range(0, n_busted, chunk_size) + ] + frames = list( + _get_posebusters_pool(max_workers).map(_bust_posebusters_chunk, chunks) + ) + df = pd.concat(frames, ignore_index=True) + else: + df = PoseBusters(config=_posebusters_config(), max_workers=max_workers).bust( + valid_mols, None, None + ) + + results: dict[str, float] = { + "n_generated": float(n_generated), + "n_busted": float(n_busted), + } + + # Per-check rates. `pd.to_numeric` maps True/False -> 1.0/0.0 and leaves + # un-evaluated entries as NaN; `.mean()` skips NaN, so we record the + # evaluated count alongside every rate rather than hiding the shrinkage. + check_cols: list[str] = [] + for col in df.columns: + if col in ("file", "molecule"): + continue + try: + numeric = pd.to_numeric(df[col], errors="coerce") + except Exception: + print(f"Warning: could not process PoseBusters column '{col}'.") + continue + if numeric.isna().all(): + continue + check_cols.append(col) + n_nan = int(numeric.isna().sum()) + n_eval = n_busted - n_nan + results[col] = float(numeric.mean()) + results[f"{col}__n_eval"] = float(n_eval) + results[f"{col}__n_nan"] = float(n_nan) + # Same check, but every un-evaluated molecule counts as a failure and + # the denominator is every generated molecule. + results[f"{col}__over_generated"] = float( + numeric.fillna(0.0).sum() / n_generated + ) + + if not check_cols: + results["n_all_pass"] = 0.0 + results["all_pass_over_generated"] = 0.0 + results["validity"] = 0.0 + return results + + checks = df[check_cols].apply(pd.to_numeric, errors="coerce") + evaluated = checks.notna().all(axis=1) + passed = checks.fillna(0.0).astype(bool).all(axis=1) # NaN -> failure + n_all_pass = int(passed.sum()) + n_incomplete = int((~evaluated).sum()) + + results["n_checks"] = float(len(check_cols)) + results["n_all_pass"] = float(n_all_pass) + results["n_incomplete"] = float(n_incomplete) + results["all_pass_over_busted"] = n_all_pass / n_busted + results["all_pass_over_generated"] = n_all_pass / n_generated + # Canonical aggregate: every enabled check must pass, and failed molecule + # construction is a failure because the denominator is all generations. + results["validity"] = results["all_pass_over_generated"] + # Rate over molecules where every check actually evaluated, for comparison + # against the strict number above. + n_complete = int(evaluated.sum()) + results["n_complete"] = float(n_complete) + results["all_pass_complete_only"] = ( + float((passed & evaluated).sum()) / n_complete if n_complete else float("nan") + ) + return results + + +# --------------------------------------------------------------------------- +# RDKit-stability metrics (fed precomputed bools) +# --------------------------------------------------------------------------- + + +class AtomStability(Metric): + """Fraction of atoms passing RDKit ``DetectChemistryProblems``. Fed + precomputed per-atom bools by ``calc_atom_stabilities`` (True = stable). + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_state("atom_stable", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, stabilities: list[list[bool]]) -> None: + """Accumulate per-atom stability flags across molecules.""" + flat = [b for mol in stabilities for b in mol] + self.atom_stable += sum(flat) + self.total += len(flat) + + def compute(self) -> torch.Tensor: + """Return the fraction of evaluated atoms that are stable.""" + return self.atom_stable.float() / self.total + + +class MoleculeStability(Metric): + """Fraction of molecules where every atom is stable.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_state("mol_stable", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, stabilities: list[list[bool]]) -> None: + """Count molecules whose atoms are all stable.""" + mol_stables = [len(mol) > 0 and all(mol) for mol in stabilities] + self.mol_stable += sum(mol_stables) + self.total += len(mol_stables) + + def compute(self) -> torch.Tensor: + """Return the fraction of evaluated molecules that are stable.""" + return self.mol_stable.float() / self.total + + +# --------------------------------------------------------------------------- +# SEMLA stability + validity (per-element valence table, RDKit-mol input) +# --------------------------------------------------------------------------- + + +class SEMLAValidity(GenerativeMetric): + """SEMLA sanitisation and connectedness validity without charge filtering.""" + + def __init__(self, with_hs: bool = True, connected: bool = True, **kwargs): + super().__init__(**kwargs) + self.with_hs = with_hs + self.connected = connected + self.add_state("valid", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Count molecules passing the configured SemlaFlow validity checks.""" + is_valid = [ + SEMLA_mol_is_valid(mol, with_hs=self.with_hs, connected=self.connected) + for mol in mols + ] + self.valid += sum(is_valid) + self.total += len(mols) + + def compute(self) -> torch.Tensor: + """Return valid molecules divided by all attempted molecules.""" + return self.valid.float() / self.total + + +class SEMLAAtomStability(GenerativeMetric): + """Atom-stability using SEMLA's per-element valence table.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_state("atom_stable", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate valence-based atom stability for evaluable molecules.""" + for mol in mols: + if mol is None: + continue + try: + stabilities = SEMLA_calc_atom_stabilities(mol) + except Exception: + continue + self.atom_stable += sum(stabilities) + self.total += len(stabilities) + + def compute(self) -> torch.Tensor: + """Return stable atoms divided by all evaluable atoms.""" + return self.atom_stable.float() / self.total + + +class SEMLAMoleculeStability(GenerativeMetric): + """Fraction of mols where every atom passes SEMLA's valence check.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_state("mol_stable", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Count fully stable molecules, retaining failures in the denominator.""" + for mol in mols: + if mol is None: + self.total += 1 + continue + try: + stabilities = SEMLA_calc_atom_stabilities(mol) + except Exception: + self.total += 1 + continue + if len(stabilities) > 0 and all(stabilities): + self.mol_stable += 1 + self.total += 1 + + def compute(self) -> torch.Tensor: + """Return stable molecules divided by all attempted molecules.""" + return self.mol_stable.float() / self.total + + +# --------------------------------------------------------------------------- +# Validity / uniqueness / novelty +# --------------------------------------------------------------------------- + + +class Validity(GenerativeMetric): + """Fraction of attempted molecules passing Morph's chemistry checks.""" + + def __init__(self, allow_charged: bool = False, **kwargs): + super().__init__(**kwargs) + self.allow_charged = allow_charged + self.add_state("valid", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Count valid molecules, including failed decodes in the denominator.""" + is_valid = [ + morphRD.mol_is_valid(mol, allow_charged=self.allow_charged) + for mol in mols + if mol is not None + ] + self.valid += sum(is_valid) + self.total += len(mols) + + def compute(self) -> torch.Tensor: + """Return valid molecules divided by all attempted molecules.""" + return self.valid.float() / self.total + + +# TODO I don't think this will work with DDP +class Uniqueness(GenerativeMetric): + """Note: only tracks uniqueness of molecules which can be converted into SMILES""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.valid_smiles = [] + + def reset(self): + """Clear accumulated canonical SMILES and metric state.""" + super().reset() + self.valid_smiles = [] + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Collect canonical SMILES for successfully converted molecules.""" + # Use the same canonicalizer as Novelty (RemoveHs + canonical MolToSmiles + # with exception-safe None fallback) so the two metrics share a SMILES + # space and comparable denominators. + smiles = _shared_canonical_smiles(mols) + self.valid_smiles.extend(smi for smi in smiles if smi is not None) + + def compute(self) -> torch.Tensor: + """Return distinct canonical SMILES divided by valid SMILES count.""" + if not self.valid_smiles: + return torch.tensor(float("nan")) + return torch.tensor(len(set(self.valid_smiles))) / len(self.valid_smiles) + + +class Diversity(GenerativeMetric): + """Mean pairwise Tanimoto *distance* over ECFP fingerprints. Higher = more diverse. + + Matches TABASCO's ``MolecularDiversity``, which calls ``datamol.pdist`` -> + ``scipy.spatial.distance.pdist(metric="jaccard")``. Two details are needed + for their number to be comparable: datamol's ECFP default is **radius 3** + (ECFP6), not RDKit's usual radius 2, and the reported quantity is a + *distance* (1 - similarity), so their 0.89 means mean similarity ~0.11. + + Population is every molecule accumulated since ``reset()``, not a per-batch + average: pairs are formed across the whole set in ``compute()``. + """ + + def __init__(self, radius: int = 3, n_bits: int = 2048, **kwargs): + super().__init__(**kwargs) + self.radius = radius + self.n_bits = n_bits + self.fps = [] + + def reset(self): + """Clear accumulated molecular fingerprints and metric state.""" + super().reset() + self.fps = [] + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Collect Morgan fingerprints for successfully converted molecules.""" + for mol in mols: + if mol is None: + continue + try: + self.fps.append( + AllChem.GetMorganFingerprintAsBitVect( + mol, self.radius, nBits=self.n_bits + ) + ) + except Exception: + continue + + def compute(self) -> torch.Tensor: + """Return mean pairwise Tanimoto distance, or NaN for fewer than two.""" + if len(self.fps) < 2: + return torch.tensor(float("nan")) + # Sum over the upper triangle only; BulkTanimotoSimilarity is the C++ + # bulk path, so this stays O(n^2) comparisons without n^2 Python calls. + total, n_pairs = 0.0, 0 + for i, fp in enumerate(self.fps[:-1]): + sims = DataStructs.BulkTanimotoSimilarity(fp, self.fps[i + 1 :]) + total += len(sims) - sum(sims) # distance = 1 - similarity + n_pairs += len(sims) + return torch.tensor(total / n_pairs) + + +class Novelty(GenerativeMetric): + """Fraction of canonical generated SMILES absent from the training set.""" + + def __init__( + self, + train_smiles: list[str], + already_canonical: bool = False, + **kwargs, + ): + super().__init__(**kwargs) + + if already_canonical: + canonical = train_smiles + else: + affinity = ( + len(os.sched_getaffinity(0)) + if hasattr(os, "sched_getaffinity") + else os.cpu_count() or 1 + ) + n_workers = min(8, affinity) + with ProcessPoolExecutor(max_workers=n_workers) as executor: + canonical = list(executor.map(_canonical_smiles, train_smiles)) + + self.smiles = {smi for smi in canonical if smi is not None} + + self.add_state("novel", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Count novel SMILES among successfully canonicalized molecules.""" + smiles = _shared_canonical_smiles(mols) + valid_smiles = [smi for smi in smiles if smi is not None] + novel = [smi not in self.smiles for smi in valid_smiles] + + self.novel += sum(novel) + self.total += len(novel) + + def compute(self) -> torch.Tensor: + """Return novel molecules divided by canonicalized molecule count.""" + return self.novel.float() / self.total + + +# --------------------------------------------------------------------------- +# Energy / strain / RMSD +# --------------------------------------------------------------------------- + + +class EnergyValidity(GenerativeMetric): + """Fraction of attempted molecules with a finite force-field energy.""" + + def __init__(self, optimise=False, **kwargs): + super().__init__(**kwargs) + self.optimise = optimise + self.add_state("n_valid", default=torch.tensor(0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Count finite energies, optionally after geometry optimization.""" + num_mols = len(mols) + non_none = [m for m in mols if m is not None] + if self.optimise: + non_none = [m for m in _shared_optimise(mols) if m is not None] + energies = _pool_map(_rd_calc_energy, non_none) + valid_energies = [e for e in energies if _is_valid_float(e)] + self.n_valid += len(valid_energies) + self.total += num_mols + + def compute(self) -> torch.Tensor: + """Return finite-energy molecules divided by all attempted molecules.""" + return self.n_valid.float() / self.total + + +class AverageEnergy(GenerativeMetric): + """Average energy for molecules for which energy can be calculated. + + Energy can't be calculated for invalid mols and pose optimisation isn't + guaranteed to succeed; failures don't count towards the metric. Does not + require sanitisation, but sanitising first is usually a good idea. + """ + + def __init__(self, optimise=False, per_atom=False, **kwargs): + super().__init__(**kwargs) + self.optimise = optimise + self.per_atom = per_atom + self.add_state("energy", default=torch.tensor(0.0), dist_reduce_fx="sum") + self.add_state( + "n_valid_energies", default=torch.tensor(0), dist_reduce_fx="sum" + ) + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate finite molecular or per-atom force-field energies.""" + non_none = [m for m in mols if m is not None] + if self.optimise: + non_none = [m for m in _shared_optimise(mols) if m is not None] + energy_fn = _rd_calc_energy_per_atom if self.per_atom else _rd_calc_energy + energies = _pool_map(energy_fn, non_none) + valid_energies = [e for e in energies if _is_valid_float(e)] + self.energy += sum(valid_energies) + self.n_valid_energies += len(valid_energies) + + def compute(self) -> torch.Tensor: + """Return mean energy over successfully evaluated molecules.""" + return self.energy / self.n_valid_energies + + +# TODO: Add xTB as level of theory option and add forces as a metric + + +class AverageStrainEnergy(GenerativeMetric): + """Energy difference between a molecule's pose and its optimised pose. + + Only counted when the molecule has an energy, pose optimisation succeeds, + and the optimised-pose energy is calculable. Combine with + ``EnergyValidity(optimise=True)`` to track the fraction of molecules this + metric can be calculated on. + """ + + def __init__(self, per_atom=False, **kwargs): + super().__init__(**kwargs) + self.per_atom = per_atom + self.add_state( + "total_energy_diff", default=torch.tensor(0.0), dist_reduce_fx="sum" + ) + self.add_state("n_valid", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate energy drops between input and optimized geometries.""" + indexed = [(idx, m) for idx, m in enumerate(mols) if m is not None] + if not indexed: + return + + indices, _ = zip(*indexed, strict=True) + optimised = _shared_optimise(mols) + + energy_fn = _rd_calc_energy_per_atom if self.per_atom else _rd_calc_energy + + opt_pairs = [ + (i, m) for i, m in zip(indices, optimised, strict=True) if m is not None + ] + if not opt_pairs: + return + opt_indices, opt_mols_list = zip(*opt_pairs, strict=True) + opt_energies = _pool_map(energy_fn, list(opt_mols_list)) + + pairs = [ + (i, e) + for i, e in zip(opt_indices, opt_energies, strict=True) + if e is not None + ] + if not pairs: + return + + valid_indices, valid_energies = zip(*pairs, strict=True) + original_energies = _pool_map(energy_fn, [mols[i] for i in valid_indices]) + + energy_diffs = [ + orig - opt + for orig, opt in zip(original_energies, valid_energies, strict=True) + ] + self.total_energy_diff += sum(energy_diffs) + self.n_valid += len(energy_diffs) + + def compute(self) -> torch.Tensor: + """Return mean energy drop for pairs with valid energies.""" + return self.total_energy_diff / self.n_valid + + +class AverageOptRmsd(GenerativeMetric): + """Average RMSD between a molecule and its optimised pose. + + Only counted when the molecule is valid and pose optimisation succeeds. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.add_state("total_rmsd", default=torch.tensor(0.0), dist_reduce_fx="sum") + self.add_state("n_valid", default=torch.tensor(0), dist_reduce_fx="sum") + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate alignment RMSD between input and optimized geometries.""" + indexed = [(idx, m) for idx, m in enumerate(mols) if m is not None] + if not indexed: + return + + indices, _ = zip(*indexed, strict=True) + optimised = _shared_optimise(mols) + + pairs = [ + (i, m) for i, m in zip(indices, optimised, strict=True) if m is not None + ] + if not pairs: + return + + valid_indices, opt_mols = zip(*pairs, strict=True) + original_mols = [mols[i] for i in valid_indices] + rmsds = [ + morphRD.conf_distance(m1, m2) + for m1, m2 in zip(original_mols, opt_mols, strict=True) + ] + self.total_rmsd += sum(rmsds) + self.n_valid += len(rmsds) + + def compute(self) -> torch.Tensor: + """Return mean RMSD over successfully evaluated molecule pairs.""" + return self.total_rmsd / self.n_valid diff --git a/geom/explicit_h/runtime/src/morph/utils/metrics/distribution.py b/geom/explicit_h/runtime/src/morph/utils/metrics/distribution.py new file mode 100644 index 0000000000000000000000000000000000000000..e7928186f4eac061c0d58f59a7da4db6102af192 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/metrics/distribution.py @@ -0,0 +1,97 @@ +"""KL(gen || target) distribution metrics, computed directly from the batch. + +All four are marginal over every generated sample (no RDKit, no validity +conditioning) so they run in every representation that carries the field. +They share one attribute contract — ``gen_hist`` / ``target`` / ``labels`` — +so ``plotting.build_marginal_plots`` can render any of them generically. They +live in their own epoch-accumulated collection (see ``init_metrics``). +""" + +import torch + +from morph.dataset.core.atomic_data import ChemBatch +from morph.utils.metrics.tensor import BatchMetric + + +def _kl(p: torch.Tensor, q: torch.Tensor, eps: float = 1e-8) -> torch.Tensor: + p = p / p.sum().clamp(min=eps) + q = q / q.sum().clamp(min=eps) + return (p * (p.clamp(min=eps).log() - q.clamp(min=eps).log())).sum() + + +class _HistogramKL(BatchMetric): + """Shared machinery: a fixed-length generated histogram vs a target one. + + Subclasses implement ``_indices(batch) -> LongTensor`` returning the token + indices to bin for one update; everything else (binning, KL, plotting + attributes) is common. + """ + + def __init__( + self, + target_distribution: torch.Tensor, + labels: list[str] | None = None, + eps: float = 1e-8, + **kwargs, + ): + super().__init__(**kwargs) + target = target_distribution.detach().to(torch.float32) + self.eps = eps + self.K = int(target.numel()) + self.labels = list(labels) if labels is not None else None + self.register_buffer("target", target / target.sum().clamp(min=eps)) + self.add_state( + "gen_hist", + default=torch.zeros(self.K, dtype=torch.float32), + dist_reduce_fx="sum", + ) + + def _indices(self, batch: ChemBatch) -> torch.Tensor: + raise NotImplementedError + + def update(self, batch: ChemBatch) -> None: + idx = self._indices(batch) + if idx.numel() == 0: + return + idx = idx.clamp(0, self.K - 1) + self.gen_hist += torch.bincount(idx, minlength=self.K).to(torch.float32) + + def compute(self) -> torch.Tensor: + if self.gen_hist.sum() <= 0: + return torch.tensor(0.0, device=self.gen_hist.device) + return _kl(self.gen_hist, self.target, self.eps) + + +class AtomCountKL(_HistogramKL): + """KL over per-molecule atom counts.""" + + def _indices(self, batch: ChemBatch) -> torch.Tensor: + return batch.num_nodes_per_graph + + +class AtomTypeKL(_HistogramKL): + """KL over the atom-type histogram.""" + + def _indices(self, batch: ChemBatch) -> torch.Tensor: + return batch.a + + +class ChargeTypeKL(_HistogramKL): + """KL over the formal-charge-token histogram.""" + + def _indices(self, batch: ChemBatch) -> torch.Tensor: + return batch.c + + +class EdgeTypeKL(_HistogramKL): + """KL over upper-triangular pair edge types (incl. ```` at index 0). + + Mirrors the training-side target built in preprocessing: for every molecule + take the dense adjacency and read its strict upper triangle, so non-bonded + pairs contribute ````. PyG batches lay out each graph's nodes + contiguously, so a graph's local indices are ``global - first_node``. + """ + + def _indices(self, batch: ChemBatch) -> torch.Tensor: + rows, cols = batch.edge_index + return batch.e[rows < cols] diff --git a/geom/explicit_h/runtime/src/morph/utils/metrics/plotting.py b/geom/explicit_h/runtime/src/morph/utils/metrics/plotting.py new file mode 100644 index 0000000000000000000000000000000000000000..42f09ef1f616be57c9267af13e0a0b28cb607fb2 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/metrics/plotting.py @@ -0,0 +1,135 @@ +# Plotting imports stay lazy; select the headless backend before importing pyplot. +# ruff: noqa: PLC0415 + +"""Ground-truth vs generated marginal plots for wandb logging.""" + +import torch +from torchmetrics import MetricCollection + +from morph.utils.metrics.tensor import RG_RANGE + + +def plot_marginal_comparison( + gen_hist: torch.Tensor, + target_hist: torch.Tensor, + labels: list[str] | None, + title: str, + xlabel: str, + eps: float = 1e-8, +): + """Compare ground-truth and generated marginal densities in a Figure. + + Both inputs are unnormalized histograms; they are normalized to densities here. + """ + import matplotlib as mpl + + mpl.use("Agg", force=False) + import matplotlib.pyplot as plt + import numpy as np + + gen = gen_hist.detach().to("cpu", dtype=torch.float32) + target = target_hist.detach().to("cpu", dtype=torch.float32) + + n = max(gen.numel(), target.numel()) + if gen.numel() < n: + gen = torch.cat([gen, torch.zeros(n - gen.numel())]) + if target.numel() < n: + target = torch.cat([target, torch.zeros(n - target.numel())]) + + gen_sum = float(gen.sum()) + target_sum = float(target.sum()) + gen = gen / max(gen_sum, eps) + target = target / max(target_sum, eps) + + if labels is None or len(labels) != n: + labels = [str(i) for i in range(n)] + + x = np.arange(n) + width = 0.4 + + fig, ax = plt.subplots(figsize=(max(6.0, n * 0.35), 4.0)) + ax.bar( + x - width / 2.0, + target.numpy(), + width, + label="ground truth", + color="steelblue", + alpha=0.85, + ) + ax.bar( + x + width / 2.0, + gen.numpy(), + width, + label="generated", + color="darkorange", + alpha=0.85, + ) + ax.set_xticks(x) + ax.set_xticklabels(labels, rotation=45 if n > 10 else 0, ha="right") + ax.set_ylabel("probability") + ax.set_xlabel(xlabel) + ax.set_title(title) + ax.legend() + fig.tight_layout() + return fig + + +# Per-distribution-metric plot captions, keyed by the collection key set in +# ``init_metrics``. Anything not listed is skipped (no caption == not plotted). +_DIST_PLOT_META = { + "atom_count_dist_kl": ("Number of atoms: ground truth vs generated", "n_atoms"), + "atom_type_dist_kl": ("Atom types: ground truth vs generated", "atom type"), + "edge_type_dist_kl": ( + "Edge types (upper-tri pairs incl. NO_BOND): ground truth vs generated", + "edge type", + ), + "charge_type_dist_kl": ( + "Formal charges: ground truth vs generated", + "formal charge", + ), +} + + +def build_marginal_plots(distribution_metrics: MetricCollection) -> dict: + """One Figure per distribution metric with samples, keyed by a short name. + + Generic over the shared ``gen_hist`` / ``target`` / ``labels`` attribute + contract of the tensor distribution metrics. Metrics that have not seen any + samples are skipped. Caller closes the figures after logging. + """ + plots: dict = {} + for key, (title, xlabel) in _DIST_PLOT_META.items(): + if key not in distribution_metrics: + continue + m = distribution_metrics[key] + if float(m.gen_hist.sum()) <= 0.0: + continue + plots[key.replace("_dist_kl", "")] = plot_marginal_comparison( + gen_hist=m.gen_hist, + target_hist=m.target, + labels=m.labels, + title=title, + xlabel=xlabel, + ) + return plots + + +def build_batch_marginal_plots(metrics, atom_tokens: list[str] | None = None): + """GT-vs-generated plot for the geometric batch metrics that carry a + histogram. Returns ``{wandb_key: matplotlib.Figure}``. + + Currently just radius of gyration — the 1D atom-count / atom-type marginals + moved to the distribution collection (see ``build_marginal_plots``). + """ + figs: dict = {} + by_name = dict(metrics.items()) if hasattr(metrics, "items") else {} + m = by_name.get("rog_l1") + if m is not None and getattr(m, "gen_hist", None) is not None: + figs["val/batch/plots/rog"] = plot_marginal_comparison( + m.gen_hist, + m.target_hist, + None, + "Radius of gyration", + f"RoG bin (Å in [{RG_RANGE[0]}, {RG_RANGE[1]}])", + ) + return figs diff --git a/geom/explicit_h/runtime/src/morph/utils/metrics/posecheck_strain.py b/geom/explicit_h/runtime/src/morph/utils/metrics/posecheck_strain.py new file mode 100644 index 0000000000000000000000000000000000000000..984523e52d9cb7f69a7755144ea24abcae115666 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/metrics/posecheck_strain.py @@ -0,0 +1,173 @@ +"""PoseCheck strain energy (Harris et al., 2023). + +This is the metric TABASCO's "Strain Energy" column reports, and it is *not* +what ``AverageStrainEnergy`` in ``chemistry.py`` computes. The two differ in +both terms of the difference: + + this module (PoseCheck) AverageStrainEnergy + force field UFF MMFF94 + minuend pose after a POSITION- raw pose, unrelaxed + CONSTRAINED relaxation + (maxDispl 0.1 A, k = 1e5) + subtrahend min over 50 freshly embedded global minimum of the + + UFF-relaxed conformers, *given* pose only + plus the pose relaxed freely + +Both differences push the same way: PoseCheck's constrained relaxation removes +local geometric noise so the number reflects *conformational* strain rather +than bad bond lengths, while its global reference is <= the nearest local +minimum. So the two metrics are not on a common scale and must not be compared. + +There is a third difference that dwarfs both, in the AGGREGATION: + + TABASCO reports the MEDIAN. ``AverageStrainEnergy`` reports the MEAN. + +TABASCO's ``src/tabasco/utils/metrics.py`` calls PoseCheck's +``calculate_strain_energy`` and aggregates with ``mode`` defaulting to +``"median"``. The per-molecule distribution has a brutal right tail -- on a +6-molecule smoke test the mean was 20.19 against a median of 1.09 -- so the two +statistics differ by more than an order of magnitude and the choice must be +stated with any published number. This module therefore defaults to the median, +matching the table it exists to be compared against. + +``AverageStrainEnergy`` in ``chemistry.py`` is SemlaFlow's metric, reproduced +class-for-class (same name, docstring, ``calc_energy``/``optimise_mol`` +helpers, mean aggregation). It is a valid metric; it is simply not the one +TABASCO tabulates. + +References: + - https://github.com/cch1999/posecheck ``posecheck/utils/strain.py`` + - https://github.com/carlosinator/tabasco ``src/tabasco/utils/metrics.py`` + - https://github.com/rssrwn/semla-flow ``semlaflow/util/metrics.py`` + +Cost is ~0.9 s/molecule serial (51 UFF relaxations), ~0.15 s/molecule across the +shared pool. Evaluation-time only -- it is too expensive for a per-step training +objective without a cheaper surrogate. +""" + +from __future__ import annotations + +import torch +from rdkit import Chem +from rdkit.Chem import AllChem + +from .chemistry import GenerativeMetric, _pool_map + +# PoseCheck defaults, from posecheck/utils/strain.py. +NUM_CONFS = 50 +MAX_DISPLACEMENT = 0.1 +FORCE_CONSTANT = 1.0e5 + + +def _uff_energy(mol: Chem.Mol, conf_id: int = 0) -> float | None: + try: + ff = AllChem.UFFGetMoleculeForceField(mol, confId=conf_id) + return float(ff.CalcEnergy()) + except Exception: + return None + + +def _local_energy(mol: Chem.Mol) -> float | None: + """UFF energy after relaxing the pose under per-atom position restraints. + + The restraints are what make this "strain of *this* conformer" rather than + "how bad is this geometry": every atom may move at most ``MAX_DISPLACEMENT`` + from where the model put it, so clashes and stretched bonds relax out while + the conformational hypothesis is preserved. + """ + probe = Chem.Mol(mol) + try: + ff = AllChem.UFFGetMoleculeForceField(probe, confId=0) + for i in range(probe.GetNumAtoms()): + ff.UFFAddPositionConstraint(i, MAX_DISPLACEMENT, FORCE_CONSTANT) + ff.Minimize() + return float(ff.CalcEnergy()) + except Exception: + return None + + +def _global_energy( + mol: Chem.Mol, num_confs: int = NUM_CONFS, seed: int = 0xF00D +) -> float | None: + """Lowest UFF energy over freshly embedded conformers plus the relaxed pose. + + Including the freely-relaxed pose (PoseCheck's ``relax_global_on_pose``) + matters: ETKDG can miss the basin the model actually generated, and without + it a molecule whose pose is *better* than anything embedded would score a + spurious negative strain. + """ + energies: list[float] = [] + + freed = Chem.Mol(mol) + try: + AllChem.UFFOptimizeMolecule(freed, maxIters=1000) + e = _uff_energy(freed) + if e is not None: + energies.append(e) + except Exception: + pass + + embedded = Chem.Mol(mol) + try: + params = AllChem.ETKDGv3() + params.randomSeed = seed + embedded.RemoveAllConformers() + AllChem.EmbedMultipleConfs(embedded, numConfs=num_confs, params=params) + AllChem.UFFOptimizeMoleculeConfs(embedded, maxIters=1000) + energies += [ + e + for c in embedded.GetConformers() + if (e := _uff_energy(embedded, c.GetId())) is not None + ] + except Exception: + pass + + return min(energies) if energies else None + + +def posecheck_strain_energy(mol: Chem.Mol | None) -> float | None: + """``local - global`` in kcal/mol; ``None`` when either term fails.""" + if mol is None or mol.GetNumConformers() == 0: + return None + local = _local_energy(mol) + if local is None: + return None + glob = _global_energy(mol) + if glob is None: + return None + return local - glob + + +class PoseCheckStrainEnergy(GenerativeMetric): + """Mean PoseCheck strain over molecules where it is calculable. + + Molecules that fail (unembeddable, no UFF parameters) are dropped rather + than counted as zero, matching how ``AverageStrainEnergy`` handles failures. + + Defaults to the MEDIAN because that is what TABASCO's table reports. Pass + ``median=False`` for the mean, but never compare the two: the tail makes + them differ by ~20x. ``self.values`` holds the raw per-molecule numbers if + another statistic is wanted without paying for a second pass. + """ + + def __init__(self, median: bool = True, **kwargs): + super().__init__(**kwargs) + self.median = median + self.values: list[float] = [] + + def reset(self): + """Clear all accumulated strain energies.""" + super().reset() + self.values = [] + + def update(self, mols: list[Chem.rdchem.Mol]) -> None: + """Accumulate strain energies for the provided molecules.""" + vals = _pool_map(posecheck_strain_energy, [m for m in mols if m is not None]) + self.values += [v for v in vals if v is not None] + + def compute(self) -> torch.Tensor: + """Return the configured aggregate over accumulated energies.""" + if not self.values: + return torch.tensor(float("nan")) + t = torch.tensor(self.values, dtype=torch.float64) + return (t.median() if self.median else t.mean()).float() diff --git a/geom/explicit_h/runtime/src/morph/utils/metrics/tensor.py b/geom/explicit_h/runtime/src/morph/utils/metrics/tensor.py new file mode 100644 index 0000000000000000000000000000000000000000..72a99071dc8d06d18ac464017ff6c827bc972dde --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/metrics/tensor.py @@ -0,0 +1,336 @@ +"""Geometric tensor metrics consumed directly from ``ChemBatch``. + +These run in every representation (no RDKit, no validity conditioning). Target +stats (per-element-pair distance histogram, RoG histogram) are computed once at +preprocessing time on training-set coords in Angstroms and stored in +``Distributions``. Generated coords are expected to arrive in Angstroms too — +``sample()`` in the LightningModule already multiplies by ``coordinate_std`` +before returning the final ``ChemBatch``. + +Also hosts the shared bin edges + histogram accumulators because both this +module and ``preprocessing.py`` must agree on them exactly. +""" + +from dataclasses import dataclass + +import torch +from torchmetrics import Metric + +from morph.dataset.core.atomic_data import ChemBatch + +# Shared bin edges (Angstroms). Used both at preprocessing time to compute +# target histograms and at metric time to bin generated samples — they must +# match exactly. Defaults are generous enough for QM9 / GEOM / TMQM; values +# above the upper bound are counted in an overflow accumulator (see the binning +# helpers below) so silent clamping is visible at validation time. +DIST_RANGE = (0.0, 15.0) +DIST_N_BINS = 150 +RG_RANGE = (0.0, 8.0) +RG_N_BINS = 80 + + +@dataclass(frozen=True) +class BatchGeometry: + """Geometry shared by all tensor metrics for one generated batch.""" + + num_graphs: int + node_counts: torch.Tensor + pair_distances: torch.Tensor + pair_atom_lo: torch.Tensor + pair_atom_hi: torch.Tensor + pair_graph: torch.Tensor + radii_of_gyration: torch.Tensor + + +def compute_batch_geometry(batch: ChemBatch) -> BatchGeometry: + """Compute pair distances and radii of gyration once for all metrics.""" + rows, cols = batch.edge_index + upper = rows < cols + rows, cols = rows[upper], cols[upper] + pair_distances = (batch.x[rows] - batch.x[cols]).norm(dim=-1) + atom_i, atom_j = batch.a[rows], batch.a[cols] + + counts = batch.num_nodes_per_graph + centers = batch.x.new_zeros((batch.num_graphs, batch.x.shape[-1])) + centers.index_add_(0, batch.batch, batch.x) + centers = centers / counts.clamp(min=1).unsqueeze(1) + squared_radius = (batch.x - centers[batch.batch]).square().sum(dim=-1) + mean_squared_radius = batch.x.new_zeros(batch.num_graphs) + mean_squared_radius.index_add_(0, batch.batch, squared_radius) + mean_squared_radius = mean_squared_radius / counts.clamp(min=1) + + return BatchGeometry( + num_graphs=batch.num_graphs, + node_counts=counts, + pair_distances=pair_distances, + pair_atom_lo=torch.minimum(atom_i, atom_j), + pair_atom_hi=torch.maximum(atom_i, atom_j), + pair_graph=batch.batch[rows], + radii_of_gyration=mean_squared_radius.sqrt(), + ) + + +def dist_edges() -> torch.Tensor: + return torch.linspace(DIST_RANGE[0], DIST_RANGE[1], DIST_N_BINS + 1) + + +def rg_edges() -> torch.Tensor: + return torch.linspace(RG_RANGE[0], RG_RANGE[1], RG_N_BINS + 1) + + +def accumulate_pairwise_distance_hist( + hist: torch.Tensor, + coord: torch.Tensor, + atom_idx: torch.Tensor, + edges: torch.Tensor, +) -> tuple[int, int]: + """Add upper-triangular pair distances to ``hist`` (A, A, B), in place. + + The pair key is ``(min(a_i, a_j), max(a_i, a_j))`` so the histogram is + naturally invariant to atom ordering. + + Returns ``(n_overflow, n_total)`` for the pairs in this molecule, so callers + can track the fraction of distances that fell beyond the histogram range + (silent clamping otherwise hides "the cloud is way too large"). + """ + n = coord.shape[0] + if n < 2: + return 0, 0 + i_idx, j_idx = torch.triu_indices(n, n, offset=1, device=coord.device) + dists = (coord[i_idx] - coord[j_idx]).norm(dim=-1) + lo = torch.minimum(atom_idx[i_idx], atom_idx[j_idx]) + hi = torch.maximum(atom_idx[i_idx], atom_idx[j_idx]) + edges_d = edges.to(coord.device) + n_overflow = int((dists >= edges_d[-1]).sum().item()) + bin_idx = torch.bucketize(dists, edges_d).sub_(1).clamp_(0, hist.shape[-1] - 1) + a = hist.shape[0] + flat = (lo.long() * a + hi.long()) * hist.shape[-1] + bin_idx + hist.view(-1).index_add_( + 0, flat, torch.ones_like(flat, dtype=hist.dtype, device=coord.device) + ) + return n_overflow, int(dists.numel()) + + +def accumulate_rog_hist( + hist: torch.Tensor, coord: torch.Tensor, edges: torch.Tensor +) -> tuple[int, int]: + """Add one molecule's radius of gyration into ``hist`` (B,), in place. + + Returns ``(n_overflow, n_total)`` (0 or 1 each) for the same reason as + ``accumulate_pairwise_distance_hist``. + """ + if coord.shape[0] == 0: + return 0, 0 + centered = coord - coord.mean(dim=0) + rog = (centered.pow(2).sum(dim=-1).mean()).sqrt() + edges_d = edges.to(coord.device) + overflow = int((rog >= edges_d[-1]).item()) + bin_idx = ( + torch.bucketize(rog.unsqueeze(0), edges_d).sub_(1).clamp_(0, hist.shape[0] - 1) + ) + hist.index_add_(0, bin_idx, torch.ones(1, dtype=hist.dtype, device=coord.device)) + return overflow, 1 + + +class BatchMetric(Metric): + """Base class for metrics that consume a ``ChemBatch`` directly.""" + + def update(self, batch: ChemBatch) -> None: + """Accumulate tensor statistics from molecular batches.""" + raise NotImplementedError + + def compute(self) -> torch.Tensor: + """Reduce the accumulated tensor statistics to a metric value.""" + raise NotImplementedError + + +class PairwiseDistanceL1(BatchMetric): + """Mean per-element-pair L1 distance between gen and target distance histograms. + + For every ordered (lo, hi) atom-type pair the *target* has meaningful mass + for, we normalize gen and target slices independently and report + ``|gen - target|.sum()``. The mean over those pairs is a 0-2 metric where 0 + means perfect distributional match. + + Note: gen-empty pairs are *not* excluded. If the target has C-N distances + but the model never generates a C-N pair, the L1 against zeros is 1.0 and + that pair counts in the average — which is what we want. + """ + + def __init__( + self, target_hist: torch.Tensor, min_pair_count: float = 10.0, **kwargs + ): + super().__init__(**kwargs) + self.register_buffer("target_hist", target_hist.detach().to(torch.float32)) + self.register_buffer("edges", dist_edges()) + self.min_pair_count = min_pair_count + self.add_state( + "gen_hist", + default=torch.zeros_like(self.target_hist), + dist_reduce_fx="sum", + ) + + def update(self, geometry: BatchGeometry) -> None: + """Accumulate pair-distance histograms by unordered atom-type pair.""" + distances = geometry.pair_distances + if distances.numel() == 0: + return + edges = self.edges.to(distances.device) + bins = ( + torch.bucketize(distances, edges) + .sub_(1) + .clamp_(0, self.gen_hist.shape[-1] - 1) + ) + num_atom_types = self.gen_hist.shape[0] + flat = ( + geometry.pair_atom_lo.long() * num_atom_types + geometry.pair_atom_hi.long() + ) * self.gen_hist.shape[-1] + bins + self.gen_hist.view(-1).index_add_( + 0, flat, torch.ones_like(flat, dtype=self.gen_hist.dtype) + ) + + def compute(self) -> torch.Tensor: + """Average normalized histogram L1 errors over populated target pairs.""" + gen = self.gen_hist # (A, A, B) + tgt = self.target_hist + pair_t = tgt.sum(dim=-1) + # Target-populated only: a pair the training set knows about counts in + # the metric even if the model generates zero of them (gen_n = 0 → + # L1 == tgt_n.sum() == 1, which is maximal pain). + populated = pair_t >= self.min_pair_count + if not populated.any(): + return torch.tensor(0.0, device=gen.device) + gen_n = gen / gen.sum(dim=-1).unsqueeze(-1).clamp(min=1.0) + tgt_n = tgt / pair_t.unsqueeze(-1).clamp(min=1.0) + l1 = (gen_n - tgt_n).abs().sum(dim=-1) # (A, A) + return l1[populated].mean() + + +class MinDistanceViolation(BatchMetric): + """Fraction of generated molecules whose smallest pairwise distance is below + a physical threshold (default 0.5 Å). Catches collapse and overlap. + """ + + def __init__(self, threshold: float = 0.5, **kwargs): + super().__init__(**kwargs) + self.threshold = float(threshold) + self.add_state("violations", default=torch.tensor(0.0), dist_reduce_fx="sum") + self.add_state("total", default=torch.tensor(0.0), dist_reduce_fx="sum") + + def update(self, geometry: BatchGeometry) -> None: + """Count graphs with an atom pair below the distance threshold.""" + valid = geometry.node_counts >= 2 + minima = geometry.pair_distances.new_full((geometry.num_graphs,), float("inf")) + minima.scatter_reduce_( + 0, geometry.pair_graph, geometry.pair_distances, reduce="amin" + ) + self.violations += ((minima < self.threshold) & valid).sum() + self.total += valid.sum() + + def compute(self) -> torch.Tensor: + """Return the violation fraction among graphs with at least two atoms.""" + if self.total <= 0: + return torch.tensor(0.0, device=self.violations.device) + return self.violations / self.total + + +class RangeOverflow(BatchMetric): + """Fraction of generated values that landed outside the histogram range. + + If this is non-trivial (say >1%), the fixed range in this module is too + small for the dataset and we're silently clamping into the last bin — + revisit ``DIST_RANGE`` / ``RG_RANGE`` or switch to target-derived edges. + """ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.register_buffer("dist_edges_buf", dist_edges()) + self.register_buffer("rog_edges_buf", rg_edges()) + self.add_state("dist_over", default=torch.tensor(0.0), dist_reduce_fx="sum") + self.add_state("dist_total", default=torch.tensor(0.0), dist_reduce_fx="sum") + self.add_state("rog_over", default=torch.tensor(0.0), dist_reduce_fx="sum") + self.add_state("rog_total", default=torch.tensor(0.0), dist_reduce_fx="sum") + + def update(self, geometry: BatchGeometry) -> None: + """Count distances and radii outside their histogram ranges.""" + self.dist_over += (geometry.pair_distances >= self.dist_edges_buf[-1]).sum() + self.dist_total += geometry.pair_distances.numel() + valid = geometry.node_counts > 0 + self.rog_over += ( + (geometry.radii_of_gyration >= self.rog_edges_buf[-1]) & valid + ).sum() + self.rog_total += valid.sum() + + def compute(self) -> torch.Tensor: + """Return the larger of the distance and radius overflow fractions.""" + d = self.dist_over / self.dist_total.clamp(min=1.0) + r = self.rog_over / self.rog_total.clamp(min=1.0) + return torch.maximum(d, r) + + +class RoGL1(BatchMetric): + """L1 distance between gen and target normalized radius-of-gyration histograms.""" + + def __init__(self, target_hist: torch.Tensor, **kwargs): + super().__init__(**kwargs) + self.register_buffer("target_hist", target_hist.detach().to(torch.float32)) + self.register_buffer("edges", rg_edges()) + self.add_state( + "gen_hist", + default=torch.zeros_like(self.target_hist), + dist_reduce_fx="sum", + ) + + def update(self, geometry: BatchGeometry) -> None: + """Accumulate radii of gyration for nonempty graphs.""" + valid = geometry.node_counts > 0 + radii = geometry.radii_of_gyration[valid] + if radii.numel() == 0: + return + bins = ( + torch.bucketize(radii, self.edges.to(radii.device)) + .sub_(1) + .clamp_(0, self.gen_hist.shape[0] - 1) + ) + self.gen_hist.index_add_( + 0, bins, torch.ones_like(bins, dtype=self.gen_hist.dtype) + ) + + def compute(self) -> torch.Tensor: + """Return the L1 distance between normalized radius histograms.""" + gen = self.gen_hist + tgt = self.target_hist + if gen.sum() <= 0 or tgt.sum() <= 0: + return torch.tensor(0.0, device=gen.device) + gen_n = gen / gen.sum().clamp(min=1.0) + tgt_n = tgt / tgt.sum().clamp(min=1.0) + return (gen_n - tgt_n).abs().sum() + + +def build_batch_metrics(distributions) -> dict[str, BatchMetric]: + """Construct the geometric batch-side metric set from training target stats. + + Atom-count / atom-type / edge / charge KLs live in the distribution + collection (see ``init_metrics``), not here — this set is purely geometric. + + Returns an empty dict if Distributions lacks either geometric target stat + (e.g. an older cache built before these were added). Both are checked + independently in case one is added without the other later. + """ + if ( + distributions.pairwise_distance_histogram is None + or distributions.radius_of_gyration_histogram is None + ): + return {} + return { + "min_dist_violation": MinDistanceViolation( + threshold=0.5, sync_on_compute=False + ), + "range_overflow": RangeOverflow(sync_on_compute=False), + "pair_dist_l1": PairwiseDistanceL1( + distributions.pairwise_distance_histogram, sync_on_compute=False + ), + "rog_l1": RoGL1( + distributions.radius_of_gyration_histogram, sync_on_compute=False + ), + } diff --git a/geom/explicit_h/runtime/src/morph/utils/rdkit_utils.py b/geom/explicit_h/runtime/src/morph/utils/rdkit_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..45697557be0fdbda1106095386e45854288693be --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/rdkit_utils.py @@ -0,0 +1,288 @@ +"""RDKit molecule construction, conversion, and checks; adapted from SemlaFlow.""" + +import threading + +import numpy as np +from rdkit import Chem, RDLogger +from rdkit.Chem import AllChem +from rdkit.Chem.Descriptors import NumRadicalElectrons +from scipy.spatial.transform import Rotation + +# Silence RDKit's noisy validity/kekulize warnings. These fire once per +# generated molecule during validation and, on a shared Lustre stdout, +# can block every Python thread for minutes -- including the TorchElastic +# rendezvous keep-alive thread, which then times out and kills the job. +RDLogger.DisableLog("rdApp.*") + +ArrT = np.ndarray + + +class PeriodicTable: + """Thread-safe singleton wrapper for RDKit periodic-table queries.""" + + _instance = None + + def __new__(cls): + """Return the shared periodic-table wrapper instance.""" + if cls._instance is None: + cls._instance = super().__new__(cls) + + return cls._instance + + def __init__(self): + self._table = Chem.GetPeriodicTable() + + # Just to be certain that vocab objects are thread safe + self._pt_lock = threading.Lock() + + def atomic_from_symbol(self, symbol: str) -> int: + """Look up an atomic number under the periodic-table lock.""" + with self._pt_lock: + symbol = symbol.upper() if len(symbol) == 1 else symbol + atomic = self._table.GetAtomicNumber(symbol) + + return atomic + + def symbol_from_atomic(self, atomic_num: int) -> str: + """Look up an element symbol under the periodic-table lock.""" + with self._pt_lock: + token = self._table.GetElementSymbol(atomic_num) + + return token + + def valence(self, atom: str | int) -> int: + """Look up the default valence for an element symbol or atomic number.""" + with self._pt_lock: + valence = self._table.GetDefaultValence(atom) + + return valence + + +PT = PeriodicTable() + +IDX_BOND_MAP = { + 0: "NO_BOND", + 1: Chem.BondType.SINGLE, + 2: Chem.BondType.DOUBLE, + 3: Chem.BondType.TRIPLE, + 4: Chem.BondType.AROMATIC, +} +BOND_IDX_MAP = {bond: idx for idx, bond in IDX_BOND_MAP.items()} + +# IDX_CHARGE_MAP = {0: 0, 1: 1, 2: 2, 3: 3, 4: -1, 5: -2, 6: -3} +# CHARGE_IDX_MAP = {charge: idx for idx, charge in IDX_CHARGE_MAP.items()} + + +# TODO merge these with check functions in other files + + +def tensors_to_rdkit_mol( + atom_types: list[str], + coords: np.ndarray, + charge_types: list[int], + edge_types: list[Chem.BondType], + edge_index: list[tuple[int, int]], + sanitize: bool = True, + implicit_hydrogens: bool = False, +): + mol = Chem.EditableMol(Chem.Mol()) + + # Add atoms with formal charges + for idx, atomic in enumerate(atom_types): + atom = Chem.Atom(atomic) + charge = charge_types[idx] + atom.SetFormalCharge(charge) + atom.SetNoImplicit(not implicit_hydrogens) + mol.AddAtom(atom) + + mol = mol.GetMol() + + # Add 3D coords + conf = Chem.Conformer(len(atom_types)) + + conf.SetPositions(np.ascontiguousarray(coords, dtype=np.float64)) + + mol.AddConformer(conf, assignId=True) + + mol = Chem.EditableMol(mol) + for edge, edge_type in zip(edge_index, edge_types, strict=True): + start, end = edge + mol.AddBond(start, end, edge_type) + + try: + mol = mol.GetMol() + # DetermineBonds(mol) + for atom in mol.GetAtoms(): + atom.UpdatePropertyCache(strict=False) + except Exception: + return None + + if sanitize: + return sanitize_mol_correctly(mol, implicit_hydrogens=implicit_hydrogens) + + return mol + + +def sanitize_mol_correctly(mol: Chem.Mol, *, implicit_hydrogens: bool | None = False): + """Sanitize in place; None preserves each atom's existing hydrogen policy.""" + for a in mol.GetAtoms(): + if implicit_hydrogens is not None: + a.SetNoImplicit(not implicit_hydrogens) + if a.HasProp("_MolFileHCount"): + a.ClearProp("_MolFileHCount") + + flags = Chem.SanitizeFlags.SANITIZE_ALL + if all(a.GetNoImplicit() for a in mol.GetAtoms()): + flags &= ~Chem.SanitizeFlags.SANITIZE_ADJUSTHS + + # Explicit-H graphs must not acquire extra H; implicit-H graphs need ADJUSTHS. + err = Chem.SanitizeMol( + mol, + sanitizeOps=flags, + catchErrors=True, + ) + if err: # nonzero bitmask means some step failed + return None + return mol + + +def mol_is_valid( + mol: Chem.Mol, allow_radical: bool = False, allow_charged: bool = False +) -> bool: + # Validity is a predicate: do not let sanitization mutate the molecule that + # subsequent metrics, serialization, or dataset processing will consume. + if mol is None or mol.GetNumAtoms() == 0: + return False + sanitized_mol = sanitize_mol_correctly(Chem.Mol(mol), implicit_hydrogens=None) + return not ( + sanitized_mol is None + or len(Chem.GetMolFrags(sanitized_mol)) > 1 + or (not allow_radical and NumRadicalElectrons(sanitized_mol) != 0) + or (not allow_charged and Chem.GetFormalCharge(sanitized_mol) != 0) + ) + + +def calc_energy(mol: Chem.Mol, per_atom: bool = False, level: str = "MMFF94") -> float: + """Calculate the energy for an RDKit molecule using the MMFF forcefield + + Evaluate the first conformer on a copy, leaving the original unchanged. + + Args: + mol (Chem.Mol): RDKit molecule + per_atom (bool): Whether to normalise by the number of atoms. + level: Force-field variant; currently only "MMFF94" is supported. + + Returns: + float: Energy of the molecule or None if the energy could not be calculated + """ + + mol_copy = Chem.Mol(mol) + + try: + if level == "MMFF94": + mmff_props = AllChem.MMFFGetMoleculeProperties( + mol_copy, mmffVariant="MMFF94" + ) + ff = AllChem.MMFFGetMoleculeForceField(mol_copy, mmff_props, confId=0) + energy = ff.CalcEnergy() + energy = energy / mol.GetNumAtoms() if per_atom else energy + except Exception: + energy = None + + return energy + + +def optimise_mol( + mol: Chem.rdchem.Mol, level: str = "MMFF94", max_iters: int = 1000 +) -> Chem.rdchem.Mol: + """Optimise the conformation of an RDKit molecule + + Optimize the first conformer on a copy, leaving the original unchanged. + + Args: + mol (Chem.Mol): RDKit molecule + max_iters (int): Maximum iterations for conformer optimization. + level: Force-field variant; currently only "MMFF94" is supported. + + Returns: + Chem.Mol: Optimized copy, or None if the force-field call raises an error. + """ + + mol_copy = Chem.Mol(mol) + try: + if level == "MMFF94": + AllChem.MMFFOptimizeMolecule(mol_copy, maxIters=max_iters) + except Exception: + return None + + return mol_copy + + +def conf_distance( + mol1: Chem.rdchem.Mol, mol2: Chem.rdchem.Mol, fix_order: bool = True +) -> float: + """Approximately align two molecules and then calculate RMSD between them + + Alignment and distance use the default conformer of each molecule. + + Args: + mol1 (Chem.Mol): First molecule to align + mol2 (Chem.Mol): Second molecule to align + fix_order (bool): Whether to fix the atom order of the molecules + + Returns: + float: RMSD between molecules after approximate alignment + """ + + assert len(mol1.GetAtoms()) == len(mol2.GetAtoms()) + + if not fix_order: + raise NotImplementedError + + coords1 = np.array(mol1.GetConformer().GetPositions()) + coords2 = np.array(mol2.GetConformer().GetPositions()) + + # Firstly, centre both molecules + coords1 = coords1 - (coords1.sum(axis=0) / coords1.shape[0]) + coords2 = coords2 - (coords2.sum(axis=0) / coords2.shape[0]) + + if coords1.shape[0] == 1: + # catch the case where the molecule is a single atom + return 0.0 + + else: + # Find the best rotation alignment between the centred mols + rotation, _ = Rotation.align_vectors(coords1, coords2) + aligned_coords2 = rotation.apply(coords2) + + sqrd_dists = (coords1 - aligned_coords2) ** 2 + rmsd = np.sqrt(sqrd_dists.sum(axis=1).mean()) + return rmsd + + +def smiles_from_mol( + mol: Chem.rdchem.Mol, canonical: bool = True, explicit_hs: bool = False +) -> str | None: + """Create a SMILES string from a molecule + + Args: + mol (Chem.Mol): RDKit molecule object + canonical (bool): Whether to create a canonical SMILES, default True + explicit_hs (bool): Add explicit hydrogen atoms before creating the SMILES. + If False, remove hydrogen atoms. Explicit and implicit hydrogen + representations can produce different strings. + + Returns: + str: SMILES string which could be None if the SMILES generation failed + """ + + if mol is None: + return None + + try: + mol = Chem.AddHs(mol) if explicit_hs else Chem.RemoveHs(mol) + smiles = Chem.MolToSmiles(mol, canonical=canonical) + except Exception: + smiles = None + + return smiles diff --git a/geom/explicit_h/runtime/src/morph/utils/run_config.py b/geom/explicit_h/runtime/src/morph/utils/run_config.py new file mode 100644 index 0000000000000000000000000000000000000000..4c45abf0f0e16a1a9a4dcbaf8f17d17f61a65665 --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/run_config.py @@ -0,0 +1,192 @@ +"""Runtime settings, Hydra/Lightning configuration, callbacks, and run IDs.""" + +import faulthandler +import os +import sys + +import hydra +import pytorch_lightning as pl +import torch +import torch.distributed as dist +import wandb +from omegaconf import DictConfig +from pytorch_lightning import Callback +from pytorch_lightning.callbacks import ( + EarlyStopping, + ModelCheckpoint, +) +from pytorch_lightning.loggers import WandbLogger +from rdkit import RDLogger + + +def configure_runtime(*, seed: int | None = None, diagnostics: bool = False) -> None: + """Set arithmetic, chemistry logging, and optional reproducibility diagnostics.""" + torch.set_float32_matmul_precision("medium") + RDLogger.DisableLog("rdApp.*") + if seed is not None: + pl.seed_everything(seed) + if diagnostics and os.environ.get("MORPH_STALL_DUMP"): + faulthandler.dump_traceback_later( + int(os.environ["MORPH_STALL_DUMP"]), repeat=True, file=sys.stderr + ) + faulthandler.enable(file=sys.stderr, all_threads=True) + if diagnostics and os.environ.get("MORPH_FORCE_MATH_SDP"): + torch.backends.cuda.enable_flash_sdp(False) + torch.backends.cuda.enable_mem_efficient_sdp(False) + torch.backends.cuda.enable_math_sdp(True) + + +class ManualAccumulationWandbLogger(WandbLogger): + """Report manual-optimization updates on W&B's standard step axis.""" + + def __init__(self, *args, accumulation_steps: int = 1, **kwargs): + if accumulation_steps <= 0: + raise ValueError("accumulation_steps must be positive") + self._accumulation_steps = accumulation_steps + super().__init__(*args, **kwargs) + + def log_metrics(self, metrics, step=None): + """Log metrics against completed updates rather than microbatches.""" + # Lightning's logger counter advances per batch when its Trainer-side + # accumulation is one. Manual optimization accumulates inside the + # module, so convert that counter back to completed model updates. + if step is not None: + step //= self._accumulation_steps + return super().log_metrics(metrics, step=step) + + +def validation_batches_per_rank( + num_samples: int, batch_size: int, world_size: int +) -> int: + """Return the per-rank batch limit for an exact global sample count.""" + if num_samples <= 0 or batch_size <= 0 or world_size <= 0: + raise ValueError( + "validation sample count, batch size and world size must be positive" + ) + samples_per_distributed_batch = batch_size * world_size + if num_samples % samples_per_distributed_batch: + raise ValueError( + f"num_val_samples ({num_samples}) must be divisible by val batch size " + f"({batch_size}) * world size ({world_size})" + ) + return num_samples // samples_per_distributed_batch + + +def configure_manual_gradient_accumulation(module, trainer_kwargs: dict) -> None: + """Move Lightning's accumulation setting onto a manual-optimization module. + + Lightning rejects ``Trainer(accumulate_grad_batches != 1)`` for manual + optimization. The optimizer mixin implements the equivalent accumulation, + while the Trainer must therefore retain its neutral value of one. + """ + steps = int(trainer_kwargs.get("accumulate_grad_batches", 1)) + if steps <= 0: + raise ValueError("accumulate_grad_batches must be positive") + if module.automatic_optimization: + return + module.manual_gradient_accumulation_steps = steps + trainer_kwargs["accumulate_grad_batches"] = 1 + + +def gather_rank_outputs(outputs: list) -> list | None: + """Gather picklable rank outputs on rank zero without replicating them.""" + if not (dist.is_available() and dist.is_initialized()): + return outputs + + is_global_zero = dist.get_rank() == 0 + gathered = [None] * dist.get_world_size() if is_global_zero else None + dist.gather_object(outputs, gathered, dst=0) + if not is_global_zero: + return None + return [output for rank_outputs in gathered for output in rank_outputs] + + +def build_callbacks(cfg: DictConfig) -> list[Callback]: + callbacks: list[Callback] = [] + + if "early_stopping" in cfg.callbacks: + hydra.utils.log.info("Adding callback ") + es_cfg = cfg.callbacks.early_stopping + # stopping_threshold (optional): halt the moment the monitored metric + # crosses this value (>= for mode=max), independent of the plateau path. + # Used to stop a run once validity reaches a target (e.g. 0.8). + callbacks.append( + EarlyStopping( + monitor=cfg.callbacks.monitor_metric, + mode=cfg.callbacks.monitor_metric_mode, + patience=es_cfg.patience, + verbose=es_cfg.verbose, + stopping_threshold=es_cfg.get("stopping_threshold", None), + ) + ) + + if "model_checkpoints" in cfg.callbacks: + hydra.utils.log.info("Adding callback ") + callbacks.append( + ModelCheckpoint( + monitor=cfg.callbacks.monitor_metric, + mode=cfg.callbacks.monitor_metric_mode, + every_n_epochs=cfg.callbacks.model_checkpoints.get("every_n_epochs", 1), + save_top_k=cfg.callbacks.model_checkpoints.save_top_k, + verbose=cfg.callbacks.model_checkpoints.verbose, + save_last=cfg.callbacks.model_checkpoints.save_last, + ) + ) + + if "every_n_epochs_checkpoint" in cfg.callbacks: + cb_cfg = cfg.callbacks.every_n_epochs_checkpoint + hydra.utils.log.info( + "Adding callback for every " + f"{cb_cfg.every_n_epochs} epochs" + ) + # Keep best-by-validation separate from latest-for-resume. Lightning only saves + # train-epoch checkpoints when save_on_train_epoch_end=True if validation is not + # run every epoch. + callbacks.append( + ModelCheckpoint( + dirpath="every_n_epochs", + every_n_epochs=cb_cfg.every_n_epochs, + save_top_k=cb_cfg.save_top_k, + verbose=cb_cfg.verbose, + save_last=cb_cfg.save_last, + save_on_train_epoch_end=cb_cfg.get("save_on_train_epoch_end", None), + ) + ) + + # Task-specific callbacks are built generically: any ``callbacks.`` that + # carries a ``_target_`` is instantiated as-is. This module therefore imports + # none of them, which matters because the per-task eval callbacks ship with + # their own recipe (edit / multimode) and may not be installed at + # all -- a hard import here would make this core module depend on them. + # Defaults live on the callback classes, so the config need only override. + for entry in (cfg.callbacks or {}).values(): + if not isinstance(entry, DictConfig) or "_target_" not in entry: + continue # the explicitly-handled callbacks above + hydra.utils.log.info(f"Adding callback <{entry._target_.rsplit('.', 1)[-1]}>") + callbacks.append(hydra.utils.instantiate(entry, _convert_="all")) + + return callbacks + + +def bootstrap_run_id() -> None: + """Set the shared run IDs before Hydra composes ``hydra.run.dir``. + + A single process generates a W&B-style ID. Multi-process ``torchrun`` must + receive the ID in its environment so every rank shares one output directory. + """ + if os.environ.get("MORPH_RUN_ID"): + os.environ.setdefault("WANDB_RUN_ID", os.environ["MORPH_RUN_ID"]) + return + + world_size = int(os.environ.get("WORLD_SIZE", "1")) + if world_size > 1: + raise RuntimeError( + "Multi-process runs require MORPH_RUN_ID in the environment (use the same " + "value as WANDB_RUN_ID). Example:\n" + " export MORPH_RUN_ID=\n" + ' export WANDB_RUN_ID="$MORPH_RUN_ID"' + ) + + rid = wandb.util.generate_id() + os.environ["MORPH_RUN_ID"] = rid + os.environ["WANDB_RUN_ID"] = rid diff --git a/geom/explicit_h/runtime/src/morph/utils/vocab_utils.py b/geom/explicit_h/runtime/src/morph/utils/vocab_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..c696dfa14301ebd68f955d86366dc43ac4e0ea6e --- /dev/null +++ b/geom/explicit_h/runtime/src/morph/utils/vocab_utils.py @@ -0,0 +1,177 @@ +"""Token vocabulary helpers and loss-weight computation.""" + +from copy import deepcopy + +import torch +from rdkit import Chem + + +def z_to_atom_types(z): + """Convert the atomic numbers to atom symbols with rdkit.""" + + table = Chem.GetPeriodicTable() + return [table.GetElementSymbol(z_i) for z_i in z] + + +def token_to_index(token_list, token: str): + return token_list.index(token) + + +def index_to_token(token_list, index: int): + return token_list[index] + + +def compute_token_weights( + token_list: list[str], + distribution: torch.Tensor, + special_token_names: list[str], + weight_alpha: float = 1.0, + type_loss_token_weights: str = "uniform", +) -> torch.Tensor: + """ + Compute token weights with special handling for special tokens. + + This function can be used for both node tokens (atom types) and edge tokens. + It computes inverse frequency weights, normalizes regular tokens, and assigns + appropriate weights to special tokens. + + Args: + token_list: List of all tokens (e.g., atom types or edge types) + distribution: Distribution of token types from training data. + MUST have the same length as token_list and be aligned by index. + special_token_names: List of special token names to handle specially + (e.g., [""] for edges) + weight_alpha: Alpha parameter for weight scaling (default: 1.0) + type_loss_token_weights: "uniform" or "training" - if "uniform", returns + uniform weights (all 1.0) + + Returns: + Weights tensor with same shape as distribution + """ + # Validate inputs + if len(distribution) != len(token_list): + raise ValueError( + f"Distribution length ({len(distribution)}) must match " + f"token_list length ({len(token_list)})" + ) + + # Get indices for special tokens (with validation) + special_token_indices = set() + for token_name in special_token_names: + if token_name not in token_list: + print(f"Warning: Special token '{token_name}' not in token_list, skipping") + continue + idx = token_to_index(token_list, token_name) + special_token_indices.add(idx) + + # --- Handle uniform weights case early --- + if type_loss_token_weights == "uniform": + return torch.ones_like(distribution) + + # --- 1. Initial Weight Calculation (Inverse Frequency) --- + epsilon = 1e-8 + weights = 1.0 / (distribution + epsilon) + weights = weights**weight_alpha # Apply alpha scaling + + # --- 2. Isolate & Normalize REGULAR Tokens --- + all_indices = set(range(len(token_list))) + regular_token_indices = list(all_indices - special_token_indices) + + if not regular_token_indices: + return torch.ones_like(weights) + + # Get weights for only the regular tokens + regular_weights = weights[regular_token_indices] + + # Normalize *only* the regular weights to have a mean of 1.0 + mean_regular_weight = regular_weights.mean() + if mean_regular_weight > 0: + regular_weights = regular_weights / mean_regular_weight + + # --- 3. Build Final Weights Tensor --- + # Start with the full weights (including special tokens computed from distribution) + final_weights = weights.clone() + + # Assign normalized regular weights to their correct positions + final_weights[regular_token_indices] = regular_weights + + # Handle special tokens: use their actual inverse frequency weight, + # normalized the same way as regular tokens + for token_name in special_token_names: + if token_name not in token_list: + continue + token_idx = token_to_index(token_list, token_name) + special_weight = weights[token_idx] + if mean_regular_weight > 0: + special_weight = special_weight / mean_regular_weight + final_weights[token_idx] = special_weight + + # set the minimum weight to 1.0 + # final_weights = final_weights / final_weights.min() + + return final_weights + + +def init_uniform_prior(distributions): + """Return a new Distributions with uniform atom, edge, and charge type + distributions. + + All other fields (n_atoms_distribution, coordinate_std) are copied from the + original. The original object is not modified. + """ + prior = deepcopy(distributions) + for attr in ( + "atom_type_distribution", + "edge_type_distribution", + "charge_type_distribution", + ): + dist = torch.ones_like(getattr(prior, attr)) + setattr(prior, attr, dist / dist.sum()) + return prior + + +_PRIOR_MODES = ("uniform", "marginal", "absorbing") + + +def build_token_prior( + distributions, + *, + edge_mode: str = "uniform", + atom_mode: str = "uniform", + charge_mode: str = "uniform", +): + """Return a new Distributions with per-token prior modes. + + Generalises :func:`init_uniform_prior` (``all modes == "uniform"`` reproduces + it exactly). The prior is the flow-matching *source*: it seeds + ``sample_prior_graph`` and the interpolator, so changing it changes the whole + transport path the denoiser is trained on. + + Modes per token channel: + * ``uniform`` -- maximum entropy (the historical default; for edges this + is why the prior graph starts massively over-bonded). + * ``marginal`` -- the data marginal already on ``distributions`` (DiGress- + style; the source is close to the data, so far fewer edits). + * ``absorbing`` -- one-hot on token index 0 (```` for edges): an + empty-graph / masked source with zero deletions to make. + """ + prior = deepcopy(distributions) + for attr, mode in ( + ("atom_type_distribution", atom_mode), + ("edge_type_distribution", edge_mode), + ("charge_type_distribution", charge_mode), + ): + cur = getattr(prior, attr) + if mode == "uniform": + dist = torch.ones_like(cur) + elif mode == "marginal": + dist = cur.clamp_min(0).clone() # keep the data marginal already present + elif mode == "absorbing": + dist = torch.zeros_like(cur) + dist[0] = 1.0 + else: + raise ValueError( + f"prior mode for {attr} must be one of {_PRIOR_MODES}, got {mode!r}" + ) + setattr(prior, attr, dist / dist.sum()) + return prior diff --git a/geom/explicit_h/runtime/uv.lock b/geom/explicit_h/runtime/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..b42030b324952f8c668221c2232e0eff32e7d9ab --- /dev/null +++ b/geom/explicit_h/runtime/uv.lock @@ -0,0 +1,3069 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform == 'linux'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'win32'", + "python_full_version == '3.12.*' and sys_platform == 'win32'", + "python_full_version < '3.12' and sys_platform == 'win32'", + "python_full_version == '3.13.*' and sys_platform == 'emscripten'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "python_full_version < '3.12' and sys_platform == 'emscripten'", + "python_full_version == '3.13.*' and sys_platform == 'linux'", + "python_full_version == '3.12.*' and sys_platform == 'linux'", + "python_full_version == '3.13.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version == '3.12.*' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", + "python_full_version < '3.12' and sys_platform == 'linux'", + "python_full_version < '3.12' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/4c/a164164834f03924d9a29dc3acd9e7ee58f95857e0b467f6d04298594ebb/aiohttp-3.13.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5b6073099fb654e0a068ae678b10feff95c5cae95bbfcbfa7af669d361a8aa6b", size = 746051, upload-time = "2026-01-03T17:29:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/82/71/d5c31390d18d4f58115037c432b7e0348c60f6f53b727cad33172144a112/aiohttp-3.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cb93e166e6c28716c8c6aeb5f99dfb6d5ccf482d29fe9bf9a794110e6d0ab64", size = 499234, upload-time = "2026-01-03T17:29:44.822Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c9/741f8ac91e14b1d2e7100690425a5b2b919a87a5075406582991fb7de920/aiohttp-3.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e027cf2f6b641693a09f631759b4d9ce9165099d2b5d92af9bd4e197690eea", size = 494979, upload-time = "2026-01-03T17:29:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/75/b5/31d4d2e802dfd59f74ed47eba48869c1c21552c586d5e81a9d0d5c2ad640/aiohttp-3.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b61b7169ababd7802f9568ed96142616a9118dd2be0d1866e920e77ec8fa92a", size = 1748297, upload-time = "2026-01-03T17:29:48.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/3e/eefad0ad42959f226bb79664826883f2687d602a9ae2941a18e0484a74d3/aiohttp-3.13.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:80dd4c21b0f6237676449c6baaa1039abae86b91636b6c91a7f8e61c87f89540", size = 1707172, upload-time = "2026-01-03T17:29:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/c5/3a/54a64299fac2891c346cdcf2aa6803f994a2e4beeaf2e5a09dcc54acc842/aiohttp-3.13.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65d2ccb7eabee90ce0503c17716fc77226be026dcc3e65cce859a30db715025b", size = 1805405, upload-time = "2026-01-03T17:29:51.244Z" }, + { url = "https://files.pythonhosted.org/packages/6c/70/ddc1b7169cf64075e864f64595a14b147a895a868394a48f6a8031979038/aiohttp-3.13.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b179331a481cb5529fca8b432d8d3c7001cb217513c94cd72d668d1248688a3", size = 1899449, upload-time = "2026-01-03T17:29:53.938Z" }, + { url = "https://files.pythonhosted.org/packages/a1/7e/6815aab7d3a56610891c76ef79095677b8b5be6646aaf00f69b221765021/aiohttp-3.13.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d4c940f02f49483b18b079d1c27ab948721852b281f8b015c058100e9421dd1", size = 1748444, upload-time = "2026-01-03T17:29:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f2/073b145c4100da5511f457dc0f7558e99b2987cf72600d42b559db856fbc/aiohttp-3.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9444f105664c4ce47a2a7171a2418bce5b7bae45fb610f4e2c36045d85911d3", size = 1606038, upload-time = "2026-01-03T17:29:57.179Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/778d011920cae03ae01424ec202c513dc69243cf2db303965615b81deeea/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:694976222c711d1d00ba131904beb60534f93966562f64440d0c9d41b8cdb440", size = 1724156, upload-time = "2026-01-03T17:29:58.914Z" }, + { url = "https://files.pythonhosted.org/packages/0e/cb/3419eabf4ec1e9ec6f242c32b689248365a1cf621891f6f0386632525494/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f33ed1a2bf1997a36661874b017f5c4b760f41266341af36febaf271d179f6d7", size = 1722340, upload-time = "2026-01-03T17:30:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/7a/e5/76cf77bdbc435bf233c1f114edad39ed4177ccbfab7c329482b179cff4f4/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e636b3c5f61da31a92bf0d91da83e58fdfa96f178ba682f11d24f31944cdd28c", size = 1783041, upload-time = "2026-01-03T17:30:03.609Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d4/dd1ca234c794fd29c057ce8c0566b8ef7fd6a51069de5f06fa84b9a1971c/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5d2d94f1f5fcbe40838ac51a6ab5704a6f9ea42e72ceda48de5e6b898521da51", size = 1596024, upload-time = "2026-01-03T17:30:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/4345b5f26661a6180afa686c473620c30a66afdf120ed3dd545bbc809e85/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2be0e9ccf23e8a94f6f0650ce06042cefc6ac703d0d7ab6c7a917289f2539ad4", size = 1804590, upload-time = "2026-01-03T17:30:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/7b/06/05950619af6c2df7e0a431d889ba2813c9f0129cec76f663e547a5ad56f2/aiohttp-3.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9af5e68ee47d6534d36791bbe9b646d2a7c7deb6fc24d7943628edfbb3581f29", size = 1740355, upload-time = "2026-01-03T17:30:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/3e/80/958f16de79ba0422d7c1e284b2abd0c84bc03394fbe631d0a39ffa10e1eb/aiohttp-3.13.3-cp311-cp311-win32.whl", hash = "sha256:a2212ad43c0833a873d0fb3c63fa1bacedd4cf6af2fee62bf4b739ceec3ab239", size = 433701, upload-time = "2026-01-03T17:30:10.869Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/27cdf04c9851712d6c1b99df6821a6623c3c9e55956d4b1e318c337b5a48/aiohttp-3.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:642f752c3eb117b105acbd87e2c143de710987e09860d674e068c4c2c441034f", size = 457678, upload-time = "2026-01-03T17:30:12.719Z" }, + { url = "https://files.pythonhosted.org/packages/a0/be/4fc11f202955a69e0db803a12a062b8379c970c7c84f4882b6da17337cc1/aiohttp-3.13.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b903a4dfee7d347e2d87697d0713be59e0b87925be030c9178c5faa58ea58d5c", size = 739732, upload-time = "2026-01-03T17:30:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/97/2c/621d5b851f94fa0bb7430d6089b3aa970a9d9b75196bc93bb624b0db237a/aiohttp-3.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a45530014d7a1e09f4a55f4f43097ba0fd155089372e105e4bff4ca76cb1b168", size = 494293, upload-time = "2026-01-03T17:30:15.96Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/4be01406b78e1be8320bb8316dc9c42dbab553d281c40364e0f862d5661c/aiohttp-3.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27234ef6d85c914f9efeb77ff616dbf4ad2380be0cda40b4db086ffc7ddd1b7d", size = 493533, upload-time = "2026-01-03T17:30:17.431Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a8/5a35dc56a06a2c90d4742cbf35294396907027f80eea696637945a106f25/aiohttp-3.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d32764c6c9aafb7fb55366a224756387cd50bfa720f32b88e0e6fa45b27dcf29", size = 1737839, upload-time = "2026-01-03T17:30:19.422Z" }, + { url = "https://files.pythonhosted.org/packages/bf/62/4b9eeb331da56530bf2e198a297e5303e1c1ebdceeb00fe9b568a65c5a0c/aiohttp-3.13.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b1a6102b4d3ebc07dad44fbf07b45bb600300f15b552ddf1851b5390202ea2e3", size = 1703932, upload-time = "2026-01-03T17:30:21.756Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f6/af16887b5d419e6a367095994c0b1332d154f647e7dc2bd50e61876e8e3d/aiohttp-3.13.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c014c7ea7fb775dd015b2d3137378b7be0249a448a1612268b5a90c2d81de04d", size = 1771906, upload-time = "2026-01-03T17:30:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/ce/83/397c634b1bcc24292fa1e0c7822800f9f6569e32934bdeef09dae7992dfb/aiohttp-3.13.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2b8d8ddba8f95ba17582226f80e2de99c7a7948e66490ef8d947e272a93e9463", size = 1871020, upload-time = "2026-01-03T17:30:26Z" }, + { url = "https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc", size = 1755181, upload-time = "2026-01-03T17:30:27.554Z" }, + { url = "https://files.pythonhosted.org/packages/0a/87/20a35ad487efdd3fba93d5843efdfaa62d2f1479eaafa7453398a44faf13/aiohttp-3.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01ad2529d4b5035578f5081606a465f3b814c542882804e2e8cda61adf5c71bf", size = 1561794, upload-time = "2026-01-03T17:30:29.254Z" }, + { url = "https://files.pythonhosted.org/packages/de/95/8fd69a66682012f6716e1bc09ef8a1a2a91922c5725cb904689f112309c4/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bb4f7475e359992b580559e008c598091c45b5088f28614e855e42d39c2f1033", size = 1697900, upload-time = "2026-01-03T17:30:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/66/7b94b3b5ba70e955ff597672dad1691333080e37f50280178967aff68657/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c19b90316ad3b24c69cd78d5c9b4f3aa4497643685901185b65166293d36a00f", size = 1728239, upload-time = "2026-01-03T17:30:32.703Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/6f72f77f9f7d74719692ab65a2a0252584bf8d5f301e2ecb4c0da734530a/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:96d604498a7c782cb15a51c406acaea70d8c027ee6b90c569baa6e7b93073679", size = 1740527, upload-time = "2026-01-03T17:30:34.695Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b4/75ec16cbbd5c01bdaf4a05b19e103e78d7ce1ef7c80867eb0ace42ff4488/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:084911a532763e9d3dd95adf78a78f4096cd5f58cdc18e6fdbc1b58417a45423", size = 1554489, upload-time = "2026-01-03T17:30:36.864Z" }, + { url = "https://files.pythonhosted.org/packages/52/8f/bc518c0eea29f8406dcf7ed1f96c9b48e3bc3995a96159b3fc11f9e08321/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7a4a94eb787e606d0a09404b9c38c113d3b099d508021faa615d70a0131907ce", size = 1767852, upload-time = "2026-01-03T17:30:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/a07a75173124f31f11ea6f863dc44e6f09afe2bca45dd4e64979490deab1/aiohttp-3.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:87797e645d9d8e222e04160ee32aa06bc5c163e8499f24db719e7852ec23093a", size = 1722379, upload-time = "2026-01-03T17:30:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/3c/4a/1a3fee7c21350cac78e5c5cef711bac1b94feca07399f3d406972e2d8fcd/aiohttp-3.13.3-cp312-cp312-win32.whl", hash = "sha256:b04be762396457bef43f3597c991e192ee7da460a4953d7e647ee4b1c28e7046", size = 428253, upload-time = "2026-01-03T17:30:42.644Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b7/76175c7cb4eb73d91ad63c34e29fc4f77c9386bba4a65b53ba8e05ee3c39/aiohttp-3.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:e3531d63d3bdfa7e3ac5e9b27b2dd7ec9df3206a98e0b3445fa906f233264c57", size = 455407, upload-time = "2026-01-03T17:30:44.195Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "ase" +version = "3.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "matplotlib" }, + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/76/7696a44e56b04a8e31e8e6c85cbf5b13250b9b4c75242910d92c5ce3ed0e/ase-3.28.0.tar.gz", hash = "sha256:74fe77ca38bf78070e24cd283f5d25be129bad6503fd26619008548518144732", size = 2396385, upload-time = "2026-03-17T20:47:55.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/25/4f103d1bedb3593718713b3f743df7b3ff3fc68d36d6666c30265ef59c8a/ase-3.28.0-py3-none-any.whl", hash = "sha256:0e24056302d7307b7247f90de281de15e3031c14cf400bedb1116c3b0d0e50b8", size = 2929909, upload-time = "2026-03-17T20:47:51.773Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/a5/8e3f9b6771b0b408517c82d97aed8f2036509bc247d46114925e32fe33f0/asttokens-3.0.1.tar.gz", hash = "sha256:71a4ee5de0bde6a31d64f6b13f2293ac190344478f081c3d1bccfcf5eacb0cb7", size = 62308, upload-time = "2025-11-15T16:43:48.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/39/e7eaf1799466a4aef85b6a4fe7bd175ad2b1c6345066aa33f1f58d4b18d0/asttokens-3.0.1-py3-none-any.whl", hash = "sha256:15a3ebc0f43c2d0a50eeafea25e19046c68398e487b9f1f5b517f7c0f40f976a", size = 27047, upload-time = "2025-11-15T16:43:16.109Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/b7/cd8080344452e4874aae67c40d8940e2b4d47b01601a8fd9f44786c757c7/debugpy-1.8.20.tar.gz", hash = "sha256:55bc8701714969f1ab89a6d5f2f3d40c36f91b2cbe2f65d98bf8196f6a6a2c33", size = 1645207, upload-time = "2026-01-29T23:03:28.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/56/c3baf5cbe4dd77427fd9aef99fcdade259ad128feeb8a786c246adb838e5/debugpy-1.8.20-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:eada6042ad88fa1571b74bd5402ee8b86eded7a8f7b827849761700aff171f1b", size = 2208318, upload-time = "2026-01-29T23:03:36.481Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/4fa79a57a8e69fe0d9763e98d1110320f9ecd7f1f362572e3aafd7417c9d/debugpy-1.8.20-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:7de0b7dfeedc504421032afba845ae2a7bcc32ddfb07dae2c3ca5442f821c344", size = 3171493, upload-time = "2026-01-29T23:03:37.775Z" }, + { url = "https://files.pythonhosted.org/packages/7d/f2/1e8f8affe51e12a26f3a8a8a4277d6e60aa89d0a66512f63b1e799d424a4/debugpy-1.8.20-cp311-cp311-win32.whl", hash = "sha256:773e839380cf459caf73cc533ea45ec2737a5cc184cf1b3b796cd4fd98504fec", size = 5209240, upload-time = "2026-01-29T23:03:39.109Z" }, + { url = "https://files.pythonhosted.org/packages/d5/92/1cb532e88560cbee973396254b21bece8c5d7c2ece958a67afa08c9f10dc/debugpy-1.8.20-cp311-cp311-win_amd64.whl", hash = "sha256:1f7650546e0eded1902d0f6af28f787fa1f1dbdbc97ddabaf1cd963a405930cb", size = 5233481, upload-time = "2026-01-29T23:03:40.659Z" }, + { url = "https://files.pythonhosted.org/packages/14/57/7f34f4736bfb6e00f2e4c96351b07805d83c9a7b33d28580ae01374430f7/debugpy-1.8.20-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:4ae3135e2089905a916909ef31922b2d733d756f66d87345b3e5e52b7a55f13d", size = 2550686, upload-time = "2026-01-29T23:03:42.023Z" }, + { url = "https://files.pythonhosted.org/packages/ab/78/b193a3975ca34458f6f0e24aaf5c3e3da72f5401f6054c0dfd004b41726f/debugpy-1.8.20-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:88f47850a4284b88bd2bfee1f26132147d5d504e4e86c22485dfa44b97e19b4b", size = 4310588, upload-time = "2026-01-29T23:03:43.314Z" }, + { url = "https://files.pythonhosted.org/packages/c1/55/f14deb95eaf4f30f07ef4b90a8590fc05d9e04df85ee379712f6fb6736d7/debugpy-1.8.20-cp312-cp312-win32.whl", hash = "sha256:4057ac68f892064e5f98209ab582abfee3b543fb55d2e87610ddc133a954d390", size = 5331372, upload-time = "2026-01-29T23:03:45.526Z" }, + { url = "https://files.pythonhosted.org/packages/a1/39/2bef246368bd42f9bd7cba99844542b74b84dacbdbea0833e610f384fee8/debugpy-1.8.20-cp312-cp312-win_amd64.whl", hash = "sha256:a1a8f851e7cf171330679ef6997e9c579ef6dd33c9098458bd9986a0f4ca52e3", size = 5372835, upload-time = "2026-01-29T23:03:47.245Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/fc500524cc6f104a9d049abc85a0a8b3f0d14c0a39b9c140511c61e5b40b/debugpy-1.8.20-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:5dff4bb27027821fdfcc9e8f87309a28988231165147c31730128b1c983e282a", size = 2539560, upload-time = "2026-01-29T23:03:48.738Z" }, + { url = "https://files.pythonhosted.org/packages/90/83/fb33dcea789ed6018f8da20c5a9bc9d82adc65c0c990faed43f7c955da46/debugpy-1.8.20-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:84562982dd7cf5ebebfdea667ca20a064e096099997b175fe204e86817f64eaf", size = 4293272, upload-time = "2026-01-29T23:03:50.169Z" }, + { url = "https://files.pythonhosted.org/packages/a6/25/b1e4a01bfb824d79a6af24b99ef291e24189080c93576dfd9b1a2815cd0f/debugpy-1.8.20-cp313-cp313-win32.whl", hash = "sha256:da11dea6447b2cadbf8ce2bec59ecea87cc18d2c574980f643f2d2dfe4862393", size = 5331208, upload-time = "2026-01-29T23:03:51.547Z" }, + { url = "https://files.pythonhosted.org/packages/13/f7/a0b368ce54ffff9e9028c098bd2d28cfc5b54f9f6c186929083d4c60ba58/debugpy-1.8.20-cp313-cp313-win_amd64.whl", hash = "sha256:eb506e45943cab2efb7c6eafdd65b842f3ae779f020c82221f55aca9de135ed7", size = 5372930, upload-time = "2026-01-29T23:03:53.585Z" }, + { url = "https://files.pythonhosted.org/packages/33/2e/f6cb9a8a13f5058f0a20fe09711a7b726232cd5a78c6a7c05b2ec726cff9/debugpy-1.8.20-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9c74df62fc064cd5e5eaca1353a3ef5a5d50da5eb8058fcef63106f7bebe6173", size = 2538066, upload-time = "2026-01-29T23:03:54.999Z" }, + { url = "https://files.pythonhosted.org/packages/c5/56/6ddca50b53624e1ca3ce1d1e49ff22db46c47ea5fb4c0cc5c9b90a616364/debugpy-1.8.20-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:077a7447589ee9bc1ff0cdf443566d0ecf540ac8aa7333b775ebcb8ce9f4ecad", size = 4269425, upload-time = "2026-01-29T23:03:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/c5/d9/d64199c14a0d4c476df46c82470a3ce45c8d183a6796cfb5e66533b3663c/debugpy-1.8.20-cp314-cp314-win32.whl", hash = "sha256:352036a99dd35053b37b7803f748efc456076f929c6a895556932eaf2d23b07f", size = 5331407, upload-time = "2026-01-29T23:03:58.481Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d9/1f07395b54413432624d61524dfd98c1a7c7827d2abfdb8829ac92638205/debugpy-1.8.20-cp314-cp314-win_amd64.whl", hash = "sha256:a98eec61135465b062846112e5ecf2eebb855305acc1dfbae43b72903b8ab5be", size = 5372521, upload-time = "2026-01-29T23:03:59.864Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c3/7f67dea8ccf8fdcb9c99033bbe3e90b9e7395415843accb81428c441be2d/debugpy-1.8.20-py2.py3-none-any.whl", hash = "sha256:5be9bed9ae3be00665a06acaa48f8329d2b9632f15fd09f6a9a8c8d9907e54d7", size = 5337658, upload-time = "2026-01-29T23:04:17.404Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "einops" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/77/850bef8d72ffb9219f0b1aac23fbc1bf7d038ee6ea666f331fa273031aa2/einops-0.8.2.tar.gz", hash = "sha256:609da665570e5e265e27283aab09e7f279ade90c4f01bcfca111f3d3e13f2827", size = 56261, upload-time = "2026-01-26T04:13:17.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/09/f8d8f8f31e4483c10a906437b4ce31bdf3d6d417b73fe33f1a8b59e34228/einops-0.8.2-py3-none-any.whl", hash = "sha256:54058201ac7087911181bfec4af6091bb59380360f069276601256a76af08193", size = 65638, upload-time = "2026-01-26T04:13:18.546Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "filelock" +version = "3.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/77/18/a1fd2231c679dcb9726204645721b12498aeac28e1ad0601038f94b42556/filelock-3.25.0.tar.gz", hash = "sha256:8f00faf3abf9dc730a1ffe9c354ae5c04e079ab7d3a683b7c32da5dd05f26af3", size = 40158, upload-time = "2026-03-01T15:08:45.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, +] + +[[package]] +name = "flash-attn" +version = "2.8.3.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "einops" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/7a/92a46e7cd6bbb4d7b2855a457c3b855df54a97af5656d98fc92e58e61065/flash_attn-2.8.3.post1.tar.gz", hash = "sha256:55d5103ed846da8b56e0797acf4bde07dee4b1c7e8907fcfc6699c203030c348", size = 8451657, upload-time = "2026-06-11T04:35:21.318Z" } + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + +[package.optional-dependencies] +http = [ + { name = "aiohttp" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "hf-xet" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/27/06d899ea7bd721d272f84aac98bdb238de98af4cc767a69056d967d68c71/hf_xet-1.7.0.tar.gz", hash = "sha256:d406ec79053c0871817f700c2ac8c36ba0d87f9c34b7458b0f0063bb218b0466", size = 985689, upload-time = "2026-10-06T20:18:43.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/7c/3e45174942e6793adde6cba4daa7fb037275cf02a944d9eadfcf9ff33b86/hf_xet-1.7.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:fa029678be1ba7f953c409b0b27bf15cc69cd1c9b3a674fbd78856ebefca1052", size = 3803919, upload-time = "2026-10-06T20:18:09.844Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3a/5e8b363391adcbb002e191dbf924dab31464ea9c45adfeb73502afc36d35/hf_xet-1.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57bc157b8b7fe3bee9dcb9af7f3da8de41801c3b31a9ef68a77a33c6a6be382f", size = 3553588, upload-time = "2026-10-06T20:18:13.376Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c2/0d1eaa5da13bbf9c896badc7f380601c7d973a87a6ffb4d100267c4536c1/hf_xet-1.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:87dab080f8f7d32781c2586904e3603f4e60d09bfc727706c3ae419e0829beeb", size = 4201962, upload-time = "2026-10-06T20:18:16.11Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/225d5b11a9ca7d31b9470a57f2b2be1a5cef8b84325a2146aeb4589e226c/hf_xet-1.7.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b01fe18dbbd151a2403d2c64ed30dc6547b00d6babab9a617d77c7acdb81ee66", size = 3982978, upload-time = "2026-10-06T20:18:18.092Z" }, + { url = "https://files.pythonhosted.org/packages/93/34/9d681f0e3dac0b5dae0d7dea748429266f24e52415446523f464fbaa828e/hf_xet-1.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ee5e05a627f5ab5bad7a86582277d645556ea1e199903aae19e033a392aa13a", size = 4181558, upload-time = "2026-10-06T20:18:20.082Z" }, + { url = "https://files.pythonhosted.org/packages/de/f0/277f039b7d72027bc2ed277f1b62a2f70f740a5aac2a3e7243e5b6854c5d/hf_xet-1.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:19c0e64f14175ccb6a1aff69e0d2ab9ec5269a560e6687abaf2b3fa4f73de7cd", size = 4411546, upload-time = "2026-10-06T20:18:21.999Z" }, + { url = "https://files.pythonhosted.org/packages/3d/7f/832d3ddb49326114175b7bcc50daea8565c09fd21ac03a02b211c09fefb7/hf_xet-1.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:757168feb5679647c0bb13ee5d0faebe799c4dff9051419885a566ebd79f949d", size = 3812809, upload-time = "2026-10-06T20:18:24.288Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c4/310c3c29e5beae7c049e63947bd1923d597883b41c9ec4718589920812c4/hf_xet-1.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b91569d5f1b61c34b043687da02c05dd3604f3d329e7868510bf3f7971599006", size = 3646174, upload-time = "2026-10-06T20:18:26.279Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0b/b03be21ffaada749ba0d3197d8aefbf1aa698bac149580421c15239b299e/hf_xet-1.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e3e88a7a75d7d95cbee1f37dc31341d6201124cf21c6c4b1dfab8ccba9b09e0f", size = 3796096, upload-time = "2026-10-06T20:18:28.43Z" }, + { url = "https://files.pythonhosted.org/packages/c3/47/a26ebdce7056a61e931f228439bc0ab08cbec239d1690f965e5e637cba79/hf_xet-1.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:59fba37039233c7fcbe196817d6cdcf1b40dfb17b410f229d85b0cf0a1848da4", size = 3560352, upload-time = "2026-10-06T20:18:30.365Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4c/2bf3b66c215d409655f28de1622393dde04c9461280d48c7924bb3b2decd/hf_xet-1.7.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2814a6e999d13464c4d679b788cc5d784eb5a4edfc638a31f10e9a11ab531ef8", size = 4212180, upload-time = "2026-10-06T20:18:32.292Z" }, + { url = "https://files.pythonhosted.org/packages/49/0c/a2f703a5a78267556e89e03316fa0805c86b72b50829bc67665746e8ebf0/hf_xet-1.7.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fcfd6c22418e57dd5b3aea649e813b2e2cfb2aebf317b210d90f1fe4b3018b52", size = 3990011, upload-time = "2026-10-06T20:18:34.21Z" }, + { url = "https://files.pythonhosted.org/packages/a4/77/e52e4201b1cbf571530a61cc57f70182045a39a230089ee5f1df182a4de2/hf_xet-1.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80f79dae613ce9e0ea1fd1ae15616ca9ac74aed4c770aabc199c4f03ebecc863", size = 4190628, upload-time = "2026-10-06T20:18:36.062Z" }, + { url = "https://files.pythonhosted.org/packages/6c/dc/03a21b89f118664a0926ff25b0f8e44a519bf22724a6a8fc7a9abbc188b6/hf_xet-1.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0a9e802f33bf50c851abe45fc5380e61f959e2d369647d6742b79ad9d6c27cab", size = 4418814, upload-time = "2026-10-06T20:18:37.888Z" }, + { url = "https://files.pythonhosted.org/packages/4d/59/b35106dfa71b6eef605dc88bd038fe99c7f86fb132a15b60d0bf2f235b2c/hf_xet-1.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:2b7bb5727889b0f2436dbaaad8fc4c3e66b8240d992716989e0c086b4278b1bc", size = 3822644, upload-time = "2026-10-06T20:18:40.052Z" }, + { url = "https://files.pythonhosted.org/packages/48/cd/072313585f74fe9d441e2eb5e0a4703c30586cd709810ea369675f61b74e/hf_xet-1.7.0-cp38-abi3-win_arm64.whl", hash = "sha256:acc3851cf2576a8fb2ae926da863f4efabe21303cf292e9a44332802ab0dcc6a", size = 3662436, upload-time = "2026-10-06T20:18:42.205Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "huggingface-hub" +version = "1.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "filelock" }, + { name = "fsspec" }, + { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/25/2a/484d112c0d8fc5f665d7b65137ac9cdb2953c982391598c3597968a12ee7/huggingface_hub-1.33.0.tar.gz", hash = "sha256:367be21a201db9523eddf8aeac7048f2602c1b308691c97640d5e72ed188007e", size = 1043548, upload-time = "2026-09-24T09:49:29.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/16/963096d224b80909432dc16561a615fd33d2d13beef3ce4c63fa25e40867/huggingface_hub-1.33.0-py3-none-any.whl", hash = "sha256:04e434b06e100eddbce9a6e817d72693a7884b10a79bd67ab48080d5c07eb899", size = 846435, upload-time = "2026-09-24T09:49:28.059Z" }, +] + +[[package]] +name = "hydra-core" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "omegaconf" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/8e/07e42bc434a847154083b315779b0a81d567154504624e181caf2c71cd98/hydra-core-1.3.2.tar.gz", hash = "sha256:8a878ed67216997c3e9d88a8e72e7b4767e81af37afb4ea3334b269a4390a824", size = 3263494, upload-time = "2023-02-23T18:33:43.03Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/50/e0edd38dcd63fb26a8547f13d28f7a008bc4a3fd4eb4ff030673f22ad41a/hydra_core-1.3.2-py3-none-any.whl", hash = "sha256:fa0238a9e31df3373b35b0bfb672c34cc92718d21f81311d8996a16de1141d8b", size = 154547, upload-time = "2023-02-23T18:33:40.801Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/8d/b68b728e2d06b9e0051019640a40a9eb7a88fcd82c2e1b5ce70bef5ff044/ipykernel-7.2.0.tar.gz", hash = "sha256:18ed160b6dee2cbb16e5f3575858bc19d8f1fe6046a9a680c708494ce31d909e", size = 176046, upload-time = "2026-02-06T16:43:27.403Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b9/e73d5d9f405cba7706c539aa8b311b49d4c2f3d698d9c12f815231169c71/ipykernel-7.2.0-py3-none-any.whl", hash = "sha256:3bbd4420d2b3cc105cbdf3756bfc04500b1e52f090a90716851f3916c62e1661", size = 118788, upload-time = "2026-02-06T16:43:25.149Z" }, +] + +[[package]] +name = "ipython" +version = "9.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/e4/ba649102a3bc3fbca54e7239fb924fd434c766f855693d86de0b1f2bec81/jupyter_client-8.8.0.tar.gz", hash = "sha256:d556811419a4f2d96c869af34e854e3f059b7cc2d6d01a9cd9c85c267691be3e", size = 348020, upload-time = "2026-01-08T13:55:47.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/0b/ceb7694d864abc0a047649aec263878acb9f792e1fec3e676f22dc9015e3/jupyter_client-8.8.0-py3-none-any.whl", hash = "sha256:f93a5b99c5e23a507b773d3a1136bd6e16c67883ccdbd9a829b0bbdb98cd7d7a", size = 107371, upload-time = "2026-01-08T13:55:45.562Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "lightning" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pytorch-lightning" }, + { name = "pyyaml" }, + { name = "torch" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/ad/a1c91a795521be252209d45fb080f28a4f1e7244d3b37121fcc6e3e43034/lightning-2.6.1.tar.gz", hash = "sha256:859104b98c61add6fe60d0c623abf749baf25f2950a66ebdfb4bd18aa7decba9", size = 663175, upload-time = "2026-01-30T14:59:13.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/6d/42640e15a8c34b57dc7ea922152440c0c6692214a08d5282b6e3eb46ddf4/lightning-2.6.1-py3-none-any.whl", hash = "sha256:30e1adac23004c713663928541bd72ecb1371b7abc9aff9f46b7fd2644988d30", size = 853631, upload-time = "2026-01-30T14:59:11.687Z" }, +] + +[[package]] +name = "lightning-utilities" +version = "0.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/45/7fa8f56b17dc0f0a41ec70dd307ecd6787254483549843bef4c30ab5adce/lightning_utilities-0.15.3.tar.gz", hash = "sha256:792ae0204c79f6859721ac7f386c237a33b0ed06ba775009cb894e010a842033", size = 33553, upload-time = "2026-02-22T14:48:53.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/f4/ead6e0e37209b07c9baa3e984ccdb0348ca370b77cea3aaea8ddbb097e00/lightning_utilities-0.15.3-py3-none-any.whl", hash = "sha256:6c55f1bee70084a1cbeaa41ada96e4b3a0fea5909e844dd335bd80f5a73c5f91", size = 31906, upload-time = "2026-02-22T14:48:52.488Z" }, +] + +[[package]] +name = "lmdb" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/44/d94934efaf8f887b6959f131fde740fcaa831edfd13eb5425574637cddd5/lmdb-2.2.0.tar.gz", hash = "sha256:53020e20305c043ea6e68089bc242d744fba6073cdb268332299ba6dda2886d4", size = 933189, upload-time = "2026-03-30T01:26:19.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/20/043bd8851979fb86a7fdb08b4337d319dbccf7f468632418527bad684945/lmdb-2.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a72dba2c63f6d497f1dd1a1e46e30f14dfb8c1fddc5a51ed913993f5ac03736c", size = 112274, upload-time = "2026-03-30T01:25:40.919Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d1/d8f61fda6f837dad050514544560385a0f12e8b94e91079f63632195acc6/lmdb-2.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5c807ce9c514354c4f2e76f97e69002048b7f4a3c97a3eaf82415bf7c5daed77", size = 111129, upload-time = "2026-03-30T01:25:42.31Z" }, + { url = "https://files.pythonhosted.org/packages/19/11/f25fc19a68d8218d1337894b323fae79a4cccdef0994ba1c2714e268a2cd/lmdb-2.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a073fada46565c373c8683c67c7c07cc0d3511fef7e122da7052bb5720d2af09", size = 321904, upload-time = "2026-03-30T01:25:43.436Z" }, + { url = "https://files.pythonhosted.org/packages/31/a0/1b95f1d53e207d7f4581950228ae891fd930f5d2aeda1501a95982c7b2a8/lmdb-2.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:856b322399dcc1992675b8cf5f56cd54e89d05ea86a89dc5f6fa6d671c7b48f2", size = 324208, upload-time = "2026-03-30T01:25:44.706Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1a/6c5931ee1412a9d8c0c3859ed33bb64ed00ea8ef418413c56524e0372ef3/lmdb-2.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:beacb2aed281cc806cb9a91663ed1a772fecd7a125d16b694cfc7af94a9864be", size = 109793, upload-time = "2026-03-30T01:25:46.148Z" }, + { url = "https://files.pythonhosted.org/packages/2e/36/0ba441a4faddd32376270aabedf915d7a21f5fe031313e18c6998b0138d4/lmdb-2.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:e36455ace4c50b5185e4660e19d63533fe5c07840598eeefaad783415a380bab", size = 103680, upload-time = "2026-03-30T01:25:47.222Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a7/9604e594725e2d2d0482669cfd9cba23cc47bd288f076c7e93985e5c046c/lmdb-2.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8cc73de748070321986a3a26f51f3693bdd196c20e797d8d2ad0e860b5d2e26c", size = 113096, upload-time = "2026-03-30T01:25:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/05/cf/7b8e13c1253c77a2c41b7786659d64e97f758a13f1fafdb815cf76630eba/lmdb-2.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9b6fecb1e32c55f0a1f3585d637f221e20146bb3ea9997c50fdfa3a58c0c2e41", size = 111656, upload-time = "2026-03-30T01:25:49.36Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/f059c48e4f3321710825fdb1cdee50d32eea90e0c097441beec1b155788f/lmdb-2.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:547e083457b6a0936fe73821f35c019be817877f9a85488be818ec8383ef47a6", size = 329003, upload-time = "2026-03-30T01:25:50.47Z" }, + { url = "https://files.pythonhosted.org/packages/38/22/513c885f284eccd49fc8d1c0a9a9d5da6badd9efc600d482424118df2a67/lmdb-2.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd505c995a595403f69367cbf16bcd5c88cdd208c706d709ba9b1bc2f9a16f69", size = 333140, upload-time = "2026-03-30T01:25:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/f1/9b/8b3c81009230ebbe340e59cf2996626800f291e034ed76535d754b2cf98c/lmdb-2.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:dacf737ad869c6e48e1471dfa4d3e7c6ce2d082a218c069e20c4a138804e5fd2", size = 109668, upload-time = "2026-03-30T01:25:53.091Z" }, + { url = "https://files.pythonhosted.org/packages/0b/68/368099745c1d82d079c490c62cdef5e99bc9a3e9132991e3b82967363d55/lmdb-2.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:653f5e183b04b9124c505c519a3ff691038b4fb459c3211b1323c67bfba53f37", size = 103760, upload-time = "2026-03-30T01:25:54.374Z" }, + { url = "https://files.pythonhosted.org/packages/64/43/543af71e8fa4c56623bb89c358121ab806426f26685f11539fe5452deffa/lmdb-2.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36e0cbe6b7d59f6e19b448942c5f9e91674f596a802743258f82e926a9a09632", size = 113550, upload-time = "2026-03-30T01:25:55.727Z" }, + { url = "https://files.pythonhosted.org/packages/22/2c/4702d36c0073737554b20d1d62e879a066df963482f8e514866588ddd82d/lmdb-2.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5d7a9dfd279a5884806fd478244961e4483cc6d7eb769caed1d7019a8608c20", size = 112135, upload-time = "2026-03-30T01:25:56.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/43/d015fea326ed0a634107f29740b002170a462b6d2481e509105c685520f5/lmdb-2.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0dbe7902b2cdb60bf6c893f307ef2b2a5039afd22f029515b86183f05ab1353", size = 332108, upload-time = "2026-03-30T01:25:57.907Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c9/503e7f173994b514936badcbcb7fa9f89a07a3cfe596c6fb95b1b91b8d70/lmdb-2.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c576cdb163ae61a7ef6eecbc20a6025a4abe085491c1dc0c667d726f4926b53", size = 336017, upload-time = "2026-03-30T01:25:59.234Z" }, + { url = "https://files.pythonhosted.org/packages/3e/94/b3b064acfd2f8acf5aaa53fff2c43963dbc1932ba8b8df4e27d75bf6a34a/lmdb-2.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:746eebcd4c0aeaf0eb2f897028929d270c5bc80ef4918500eec16db6f26f3fcc", size = 109574, upload-time = "2026-03-30T01:26:00.324Z" }, + { url = "https://files.pythonhosted.org/packages/b9/10/dc7488d1effc339cd9470f9d22ec0fd7052a3d4fdfae87765ecd41cb2e59/lmdb-2.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:006153aac9fb0415a5f3e8ac88789e5730dba3dd0743cd84c95e3951ff68bc3a", size = 103810, upload-time = "2026-03-30T01:26:01.559Z" }, + { url = "https://files.pythonhosted.org/packages/36/3f/452a81add862d99722e18c92b2a0202d9bb316fb19422150b4424ec7a983/lmdb-2.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0398fef4ab54d66f531257e7a68c03314a267da5d2fd76d75481f62a237ec28b", size = 113740, upload-time = "2026-03-30T01:26:02.632Z" }, + { url = "https://files.pythonhosted.org/packages/c4/73/62edf6b273d4118c0ed4b5afc5797ca68091e360daa91ef77ae8337084db/lmdb-2.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1610c601f397b0b523310b2393fd430f5973bfdb5dbec9cdc5f89510c5e887ca", size = 112192, upload-time = "2026-03-30T01:26:04.007Z" }, + { url = "https://files.pythonhosted.org/packages/dd/60/19c59e022e84dad27932b7af58319dd20afdb7de4f48698cac408f6066ab/lmdb-2.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc1d32c08afdbc7db1315d199c827e14c6ad8cdfc7d70872ff983f68079a5edb", size = 331709, upload-time = "2026-03-30T01:26:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/a9/51/28a24bd3d131ecc7b74c1dac06eea9194e05efe2af5032dece703d397a67/lmdb-2.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bdba273466d099f3ff3a10a26dc1d45101ac519bf67ae23b402a2f3191965e13", size = 334891, upload-time = "2026-03-30T01:26:06.328Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e6/3071576af6c318f76f36ac3b52f2f809b861d13193c6bbc004bdabd451de/lmdb-2.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:00a051a0d29e0d88e84035884e91a57e2e850355c7e1a3ea05c34753a56d3e12", size = 111309, upload-time = "2026-03-30T01:26:07.792Z" }, + { url = "https://files.pythonhosted.org/packages/1b/5d/723eabbfe716013db0d13c2015784249e91c87524cde1539c6b99daac68e/lmdb-2.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:2d2968d2a3ff6e69596d9604d2029d2d1265079aa2864eb721c27e076a1fd792", size = 106210, upload-time = "2026-03-30T01:26:09.231Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "morph" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "ase" }, + { name = "click" }, + { name = "huggingface-hub" }, + { name = "hydra-core" }, + { name = "ipykernel" }, + { name = "lightning" }, + { name = "lmdb" }, + { name = "matplotlib" }, + { name = "numpy" }, + { name = "omegaconf" }, + { name = "pandas" }, + { name = "posebusters" }, + { name = "py3dmol" }, + { name = "rdkit" }, + { name = "scipy" }, + { name = "torch" }, + { name = "torch-geometric" }, + { name = "torchmetrics" }, + { name = "wandb" }, +] + +[package.optional-dependencies] +flash-attn = [ + { name = "flash-attn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "ase", specifier = ">=3.28.0" }, + { name = "click", specifier = ">=8.3.1" }, + { name = "flash-attn", marker = "extra == 'flash-attn'", specifier = ">=2.8.3,<2.8.4" }, + { name = "huggingface-hub", specifier = ">=1.0,<2" }, + { name = "hydra-core", specifier = ">=1.3.2" }, + { name = "ipykernel", specifier = ">=7.2.0" }, + { name = "lightning", specifier = ">=2.5.5" }, + { name = "lmdb", specifier = ">=2.2.0" }, + { name = "matplotlib", specifier = ">=3.10.8" }, + { name = "numpy", specifier = ">=2.0" }, + { name = "omegaconf", specifier = ">=2.3.0" }, + { name = "pandas", specifier = ">=2.3.3" }, + { name = "posebusters", specifier = ">=0.6.0" }, + { name = "py3dmol", specifier = ">=2.5.4" }, + { name = "rdkit", specifier = ">=2025.9.1" }, + { name = "scipy", specifier = ">=1.15.0" }, + { name = "torch", specifier = ">=2.9.0a0,<2.9.1" }, + { name = "torch-geometric", specifier = ">=2.7.0" }, + { name = "torchmetrics", specifier = ">=1.8.0" }, + { name = "wandb", specifier = ">=0.22.2" }, +] +provides-extras = ["flash-attn"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.16.5" }, +] + +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" }, + { url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" }, + { url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" }, + { url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" }, + { url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" }, + { url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" }, + { url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" }, + { url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" }, + { url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" }, + { url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" }, + { url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" }, + { url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" }, + { url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" }, + { url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" }, + { url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" }, + { url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" }, + { url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" }, + { url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" }, + { url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" }, + { url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" }, + { url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" }, + { url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" }, + { url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" }, + { url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" }, + { url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" }, + { url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" }, + { url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" }, + { url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" }, + { url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" }, + { url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" }, + { url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" }, + { url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" }, + { url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" }, + { url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" }, + { url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" }, + { url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" }, +] + +[[package]] +name = "nvidia-cublas-cu12" +version = "12.8.4.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu12" +version = "9.10.2.21" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, +] + +[[package]] +name = "nvidia-cufft-cu12" +version = "11.3.3.83" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, +] + +[[package]] +name = "nvidia-cufile-cu12" +version = "1.13.1.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" }, +] + +[[package]] +name = "nvidia-curand-cu12" +version = "10.3.9.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" }, +] + +[[package]] +name = "nvidia-cusolver-cu12" +version = "11.7.3.90" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cublas-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, +] + +[[package]] +name = "nvidia-cusparse-cu12" +version = "12.5.8.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-nvjitlink-cu12", marker = "sys_platform == 'linux'" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, +] + +[[package]] +name = "nvidia-cusparselt-cu12" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" }, +] + +[[package]] +name = "nvidia-nccl-cu12" +version = "2.27.5" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" }, +] + +[[package]] +name = "nvidia-nvjitlink-cu12" +version = "12.8.93" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" }, +] + +[[package]] +name = "nvidia-nvshmem-cu12" +version = "3.3.20" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/6c/99acb2f9eb85c29fc6f3a7ac4dccfd992e22666dd08a642b303311326a97/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d00f26d3f9b2e3c3065be895e3059d6479ea5c638a3f38c9fec49b1b9dd7c1e5", size = 124657145, upload-time = "2025-08-04T20:25:19.995Z" }, +] + +[[package]] +name = "nvidia-nvtx-cu12" +version = "12.8.90" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" }, +] + +[[package]] +name = "omegaconf" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/48/6388f1bb9da707110532cb70ec4d2822858ddfb44f1cdf1233c20a80ea4b/omegaconf-2.3.0.tar.gz", hash = "sha256:d5d4b6d29955cc50ad50c46dc269bcd92c6e00f5f90d23ab5fee7bfca4ba4cc7", size = 3298120, upload-time = "2022-12-08T20:59:22.753Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/94/1843518e420fa3ed6919835845df698c7e27e183cb997394e4a670973a65/omegaconf-2.3.0-py3-none-any.whl", hash = "sha256:7b4df175cdb08ba400f45cae3bdcae7ba8365db4d165fc65fd04b050ab63b46b", size = 79500, upload-time = "2022-12-08T20:59:19.686Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/0c/b28ed414f080ee0ad153f848586d61d1878f91689950f037f976ce15f6c8/pandas-3.0.1.tar.gz", hash = "sha256:4186a699674af418f655dbd420ed87f50d56b4cd6603784279d9eef6627823c8", size = 4641901, upload-time = "2026-02-17T22:20:16.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ff/07/c7087e003ceee9b9a82539b40414ec557aa795b584a1a346e89180853d79/pandas-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:de09668c1bf3b925c07e5762291602f0d789eca1b3a781f99c1c78f6cac0e7ea", size = 10323380, upload-time = "2026-02-17T22:18:16.133Z" }, + { url = "https://files.pythonhosted.org/packages/c1/27/90683c7122febeefe84a56f2cde86a9f05f68d53885cebcc473298dfc33e/pandas-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:24ba315ba3d6e5806063ac6eb717504e499ce30bd8c236d8693a5fd3f084c796", size = 9923455, upload-time = "2026-02-17T22:18:19.13Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f1/ed17d927f9950643bc7631aa4c99ff0cc83a37864470bc419345b656a41f/pandas-3.0.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:406ce835c55bac912f2a0dcfaf27c06d73c6b04a5dde45f1fd3169ce31337389", size = 10753464, upload-time = "2026-02-17T22:18:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/2e/7c/870c7e7daec2a6c7ff2ac9e33b23317230d4e4e954b35112759ea4a924a7/pandas-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:830994d7e1f31dd7e790045235605ab61cff6c94defc774547e8b7fdfbff3dc7", size = 11255234, upload-time = "2026-02-17T22:18:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/3653fe59af68606282b989c23d1a543ceba6e8099cbcc5f1d506a7bae2aa/pandas-3.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a64ce8b0f2de1d2efd2ae40b0abe7f8ae6b29fbfb3812098ed5a6f8e235ad9bf", size = 11767299, upload-time = "2026-02-17T22:18:26.824Z" }, + { url = "https://files.pythonhosted.org/packages/9b/31/1daf3c0c94a849c7a8dab8a69697b36d313b229918002ba3e409265c7888/pandas-3.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9832c2c69da24b602c32e0c7b1b508a03949c18ba08d4d9f1c1033426685b447", size = 12333292, upload-time = "2026-02-17T22:18:28.996Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/af63f83cd6ca603a00fe8530c10a60f0879265b8be00b5930e8e78c5b30b/pandas-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:84f0904a69e7365f79a0c77d3cdfccbfb05bf87847e3a51a41e1426b0edb9c79", size = 9892176, upload-time = "2026-02-17T22:18:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/79/ab/9c776b14ac4b7b4140788eca18468ea39894bc7340a408f1d1e379856a6b/pandas-3.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:4a68773d5a778afb31d12e34f7dd4612ab90de8c6fb1d8ffe5d4a03b955082a1", size = 9151328, upload-time = "2026-02-17T22:18:35.721Z" }, + { url = "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d", size = 10344357, upload-time = "2026-02-17T22:18:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955", size = 9884543, upload-time = "2026-02-17T22:18:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b", size = 10396030, upload-time = "2026-02-17T22:18:43.822Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4", size = 10876435, upload-time = "2026-02-17T22:18:45.954Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a6/2a75320849dd154a793f69c951db759aedb8d1dd3939eeacda9bdcfa1629/pandas-3.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:356e5c055ed9b0da1580d465657bc7d00635af4fd47f30afb23025352ba764d1", size = 11405133, upload-time = "2026-02-17T22:18:48.533Z" }, + { url = "https://files.pythonhosted.org/packages/58/53/1d68fafb2e02d7881df66aa53be4cd748d25cbe311f3b3c85c93ea5d30ca/pandas-3.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9d810036895f9ad6345b8f2a338dd6998a74e8483847403582cab67745bff821", size = 11932065, upload-time = "2026-02-17T22:18:50.837Z" }, + { url = "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43", size = 9741627, upload-time = "2026-02-17T22:18:53.905Z" }, + { url = "https://files.pythonhosted.org/packages/86/4f/caf9952948fb00d23795f09b893d11f1cacb384e666854d87249530f7cbe/pandas-3.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f463ebfd8de7f326d38037c7363c6dacb857c5881ab8961fb387804d6daf2f7", size = 9052483, upload-time = "2026-02-17T22:18:57.31Z" }, + { url = "https://files.pythonhosted.org/packages/0b/48/aad6ec4f8d007534c091e9a7172b3ec1b1ee6d99a9cbb936b5eab6c6cf58/pandas-3.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5272627187b5d9c20e55d27caf5f2cd23e286aba25cadf73c8590e432e2b7262", size = 10317509, upload-time = "2026-02-17T22:18:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/a8/14/5990826f779f79148ae9d3a2c39593dc04d61d5d90541e71b5749f35af95/pandas-3.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:661e0f665932af88c7877f31da0dc743fe9c8f2524bdffe23d24fdcb67ef9d56", size = 9860561, upload-time = "2026-02-17T22:19:02.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/f01ff54664b6d70fed71475543d108a9b7c888e923ad210795bef04ffb7d/pandas-3.0.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75e6e292ff898679e47a2199172593d9f6107fd2dd3617c22c2946e97d5df46e", size = 10365506, upload-time = "2026-02-17T22:19:05.017Z" }, + { url = "https://files.pythonhosted.org/packages/f2/85/ab6d04733a7d6ff32bfc8382bf1b07078228f5d6ebec5266b91bfc5c4ff7/pandas-3.0.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ff8cf1d2896e34343197685f432450ec99a85ba8d90cce2030c5eee2ef98791", size = 10873196, upload-time = "2026-02-17T22:19:07.204Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/9301c83d0b47c23ac5deab91c6b39fd98d5b5db4d93b25df8d381451828f/pandas-3.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eca8b4510f6763f3d37359c2105df03a7a221a508f30e396a51d0713d462e68a", size = 11370859, upload-time = "2026-02-17T22:19:09.436Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/0c1fc5bd2d29c7db2ab372330063ad555fb83e08422829c785f5ec2176ca/pandas-3.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06aff2ad6f0b94a17822cf8b83bbb563b090ed82ff4fe7712db2ce57cd50d9b8", size = 11924584, upload-time = "2026-02-17T22:19:11.562Z" }, + { url = "https://files.pythonhosted.org/packages/d6/7d/216a1588b65a7aa5f4535570418a599d943c85afb1d95b0876fc00aa1468/pandas-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fea306c783e28884c29057a1d9baa11a349bbf99538ec1da44c8476563d1b25", size = 9742769, upload-time = "2026-02-17T22:19:13.926Z" }, + { url = "https://files.pythonhosted.org/packages/c4/cb/810a22a6af9a4e97c8ab1c946b47f3489c5bca5adc483ce0ffc84c9cc768/pandas-3.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:a8d37a43c52917427e897cb2e429f67a449327394396a81034a4449b99afda59", size = 9043855, upload-time = "2026-02-17T22:19:16.09Z" }, + { url = "https://files.pythonhosted.org/packages/92/fa/423c89086cca1f039cf1253c3ff5b90f157b5b3757314aa635f6bf3e30aa/pandas-3.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:d54855f04f8246ed7b6fc96b05d4871591143c46c0b6f4af874764ed0d2d6f06", size = 10752673, upload-time = "2026-02-17T22:19:18.304Z" }, + { url = "https://files.pythonhosted.org/packages/22/23/b5a08ec1f40020397f0faba72f1e2c11f7596a6169c7b3e800abff0e433f/pandas-3.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e1b677accee34a09e0dc2ce5624e4a58a1870ffe56fc021e9caf7f23cd7668f", size = 10404967, upload-time = "2026-02-17T22:19:20.726Z" }, + { url = "https://files.pythonhosted.org/packages/5c/81/94841f1bb4afdc2b52a99daa895ac2c61600bb72e26525ecc9543d453ebc/pandas-3.0.1-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9cabbdcd03f1b6cd254d6dda8ae09b0252524be1592594c00b7895916cb1324", size = 10320575, upload-time = "2026-02-17T22:19:24.919Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8b/2ae37d66a5342a83adadfd0cb0b4bf9c3c7925424dd5f40d15d6cfaa35ee/pandas-3.0.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ae2ab1f166668b41e770650101e7090824fd34d17915dd9cd479f5c5e0065e9", size = 10710921, upload-time = "2026-02-17T22:19:27.181Z" }, + { url = "https://files.pythonhosted.org/packages/a2/61/772b2e2757855e232b7ccf7cb8079a5711becb3a97f291c953def15a833f/pandas-3.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6bf0603c2e30e2cafac32807b06435f28741135cb8697eae8b28c7d492fc7d76", size = 11334191, upload-time = "2026-02-17T22:19:29.411Z" }, + { url = "https://files.pythonhosted.org/packages/1b/08/b16c6df3ef555d8495d1d265a7963b65be166785d28f06a350913a4fac78/pandas-3.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6c426422973973cae1f4a23e51d4ae85974f44871b24844e4f7de752dd877098", size = 11782256, upload-time = "2026-02-17T22:19:32.34Z" }, + { url = "https://files.pythonhosted.org/packages/55/80/178af0594890dee17e239fca96d3d8670ba0f5ff59b7d0439850924a9c09/pandas-3.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b03f91ae8c10a85c1613102c7bef5229b5379f343030a3ccefeca8a33414cf35", size = 10485047, upload-time = "2026-02-17T22:19:34.605Z" }, + { url = "https://files.pythonhosted.org/packages/bb/8b/4bb774a998b97e6c2fd62a9e6cfdaae133b636fd1c468f92afb4ae9a447a/pandas-3.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:99d0f92ed92d3083d140bf6b97774f9f13863924cf3f52a70711f4e7588f9d0a", size = 10322465, upload-time = "2026-02-17T22:19:36.803Z" }, + { url = "https://files.pythonhosted.org/packages/72/3a/5b39b51c64159f470f1ca3b1c2a87da290657ca022f7cd11442606f607d1/pandas-3.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b66857e983208654294bb6477b8a63dee26b37bdd0eb34d010556e91261784f", size = 9910632, upload-time = "2026-02-17T22:19:39.001Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f7/b449ffb3f68c11da12fc06fbf6d2fa3a41c41e17d0284d23a79e1c13a7e4/pandas-3.0.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56cf59638bf24dc9bdf2154c81e248b3289f9a09a6d04e63608c159022352749", size = 10440535, upload-time = "2026-02-17T22:19:41.157Z" }, + { url = "https://files.pythonhosted.org/packages/55/77/6ea82043db22cb0f2bbfe7198da3544000ddaadb12d26be36e19b03a2dc5/pandas-3.0.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a9f55e0f46951874b863d1f3906dcb57df2d9be5c5847ba4dfb55b2c815249", size = 10893940, upload-time = "2026-02-17T22:19:43.493Z" }, + { url = "https://files.pythonhosted.org/packages/03/30/f1b502a72468c89412c1b882a08f6eed8a4ee9dc033f35f65d0663df6081/pandas-3.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1849f0bba9c8a2fb0f691d492b834cc8dadf617e29015c66e989448d58d011ee", size = 11442711, upload-time = "2026-02-17T22:19:46.074Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f0/ebb6ddd8fc049e98cabac5c2924d14d1dda26a20adb70d41ea2e428d3ec4/pandas-3.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3d288439e11b5325b02ae6e9cc83e6805a62c40c5a6220bea9beb899c073b1c", size = 11963918, upload-time = "2026-02-17T22:19:48.838Z" }, + { url = "https://files.pythonhosted.org/packages/09/f8/8ce132104074f977f907442790eaae24e27bce3b3b454e82faa3237ff098/pandas-3.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:93325b0fe372d192965f4cca88d97667f49557398bbf94abdda3bf1b591dbe66", size = 9862099, upload-time = "2026-02-17T22:19:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/6af9aac41ef2456b768ef0ae60acf8abcebb450a52043d030a65b4b7c9bd/pandas-3.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:97ca08674e3287c7148f4858b01136f8bdfe7202ad25ad04fec602dd1d29d132", size = 9185333, upload-time = "2026-02-17T22:19:53.266Z" }, + { url = "https://files.pythonhosted.org/packages/66/fc/848bb6710bc6061cb0c5badd65b92ff75c81302e0e31e496d00029fe4953/pandas-3.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:58eeb1b2e0fb322befcf2bbc9ba0af41e616abadb3d3414a6bc7167f6cbfce32", size = 10772664, upload-time = "2026-02-17T22:19:55.806Z" }, + { url = "https://files.pythonhosted.org/packages/69/5c/866a9bbd0f79263b4b0db6ec1a341be13a1473323f05c122388e0f15b21d/pandas-3.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cd9af1276b5ca9e298bd79a26bda32fa9cc87ed095b2a9a60978d2ca058eaf87", size = 10421286, upload-time = "2026-02-17T22:19:58.091Z" }, + { url = "https://files.pythonhosted.org/packages/51/a4/2058fb84fb1cfbfb2d4a6d485e1940bb4ad5716e539d779852494479c580/pandas-3.0.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f87a04984d6b63788327cd9f79dda62b7f9043909d2440ceccf709249ca988", size = 10342050, upload-time = "2026-02-17T22:20:01.376Z" }, + { url = "https://files.pythonhosted.org/packages/22/1b/674e89996cc4be74db3c4eb09240c4bb549865c9c3f5d9b086ff8fcfbf00/pandas-3.0.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85fe4c4df62e1e20f9db6ebfb88c844b092c22cd5324bdcf94bfa2fc1b391221", size = 10740055, upload-time = "2026-02-17T22:20:04.328Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f8/e954b750764298c22fa4614376531fe63c521ef517e7059a51f062b87dca/pandas-3.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:331ca75a2f8672c365ae25c0b29e46f5ac0c6551fdace8eec4cd65e4fac271ff", size = 11357632, upload-time = "2026-02-17T22:20:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/6d/02/c6e04b694ffd68568297abd03588b6d30295265176a5c01b7459d3bc35a3/pandas-3.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15860b1fdb1973fffade772fdb931ccf9b2f400a3f5665aef94a00445d7d8dd5", size = 11810974, upload-time = "2026-02-17T22:20:08.946Z" }, + { url = "https://files.pythonhosted.org/packages/89/41/d7dfb63d2407f12055215070c42fc6ac41b66e90a2946cdc5e759058398b/pandas-3.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:44f1364411d5670efa692b146c748f4ed013df91ee91e9bec5677fb1fd58b937", size = 10884622, upload-time = "2026-02-17T22:20:11.711Z" }, + { url = "https://files.pythonhosted.org/packages/68/b0/34937815889fa982613775e4b97fddd13250f11012d769949c5465af2150/pandas-3.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:108dd1790337a494aa80e38def654ca3f0968cf4f362c85f44c15e471667102d", size = 9452085, upload-time = "2026-02-17T22:20:14.331Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "posebusters" +version = "0.6.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pandas" }, + { name = "pyyaml" }, + { name = "rdkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/1a/257c45d93b895c51fc411f7a40a39d10c129f90b7d320a7dada8795a9632/posebusters-0.6.5.tar.gz", hash = "sha256:066d4e9589979bd72c5c1cfe49f3f7fd30246cacd63fa492c3b0d15bca5c4b12", size = 5022843, upload-time = "2026-03-07T23:25:46.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/76/62fbbfc0c6b1df40b3d424d33a8588f23d22a59b81498c07ca629c99b3fb/posebusters-0.6.5-py3-none-any.whl", hash = "sha256:3e0cbca6481079d5ab7d1989a8a8f184dbba27366613c4b515658ea52fb95ea3", size = 567794, upload-time = "2026-03-07T23:25:44.467Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, + { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, + { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, + { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "py3dmol" +version = "2.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/7a/d7be826cf33a917f4ff78fd07237368dfdf21e09ab463b9de9a33c61ec0c/py3dmol-2.5.4.tar.gz", hash = "sha256:6142e1605f51e0fd8ec000db5ab045a90a08bc6147176735631a20be301dd19d", size = 7851, upload-time = "2026-01-22T13:33:49.8Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7d/cea3531f77df694ac7f169378250d85f19f69b09a5f4fa45f650837ae7cc/py3dmol-2.5.4-py2.py3-none-any.whl", hash = "sha256:32806726b5310524a2b5bfee320737f7feef635cafc945c991062806daa9e43a", size = 7154, upload-time = "2026-01-22T13:33:48.659Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytorch-lightning" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fsspec", extra = ["http"] }, + { name = "lightning-utilities" }, + { name = "packaging" }, + { name = "pyyaml" }, + { name = "torch" }, + { name = "torchmetrics" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/ac/ebd5f6f58691cbd4f73836e43e1727f3814311b960c41f88e259606ca2b2/pytorch_lightning-2.6.1.tar.gz", hash = "sha256:ba08f8901cf226fcca473046ad9346f414e99117762dc869c76e650d5b3d7bdc", size = 665563, upload-time = "2026-01-30T14:59:11.636Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/93/c8c361bf0a2fe50f828f32def460e8b8a14b93955d3fd302b1a9b63b19e4/pytorch_lightning-2.6.1-py3-none-any.whl", hash = "sha256:1f8118567ec829e3055f16cf1aa320883a86a47c836951bfd9dcfa34ec7ffd59", size = 857273, upload-time = "2026-01-30T14:59:10.141Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "rdkit" +version = "2025.9.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/fa/61735db51afc02c2c6d1d203dc5b0ddade8bb951fe72672f463cd111a77b/rdkit-2025.9.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8348ec9c5a223d94fa848f8330a68f746d870f61540a846fe8ea356998ea77f1", size = 29513218, upload-time = "2026-03-02T18:08:41.492Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fa/5d5919b3ef2f7f99419de3d5075977594cacf97effb60ca7000450372084/rdkit-2025.9.6-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d0ec921fee7f2908e1da8c4ea1185dbb6d0a22787bcf9749b442691c121c02ef", size = 35239409, upload-time = "2026-03-02T18:08:45.413Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/0c4656224167261093d057d018acb8b9ddbad22f3ebe3706424e23ef7b6b/rdkit-2025.9.6-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3f4fc084890efb29b51ea4679bb07d28b276b6e73e3381e678a5ba057b4c4222", size = 36729354, upload-time = "2026-03-02T18:08:50.948Z" }, + { url = "https://files.pythonhosted.org/packages/51/6d/137748016222aee73e0d97b515838ee38dc5ed62f528e892c7d424994a09/rdkit-2025.9.6-cp311-cp311-win_amd64.whl", hash = "sha256:8c8e592948f442d2ec5a34f2e31295e03778686cec459a3d23c41b46ac56a358", size = 24297670, upload-time = "2026-03-02T18:08:55.013Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/599c81d456ab2eb3e8d6af1f2f8275689eec3a7c836c3ca6e4c75fb98607/rdkit-2025.9.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:95350eeb5d70c5576eca748caf59106ebeea259278ff05c3812adb12c5c4dc10", size = 29558443, upload-time = "2026-03-02T18:08:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5f/54a2d040d5ec2f21182da61d67145dd6b0cc3abf2db6737368b8bb3883ef/rdkit-2025.9.6-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7e8cda5243d586356c2a66bdf70fea3cb3fd552200556efe375da64efb047f73", size = 35119461, upload-time = "2026-03-02T18:09:03.765Z" }, + { url = "https://files.pythonhosted.org/packages/43/8c/42dde9df1a665e3589eeeaea463b11ec89c9d0c0e82db22d8128e3e14490/rdkit-2025.9.6-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6be95b735c428ba27badf39e92078133d3a505f872d0a31467ba577d73c57676", size = 36664387, upload-time = "2026-03-02T18:09:08.01Z" }, + { url = "https://files.pythonhosted.org/packages/27/45/69077326d9969bbf7958b7f6ef03a3111de83ddf8800d5c7dc390041bf36/rdkit-2025.9.6-cp312-cp312-win_amd64.whl", hash = "sha256:80c1fe3d1cc88540f2495272f32f02b4ec58a73db53649c9e4249087deeb62dc", size = 24315769, upload-time = "2026-03-02T18:09:11.987Z" }, + { url = "https://files.pythonhosted.org/packages/da/e2/33d8be4da529d9367a52b39c5f1c134070942ae56b35aa67e97f6b509b47/rdkit-2025.9.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0dee75188cae572bc2cf5b580291af0f3dc36ca6bb3f7c2a395b04450507efb0", size = 29557397, upload-time = "2026-03-02T18:09:15.392Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f8/5f6a5cda796b696aaea1b511bec5e828e5f4e7cdfc7eee56c8fcff49f8d3/rdkit-2025.9.6-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:fedb677e69e581dae2564b205418a22d1960e816a15378bf0ab358ed9ca2f477", size = 35118517, upload-time = "2026-03-02T18:09:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/72b938ea5fdcdb189b560532a47208c0b1421061d387820b487d7fae97f6/rdkit-2025.9.6-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:81023b0c9a138f0210716047ab02e4817c28d6505dc04bee783553282da06944", size = 36663055, upload-time = "2026-03-02T18:09:23.765Z" }, + { url = "https://files.pythonhosted.org/packages/13/89/116087b0a854ad951e36ee0e75b9f6da4a6b03826fb8607b8485bc49211d/rdkit-2025.9.6-cp313-cp313-win_amd64.whl", hash = "sha256:95572cd31ae6f57b9c77a25e904564bc4b973d0776614e65d89f22e0183552fd", size = 24314813, upload-time = "2026-03-02T18:09:27.54Z" }, + { url = "https://files.pythonhosted.org/packages/35/fa/11378b7f7b916b681d4812b868aad8f293d60c6a9f6c2596a9ea85ed0a89/rdkit-2025.9.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8710d903c4a83a6db2f0423f81d031a0a12c6c500d1482268e9ff86f87f5b07c", size = 29573154, upload-time = "2026-03-02T18:09:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a0/959cb16214e2924e9cb74176038d07cd67ff12a969c31f2a40f650ae75b9/rdkit-2025.9.6-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e90b33e5b44517b4f37d39aa2cc2780837cea6457fe23ef872a16235c81e91c8", size = 35153451, upload-time = "2026-03-02T18:09:35.283Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c9/faa051848450109c83d29c7b1d5871deea4a33aacbb8bacccb7b0e5460b8/rdkit-2025.9.6-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:97fa110972724cb465714395b001518ca4aef0d7f5b42848c26d77127d16aeab", size = 36675017, upload-time = "2026-03-02T18:09:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/61/a9/f5e4da805370cc2c263631303ba9b32f956b2b56c0ccbcfbac641eb4d3c3/rdkit-2025.9.6-cp314-cp314-win_amd64.whl", hash = "sha256:7448bc1498aff93a372728916546702994feb26838c8ce34bfc415ca552d2023", size = 24793545, upload-time = "2026-03-02T18:09:43.551Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/e9/2e3a46c304e7fa21eaa70612f60354e32699c7102eb961f67448e222ad7c/sentry_sdk-2.54.0.tar.gz", hash = "sha256:2620c2575128d009b11b20f7feb81e4e4e8ae08ec1d36cbc845705060b45cc1b", size = 413813, upload-time = "2026-03-02T15:12:41.355Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/39/be412cc86bc6247b8f69e9383d7950711bd86f8d0a4a4b0fe8fad685bc21/sentry_sdk-2.54.0-py2.py3-none-any.whl", hash = "sha256:fd74e0e281dcda63afff095d23ebcd6e97006102cdc8e78a29f19ecdf796a0de", size = 439198, upload-time = "2026-03-02T15:12:39.546Z" }, +] + +[[package]] +name = "setuptools" +version = "82.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/f3/748f4d6f65d1756b9ae577f329c951cda23fb900e4de9f70900ced962085/setuptools-82.0.0.tar.gz", hash = "sha256:22e0a2d69474c6ae4feb01951cb69d515ed23728cf96d05513d36e42b62b37cb", size = 1144893, upload-time = "2026-02-08T15:08:40.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/c6/76dc613121b793286a3f91621d7b75a2b493e0390ddca50f11993eadf192/setuptools-82.0.0-py3-none-any.whl", hash = "sha256:70b18734b607bd1da571d097d236cfcfacaf01de45717d59e6e04b96877532e0", size = 1003468, upload-time = "2026-02-08T15:08:38.723Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "torch" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "networkx" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-cupti-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "setuptools", marker = "python_full_version >= '3.12'" }, + { name = "sympy" }, + { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/fe/334225e6330e672b36aef23d77451fa906ea12881570c08638a91331a212/torch-2.9.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:c596708b5105d0b199215acf0c9be7c1db5f1680d88eddadf4b75a299259a677", size = 104230578, upload-time = "2025-10-15T15:46:08.182Z" }, + { url = "https://files.pythonhosted.org/packages/05/cc/49566caaa218872ec9a2912456f470ff92649894a4bc2e5274aa9ef87c4a/torch-2.9.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:51de31219c97c51cf4bf2be94d622e3deb5dcc526c6dc00e97c17eaec0fc1d67", size = 899815990, upload-time = "2025-10-15T15:48:03.336Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/e9ab21d5925b642d008f139d4a3c9664fc9ee1faafca22913c080cc4c0a5/torch-2.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:dd515c70059afd95f48b8192733764c08ca37a1d19803af6401b5ecad7c8676e", size = 109313698, upload-time = "2025-10-15T15:46:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b7/205ef3e94de636feffd64b28bb59a0dfac0771221201b9871acf9236f5ca/torch-2.9.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:614a185e4986326d526a91210c8fc1397e76e8cfafa78baf6296a790e53a9eec", size = 74463678, upload-time = "2025-10-15T15:46:29.779Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d3/3985739f3b8e88675127bf70f82b3a48ae083e39cda56305dbd90398fec0/torch-2.9.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:e5f7af1dc4c0a7c4a260c2534f41ddaf209714f7c89145e644c44712fbd6b642", size = 104107898, upload-time = "2025-10-15T15:46:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4b/f4bb2e6c25d0272f798cd6d7a04ed315da76cec68c602d87040c7847287f/torch-2.9.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:01cff95ecd9a212ea2f141db28acccdceb6a4c54f64e6c51091146f5e2a772c6", size = 899738273, upload-time = "2025-10-15T15:50:04.188Z" }, + { url = "https://files.pythonhosted.org/packages/66/11/c1c5ba6691cda6279087c35bd626536e4fd29521fe740abf5008377a9a02/torch-2.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4582b162f541651f0cb184d3e291c05c2f556c7117c64a9873e2ee158d40062b", size = 109280887, upload-time = "2025-10-15T15:46:26.228Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/b85bd8c05312d71de9402bf5868d217c38827cfd09d8f8514e5be128a52b/torch-2.9.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:33f58e9a102a91259af289d50525c30323b5c9ae1d31322b6447c0814da68695", size = 74478983, upload-time = "2025-10-15T15:46:39.406Z" }, + { url = "https://files.pythonhosted.org/packages/c2/1c/90eb13833cdf4969ea9707586d7b57095c3b6e2b223a7256bf111689bcb8/torch-2.9.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:c30a17fc83eeab346913e237c64b15b5ba6407fff812f6c541e322e19bc9ea0e", size = 104111330, upload-time = "2025-10-15T15:46:35.238Z" }, + { url = "https://files.pythonhosted.org/packages/0e/21/2254c54b8d523592c25ef4434769aa23e29b1e6bf5f4c0ad9e27bf442927/torch-2.9.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:8f25033b8667b57857dfd01458fbf2a9e6a6df1f8def23aef0dc46292f6aa642", size = 899750243, upload-time = "2025-10-15T15:48:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/b7/a5/5cb94fa4fd1e78223455c23c200f30f6dc10c6d4a2bcc8f6e7f2a2588370/torch-2.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:d037f1b4ffd25013be4a7bf3651a0a910c68554956c7b2c92ebe87c76475dece", size = 109284513, upload-time = "2025-10-15T15:46:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/66/e8/fc414d8656250ee46120b44836ffbb3266343db424b3e18ca79ebbf69d4f/torch-2.9.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e4e5b5cba837a2a8d1a497ba9a58dae46fa392593eaa13b871c42f71847503a5", size = 74830362, upload-time = "2025-10-15T15:46:48.983Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/9474c98fc5ae0cd04b9466035428cd360e6611a86b8352a0fc2fa504acdc/torch-2.9.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:64693568f5dc4dbd5f880a478b1cea0201cc6b510d91d1bc54fea86ac5d1a637", size = 104144940, upload-time = "2025-10-15T15:47:29.076Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5a/8e0c1cf57830172c109d4bd6be2708cabeaf550983eee7029291322447a0/torch-2.9.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:f8ed31ddd7d10bfb3fbe0b9fe01b1243577f13d75e6f4a0839a283915ce3791e", size = 899744054, upload-time = "2025-10-15T15:48:29.864Z" }, + { url = "https://files.pythonhosted.org/packages/6d/28/82c28b30fcb4b7c9cdd995763d18bbb830d6521356712faebbad92ffa61d/torch-2.9.0-cp313-cp313t-win_amd64.whl", hash = "sha256:eff527d4e4846e6f70d2afd8058b73825761203d66576a7e04ea2ecfebcb4ab8", size = 109517546, upload-time = "2025-10-15T15:47:33.395Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a91f96ec74347fa5fd24453fa514bc61c61ecc79196fa760b012a1873d96/torch-2.9.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:f8877779cf56d1ce431a7636703bdb13307f5960bb1af49716d8b179225e0e6a", size = 74480732, upload-time = "2025-10-15T15:47:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/73/9f70af34b334a7e0ef496ceec96b7ec767bd778ea35385ce6f77557534d1/torch-2.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7e614fae699838038d888729f82b687c03413c5989ce2a9481f9a7e7a396e0bb", size = 74433037, upload-time = "2025-10-15T15:47:41.894Z" }, + { url = "https://files.pythonhosted.org/packages/b7/84/37cf88625901934c97109e583ecc21777d21c6f54cda97a7e5bbad1ee2f2/torch-2.9.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:dfb5b8cd310ba3436c7e14e8b7833ef658cf3045e50d2bdaed23c8fc517065eb", size = 104116482, upload-time = "2025-10-15T15:47:46.266Z" }, + { url = "https://files.pythonhosted.org/packages/56/8e/ca8b17866943a8d4f4664d402ea84210aa274588b4c5d89918f5caa24eec/torch-2.9.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b3d29524993a478e46f5d598b249cd824b7ed98d7fba538bd9c4cde6c803948f", size = 899746916, upload-time = "2025-10-15T15:50:40.294Z" }, + { url = "https://files.pythonhosted.org/packages/43/65/3b17c0fbbdab6501c5b320a52a648628d0d44e7379f64e27d9eef701b6bf/torch-2.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:71c7578984f5ec0eb645eb4816ac8435fcf3e3e2ae1901bcd2f519a9cafb5125", size = 109275151, upload-time = "2025-10-15T15:49:20.715Z" }, + { url = "https://files.pythonhosted.org/packages/83/36/74f8c051f785500396e42f93542422422dfd874a174f21f8d955d36e5d64/torch-2.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:71d9309aee457bbe0b164bce2111cd911c4ed4e847e65d5077dbbcd3aba6befc", size = 74823353, upload-time = "2025-10-15T15:49:16.59Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/dc3b4e2f9ba98ae27238f0153ca098bf9340b2dafcc67fde645d496dfc2a/torch-2.9.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c08fb654d783899e204a32cca758a7ce8a45b2d78eeb89517cc937088316f78e", size = 104140340, upload-time = "2025-10-15T15:50:19.67Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8d/b00657f8141ac16af7bb6cda2e67de18499a3263b78d516b9a93fcbc98e3/torch-2.9.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ec8feb0099b2daa5728fbc7abb0b05730fd97e0f359ff8bda09865aaa7bd7d4b", size = 899731750, upload-time = "2025-10-15T15:49:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/fc/29/bd361e0cbb2c79ce6450f42643aaf6919956f89923a50571b0ebfe92d142/torch-2.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:695ba920f234ad4170c9c50e28d56c848432f8f530e6bc7f88fcb15ddf338e75", size = 109503850, upload-time = "2025-10-15T15:50:24.118Z" }, +] + +[[package]] +name = "torch-geometric" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "fsspec" }, + { name = "jinja2" }, + { name = "numpy" }, + { name = "psutil" }, + { name = "pyparsing" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/63/b210152635902da7fe79fcdd16517fae108f457a0ed22c737e702a9afbae/torch_geometric-2.7.0.tar.gz", hash = "sha256:f9099e4aece1a9f618c84dbaac33a77f43139736698c7e8bddf3301ef1f2e8d4", size = 876725, upload-time = "2025-10-15T20:48:03.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/4dffd7300500465e0b4a2ae917dcb2ce771de0b9a772670365799a27c024/torch_geometric-2.7.0-py3-none-any.whl", hash = "sha256:6e0cd3ad824d484651ef5d308fc66c687bfcf5ba040d56d1e0fe0f81f365e292", size = 1275346, upload-time = "2025-10-15T20:48:01.949Z" }, +] + +[[package]] +name = "torchmetrics" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lightning-utilities" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "torch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/2e/48a887a59ecc4a10ce9e8b35b3e3c5cef29d902c4eac143378526e7485cb/torchmetrics-1.8.2.tar.gz", hash = "sha256:cf64a901036bf107f17a524009eea7781c9c5315d130713aeca5747a686fe7a5", size = 580679, upload-time = "2025-09-03T14:00:54.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/21/aa0f434434c48490f91b65962b1ce863fdcce63febc166ca9fe9d706c2b6/torchmetrics-1.8.2-py3-none-any.whl", hash = "sha256:08382fd96b923e39e904c4d570f3d49e2cc71ccabd2a94e0f895d1f0dac86242", size = 983161, upload-time = "2025-09-03T14:00:51.921Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, + { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, +] + +[[package]] +name = "triton" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/78/949a04391c21956c816523678f0e5fa308eb5b1e7622d88c4e4ef5fceca0/triton-3.5.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f34bfa21c5b3a203c0f0eab28dcc1e49bd1f67d22724e77fb6665a659200a4ec", size = 170433488, upload-time = "2025-10-13T16:37:57.132Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3a/e991574f3102147b642e49637e0281e9bb7c4ba254edb2bab78247c85e01/triton-3.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9e71db82261c4ffa3921cd050cd5faa18322d2d405c30eb56084afaff3b0833", size = 170476535, upload-time = "2025-10-13T16:38:05.18Z" }, + { url = "https://files.pythonhosted.org/packages/6c/29/10728de8a6e932e517c10773486b8e99f85d1b1d9dd87d9a9616e1fef4a1/triton-3.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6bb9aa5519c084a333acdba443789e50012a4b851cd486c54f0b8dc2a8d3a12", size = 170487289, upload-time = "2025-10-13T16:38:11.662Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/db80e48b9220c9bce872b0f616ad0446cdf554a40b85c7865cbca99ab3c2/triton-3.5.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c83f2343e1a220a716c7b3ab9fccfcbe3ad4020d189549200e2d2e8d5868bed9", size = 170577179, upload-time = "2025-10-13T16:38:17.865Z" }, + { url = "https://files.pythonhosted.org/packages/ff/60/1810655d1d856c9a4fcc90ee8966d85f552d98c53a6589f95ab2cbe27bb8/triton-3.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da0fa67ccd76c3dcfb0bffe1b1c57c685136a6bd33d141c24d9655d4185b1289", size = 170487949, upload-time = "2025-10-13T16:38:24.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/b7/1dec8433ac604c061173d0589d99217fe7bf90a70bdc375e745d044b8aad/triton-3.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:317fe477ea8fd4524a6a8c499fb0a36984a56d0b75bf9c9cb6133a1c56d5a6e7", size = 170580176, upload-time = "2025-10-13T16:38:31.14Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "wandb" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "gitpython" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sentry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/60/d94952549920469524b689479c864c692ca47eca4b8c2fe3389b64a58778/wandb-0.25.0.tar.gz", hash = "sha256:45840495a288e34245d69d07b5a0b449220fbc5b032e6b51c4f92ec9026d2ad1", size = 43951335, upload-time = "2026-02-13T00:17:45.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/7d/0c131db3ec9deaabbd32263d90863cbfbe07659527e11c35a5c738cecdc5/wandb-0.25.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:5eecb3c7b5e60d1acfa4b056bfbaa0b79a482566a9db58c9f99724b3862bc8e5", size = 23287536, upload-time = "2026-02-13T00:17:20.265Z" }, + { url = "https://files.pythonhosted.org/packages/c3/95/31bb7f76a966ec87495e5a72ac7570685be162494c41757ac871768dbc4f/wandb-0.25.0-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:daeedaadb183dc466e634fba90ab2bab1d4e93000912be0dee95065a0624a3fd", size = 25196062, upload-time = "2026-02-13T00:17:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/d9/a1/258cdedbf30cebc692198a774cf0ef945b7ed98ee64bdaf62621281c95d8/wandb-0.25.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:5e0127dbcef13eea48f4b84268da7004d34d3120ebc7b2fa9cefb72b49dbb825", size = 22799744, upload-time = "2026-02-13T00:17:26.437Z" }, + { url = "https://files.pythonhosted.org/packages/de/91/ec9465d014cfd199c5b2083d271d31b3c2aedeae66f3d8a0712f7f54bdf3/wandb-0.25.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6c4c38077836f9b7569a35b0e1dcf1f0c43616fcd936d182f475edbfea063665", size = 25262839, upload-time = "2026-02-13T00:17:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/cb2d1c7143f534544147fb53fe87944508b8cb9a058bc5b6f8a94adbee15/wandb-0.25.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6edd8948d305cb73745bf564b807bd73da2ccbd47c548196b8a362f7df40aed8", size = 22853714, upload-time = "2026-02-13T00:17:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/d7/94/68163f70c1669edcf130822aaaea782d8198b5df74443eca0085ec596774/wandb-0.25.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ada6f08629bb014ad6e0a19d5dec478cdaa116431baa3f0a4bf4ab8d9893611f", size = 25358037, upload-time = "2026-02-13T00:17:34.676Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fb/9578eed2c01b2fc6c8b693da110aa9c73a33d7bb556480f5cfc42e48c94e/wandb-0.25.0-py3-none-win32.whl", hash = "sha256:020b42ca4d76e347709d65f59b30d4623a115edc28f462af1c92681cb17eae7c", size = 24604118, upload-time = "2026-02-13T00:17:37.641Z" }, + { url = "https://files.pythonhosted.org/packages/25/97/460f6cb738aaa39b4eb2e6b4c630b2ae4321cdd70a79d5955ea75a878981/wandb-0.25.0-py3-none-win_amd64.whl", hash = "sha256:78307ac0b328f2dc334c8607bec772851215584b62c439eb320c4af4fb077a00", size = 24604122, upload-time = "2026-02-13T00:17:39.991Z" }, + { url = "https://files.pythonhosted.org/packages/27/6c/5847b4dda1dfd52630dac08711d4348c69ed657f0698fc2d949c7f7a6622/wandb-0.25.0-py3-none-win_arm64.whl", hash = "sha256:c6174401fd6fb726295e98d57b4231c100eca96bd17de51bfc64038a57230aaf", size = 21785298, upload-time = "2026-02-13T00:17:42.475Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, +] + +[[package]] +name = "xxhash" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/84/30869e01909fb37a6cc7e18688ee8bf1e42d57e7e0777636bd47524c43c7/xxhash-3.6.0.tar.gz", hash = "sha256:f0162a78b13a0d7617b2845b90c763339d1f1d82bb04a4b07f4ab535cc5e05d6", size = 85160, upload-time = "2025-10-02T14:37:08.097Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d4/cc2f0400e9154df4b9964249da78ebd72f318e35ccc425e9f403c392f22a/xxhash-3.6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b47bbd8cf2d72797f3c2772eaaac0ded3d3af26481a26d7d7d41dc2d3c46b04a", size = 32844, upload-time = "2025-10-02T14:34:14.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ec/1cc11cd13e26ea8bc3cb4af4eaadd8d46d5014aebb67be3f71fb0b68802a/xxhash-3.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2b6821e94346f96db75abaa6e255706fb06ebd530899ed76d32cd99f20dc52fa", size = 30809, upload-time = "2025-10-02T14:34:15.484Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/19fe357ea348d98ca22f456f75a30ac0916b51c753e1f8b2e0e6fb884cce/xxhash-3.6.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d0a9751f71a1a65ce3584e9cae4467651c7e70c9d31017fa57574583a4540248", size = 194665, upload-time = "2025-10-02T14:34:16.541Z" }, + { url = "https://files.pythonhosted.org/packages/90/3b/d1f1a8f5442a5fd8beedae110c5af7604dc37349a8e16519c13c19a9a2de/xxhash-3.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b29ee68625ab37b04c0b40c3fafdf24d2f75ccd778333cfb698f65f6c463f62", size = 213550, upload-time = "2025-10-02T14:34:17.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ef/3a9b05eb527457d5db13a135a2ae1a26c80fecd624d20f3e8dcc4cb170f3/xxhash-3.6.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6812c25fe0d6c36a46ccb002f40f27ac903bf18af9f6dd8f9669cb4d176ab18f", size = 212384, upload-time = "2025-10-02T14:34:19.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/18/ccc194ee698c6c623acbf0f8c2969811a8a4b6185af5e824cd27b9e4fd3e/xxhash-3.6.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ccbff013972390b51a18ef1255ef5ac125c92dc9143b2d1909f59abc765540e", size = 445749, upload-time = "2025-10-02T14:34:20.659Z" }, + { url = "https://files.pythonhosted.org/packages/a5/86/cf2c0321dc3940a7aa73076f4fd677a0fb3e405cb297ead7d864fd90847e/xxhash-3.6.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:297b7fbf86c82c550e12e8fb71968b3f033d27b874276ba3624ea868c11165a8", size = 193880, upload-time = "2025-10-02T14:34:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/82/fb/96213c8560e6f948a1ecc9a7613f8032b19ee45f747f4fca4eb31bb6d6ed/xxhash-3.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dea26ae1eb293db089798d3973a5fc928a18fdd97cc8801226fae705b02b14b0", size = 210912, upload-time = "2025-10-02T14:34:23.937Z" }, + { url = "https://files.pythonhosted.org/packages/40/aa/4395e669b0606a096d6788f40dbdf2b819d6773aa290c19e6e83cbfc312f/xxhash-3.6.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7a0b169aafb98f4284f73635a8e93f0735f9cbde17bd5ec332480484241aaa77", size = 198654, upload-time = "2025-10-02T14:34:25.644Z" }, + { url = "https://files.pythonhosted.org/packages/67/74/b044fcd6b3d89e9b1b665924d85d3f400636c23590226feb1eb09e1176ce/xxhash-3.6.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:08d45aef063a4531b785cd72de4887766d01dc8f362a515693df349fdb825e0c", size = 210867, upload-time = "2025-10-02T14:34:27.203Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fd/3ce73bf753b08cb19daee1eb14aa0d7fe331f8da9c02dd95316ddfe5275e/xxhash-3.6.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929142361a48ee07f09121fe9e96a84950e8d4df3bb298ca5d88061969f34d7b", size = 414012, upload-time = "2025-10-02T14:34:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b3/5a4241309217c5c876f156b10778f3ab3af7ba7e3259e6d5f5c7d0129eb2/xxhash-3.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:51312c768403d8540487dbbfb557454cfc55589bbde6424456951f7fcd4facb3", size = 191409, upload-time = "2025-10-02T14:34:29.696Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/99bfbc15fb9abb9a72b088c1d95219fc4782b7d01fc835bd5744d66dd0b8/xxhash-3.6.0-cp311-cp311-win32.whl", hash = "sha256:d1927a69feddc24c987b337ce81ac15c4720955b667fe9b588e02254b80446fd", size = 30574, upload-time = "2025-10-02T14:34:31.028Z" }, + { url = "https://files.pythonhosted.org/packages/65/79/9d24d7f53819fe301b231044ea362ce64e86c74f6e8c8e51320de248b3e5/xxhash-3.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:26734cdc2d4ffe449b41d186bbeac416f704a482ed835d375a5c0cb02bc63fef", size = 31481, upload-time = "2025-10-02T14:34:32.062Z" }, + { url = "https://files.pythonhosted.org/packages/30/4e/15cd0e3e8772071344eab2961ce83f6e485111fed8beb491a3f1ce100270/xxhash-3.6.0-cp311-cp311-win_arm64.whl", hash = "sha256:d72f67ef8bf36e05f5b6c65e8524f265bd61071471cd4cf1d36743ebeeeb06b7", size = 27861, upload-time = "2025-10-02T14:34:33.555Z" }, + { url = "https://files.pythonhosted.org/packages/9a/07/d9412f3d7d462347e4511181dea65e47e0d0e16e26fbee2ea86a2aefb657/xxhash-3.6.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:01362c4331775398e7bb34e3ab403bc9ee9f7c497bc7dee6272114055277dd3c", size = 32744, upload-time = "2025-10-02T14:34:34.622Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/0429ee11d035fc33abe32dca1b2b69e8c18d236547b9a9b72c1929189b9a/xxhash-3.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b7b2df81a23f8cb99656378e72501b2cb41b1827c0f5a86f87d6b06b69f9f204", size = 30816, upload-time = "2025-10-02T14:34:36.043Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/57eb99aa0f7d98624c0932c5b9a170e1806406cdbcdb510546634a1359e0/xxhash-3.6.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dc94790144e66b14f67b10ac8ed75b39ca47536bf8800eb7c24b50271ea0c490", size = 194035, upload-time = "2025-10-02T14:34:37.354Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ed/6224ba353690d73af7a3f1c7cdb1fc1b002e38f783cb991ae338e1eb3d79/xxhash-3.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93f107c673bccf0d592cdba077dedaf52fe7f42dcd7676eba1f6d6f0c3efffd2", size = 212914, upload-time = "2025-10-02T14:34:38.6Z" }, + { url = "https://files.pythonhosted.org/packages/38/86/fb6b6130d8dd6b8942cc17ab4d90e223653a89aa32ad2776f8af7064ed13/xxhash-3.6.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aa5ee3444c25b69813663c9f8067dcfaa2e126dc55e8dddf40f4d1c25d7effa", size = 212163, upload-time = "2025-10-02T14:34:39.872Z" }, + { url = "https://files.pythonhosted.org/packages/ee/dc/e84875682b0593e884ad73b2d40767b5790d417bde603cceb6878901d647/xxhash-3.6.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7f99123f0e1194fa59cc69ad46dbae2e07becec5df50a0509a808f90a0f03f0", size = 445411, upload-time = "2025-10-02T14:34:41.569Z" }, + { url = "https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2", size = 193883, upload-time = "2025-10-02T14:34:43.249Z" }, + { url = "https://files.pythonhosted.org/packages/53/5a/ddbb83eee8e28b778eacfc5a85c969673e4023cdeedcfcef61f36731610b/xxhash-3.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd17fede52a17a4f9a7bc4472a5867cb0b160deeb431795c0e4abe158bc784e9", size = 210392, upload-time = "2025-10-02T14:34:45.042Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c2/ff69efd07c8c074ccdf0a4f36fcdd3d27363665bcdf4ba399abebe643465/xxhash-3.6.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6fb5f5476bef678f69db04f2bd1efbed3030d2aba305b0fc1773645f187d6a4e", size = 197898, upload-time = "2025-10-02T14:34:46.302Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/faa05ac19b3b622c7c9317ac3e23954187516298a091eb02c976d0d3dd45/xxhash-3.6.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:843b52f6d88071f87eba1631b684fcb4b2068cd2180a0224122fe4ef011a9374", size = 210655, upload-time = "2025-10-02T14:34:47.571Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7a/06aa7482345480cc0cb597f5c875b11a82c3953f534394f620b0be2f700c/xxhash-3.6.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:7d14a6cfaf03b1b6f5f9790f76880601ccc7896aff7ab9cd8978a939c1eb7e0d", size = 414001, upload-time = "2025-10-02T14:34:49.273Z" }, + { url = "https://files.pythonhosted.org/packages/23/07/63ffb386cd47029aa2916b3d2f454e6cc5b9f5c5ada3790377d5430084e7/xxhash-3.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:418daf3db71e1413cfe211c2f9a528456936645c17f46b5204705581a45390ae", size = 191431, upload-time = "2025-10-02T14:34:50.798Z" }, + { url = "https://files.pythonhosted.org/packages/0f/93/14fde614cadb4ddf5e7cebf8918b7e8fac5ae7861c1875964f17e678205c/xxhash-3.6.0-cp312-cp312-win32.whl", hash = "sha256:50fc255f39428a27299c20e280d6193d8b63b8ef8028995323bf834a026b4fbb", size = 30617, upload-time = "2025-10-02T14:34:51.954Z" }, + { url = "https://files.pythonhosted.org/packages/13/5d/0d125536cbe7565a83d06e43783389ecae0c0f2ed037b48ede185de477c0/xxhash-3.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0f2ab8c715630565ab8991b536ecded9416d615538be8ecddce43ccf26cbc7c", size = 31534, upload-time = "2025-10-02T14:34:53.276Z" }, + { url = "https://files.pythonhosted.org/packages/54/85/6ec269b0952ec7e36ba019125982cf11d91256a778c7c3f98a4c5043d283/xxhash-3.6.0-cp312-cp312-win_arm64.whl", hash = "sha256:eae5c13f3bc455a3bbb68bdc513912dc7356de7e2280363ea235f71f54064829", size = 27876, upload-time = "2025-10-02T14:34:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/33/76/35d05267ac82f53ae9b0e554da7c5e281ee61f3cad44c743f0fcd354f211/xxhash-3.6.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:599e64ba7f67472481ceb6ee80fa3bd828fd61ba59fb11475572cc5ee52b89ec", size = 32738, upload-time = "2025-10-02T14:34:55.839Z" }, + { url = "https://files.pythonhosted.org/packages/31/a8/3fbce1cd96534a95e35d5120637bf29b0d7f5d8fa2f6374e31b4156dd419/xxhash-3.6.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d8b8aaa30fca4f16f0c84a5c8d7ddee0e25250ec2796c973775373257dde8f1", size = 30821, upload-time = "2025-10-02T14:34:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ea/d387530ca7ecfa183cb358027f1833297c6ac6098223fd14f9782cd0015c/xxhash-3.6.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d597acf8506d6e7101a4a44a5e428977a51c0fadbbfd3c39650cca9253f6e5a6", size = 194127, upload-time = "2025-10-02T14:34:59.21Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/71435dcb99874b09a43b8d7c54071e600a7481e42b3e3ce1eb5226a5711a/xxhash-3.6.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:858dc935963a33bc33490128edc1c12b0c14d9c7ebaa4e387a7869ecc4f3e263", size = 212975, upload-time = "2025-10-02T14:35:00.816Z" }, + { url = "https://files.pythonhosted.org/packages/84/7a/c2b3d071e4bb4a90b7057228a99b10d51744878f4a8a6dd643c8bd897620/xxhash-3.6.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba284920194615cb8edf73bf52236ce2e1664ccd4a38fdb543506413529cc546", size = 212241, upload-time = "2025-10-02T14:35:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/640b6eac0128e215f177df99eadcd0f1b7c42c274ab6a394a05059694c5a/xxhash-3.6.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b54219177f6c6674d5378bd862c6aedf64725f70dd29c472eaae154df1a2e89", size = 445471, upload-time = "2025-10-02T14:35:03.61Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1e/3c3d3ef071b051cc3abbe3721ffb8365033a172613c04af2da89d5548a87/xxhash-3.6.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42c36dd7dbad2f5238950c377fcbf6811b1cdb1c444fab447960030cea60504d", size = 193936, upload-time = "2025-10-02T14:35:05.013Z" }, + { url = "https://files.pythonhosted.org/packages/2c/bd/4a5f68381939219abfe1c22a9e3a5854a4f6f6f3c4983a87d255f21f2e5d/xxhash-3.6.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f22927652cba98c44639ffdc7aaf35828dccf679b10b31c4ad72a5b530a18eb7", size = 210440, upload-time = "2025-10-02T14:35:06.239Z" }, + { url = "https://files.pythonhosted.org/packages/eb/37/b80fe3d5cfb9faff01a02121a0f4d565eb7237e9e5fc66e73017e74dcd36/xxhash-3.6.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b45fad44d9c5c119e9c6fbf2e1c656a46dc68e280275007bbfd3d572b21426db", size = 197990, upload-time = "2025-10-02T14:35:07.735Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/2c0a00c97b9e18f72e1f240ad4e8f8a90fd9d408289ba9c7c495ed7dc05c/xxhash-3.6.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6f2580ffab1a8b68ef2b901cde7e55fa8da5e4be0977c68f78fc80f3c143de42", size = 210689, upload-time = "2025-10-02T14:35:09.438Z" }, + { url = "https://files.pythonhosted.org/packages/93/86/5dd8076a926b9a95db3206aba20d89a7fc14dd5aac16e5c4de4b56033140/xxhash-3.6.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40c391dd3cd041ebc3ffe6f2c862f402e306eb571422e0aa918d8070ba31da11", size = 414068, upload-time = "2025-10-02T14:35:11.162Z" }, + { url = "https://files.pythonhosted.org/packages/af/3c/0bb129170ee8f3650f08e993baee550a09593462a5cddd8e44d0011102b1/xxhash-3.6.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f205badabde7aafd1a31e8ca2a3e5a763107a71c397c4481d6a804eb5063d8bd", size = 191495, upload-time = "2025-10-02T14:35:12.971Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3a/6797e0114c21d1725e2577508e24006fd7ff1d8c0c502d3b52e45c1771d8/xxhash-3.6.0-cp313-cp313-win32.whl", hash = "sha256:2577b276e060b73b73a53042ea5bd5203d3e6347ce0d09f98500f418a9fcf799", size = 30620, upload-time = "2025-10-02T14:35:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/86/15/9bc32671e9a38b413a76d24722a2bf8784a132c043063a8f5152d390b0f9/xxhash-3.6.0-cp313-cp313-win_amd64.whl", hash = "sha256:757320d45d2fbcce8f30c42a6b2f47862967aea7bf458b9625b4bbe7ee390392", size = 31542, upload-time = "2025-10-02T14:35:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/39/c5/cc01e4f6188656e56112d6a8e0dfe298a16934b8c47a247236549a3f7695/xxhash-3.6.0-cp313-cp313-win_arm64.whl", hash = "sha256:457b8f85dec5825eed7b69c11ae86834a018b8e3df5e77783c999663da2f96d6", size = 27880, upload-time = "2025-10-02T14:35:16.315Z" }, + { url = "https://files.pythonhosted.org/packages/f3/30/25e5321c8732759e930c555176d37e24ab84365482d257c3b16362235212/xxhash-3.6.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a42e633d75cdad6d625434e3468126c73f13f7584545a9cf34e883aa1710e702", size = 32956, upload-time = "2025-10-02T14:35:17.413Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3c/0573299560d7d9f8ab1838f1efc021a280b5ae5ae2e849034ef3dee18810/xxhash-3.6.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:568a6d743219e717b07b4e03b0a828ce593833e498c3b64752e0f5df6bfe84db", size = 31072, upload-time = "2025-10-02T14:35:18.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1c/52d83a06e417cd9d4137722693424885cc9878249beb3a7c829e74bf7ce9/xxhash-3.6.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bec91b562d8012dae276af8025a55811b875baace6af510412a5e58e3121bc54", size = 196409, upload-time = "2025-10-02T14:35:20.31Z" }, + { url = "https://files.pythonhosted.org/packages/e3/8e/c6d158d12a79bbd0b878f8355432075fc82759e356ab5a111463422a239b/xxhash-3.6.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78e7f2f4c521c30ad5e786fdd6bae89d47a32672a80195467b5de0480aa97b1f", size = 215736, upload-time = "2025-10-02T14:35:21.616Z" }, + { url = "https://files.pythonhosted.org/packages/bc/68/c4c80614716345d55071a396cf03d06e34b5f4917a467faf43083c995155/xxhash-3.6.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ed0df1b11a79856df5ffcab572cbd6b9627034c1c748c5566fa79df9048a7c5", size = 214833, upload-time = "2025-10-02T14:35:23.32Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e9/ae27c8ffec8b953efa84c7c4a6c6802c263d587b9fc0d6e7cea64e08c3af/xxhash-3.6.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0e4edbfc7d420925b0dd5e792478ed393d6e75ff8fc219a6546fb446b6a417b1", size = 448348, upload-time = "2025-10-02T14:35:25.111Z" }, + { url = "https://files.pythonhosted.org/packages/d7/6b/33e21afb1b5b3f46b74b6bd1913639066af218d704cc0941404ca717fc57/xxhash-3.6.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fba27a198363a7ef87f8c0f6b171ec36b674fe9053742c58dd7e3201c1ab30ee", size = 196070, upload-time = "2025-10-02T14:35:26.586Z" }, + { url = "https://files.pythonhosted.org/packages/96/b6/fcabd337bc5fa624e7203aa0fa7d0c49eed22f72e93229431752bddc83d9/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:794fe9145fe60191c6532fa95063765529770edcdd67b3d537793e8004cabbfd", size = 212907, upload-time = "2025-10-02T14:35:28.087Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d3/9ee6160e644d660fcf176c5825e61411c7f62648728f69c79ba237250143/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:6105ef7e62b5ac73a837778efc331a591d8442f8ef5c7e102376506cb4ae2729", size = 200839, upload-time = "2025-10-02T14:35:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/0d/98/e8de5baa5109394baf5118f5e72ab21a86387c4f89b0e77ef3e2f6b0327b/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f01375c0e55395b814a679b3eea205db7919ac2af213f4a6682e01220e5fe292", size = 213304, upload-time = "2025-10-02T14:35:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1d/71056535dec5c3177eeb53e38e3d367dd1d16e024e63b1cee208d572a033/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d706dca2d24d834a4661619dcacf51a75c16d65985718d6a7d73c1eeeb903ddf", size = 416930, upload-time = "2025-10-02T14:35:32.517Z" }, + { url = "https://files.pythonhosted.org/packages/dc/6c/5cbde9de2cd967c322e651c65c543700b19e7ae3e0aae8ece3469bf9683d/xxhash-3.6.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5f059d9faeacd49c0215d66f4056e1326c80503f51a1532ca336a385edadd033", size = 193787, upload-time = "2025-10-02T14:35:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/0172e350361d61febcea941b0cc541d6e6c8d65d153e85f850a7b256ff8a/xxhash-3.6.0-cp313-cp313t-win32.whl", hash = "sha256:1244460adc3a9be84731d72b8e80625788e5815b68da3da8b83f78115a40a7ec", size = 30916, upload-time = "2025-10-02T14:35:35.107Z" }, + { url = "https://files.pythonhosted.org/packages/ad/e6/e8cf858a2b19d6d45820f072eff1bea413910592ff17157cabc5f1227a16/xxhash-3.6.0-cp313-cp313t-win_amd64.whl", hash = "sha256:b1e420ef35c503869c4064f4a2f2b08ad6431ab7b229a05cce39d74268bca6b8", size = 31799, upload-time = "2025-10-02T14:35:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/56/15/064b197e855bfb7b343210e82490ae672f8bc7cdf3ddb02e92f64304ee8a/xxhash-3.6.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ec44b73a4220623235f67a996c862049f375df3b1052d9899f40a6382c32d746", size = 28044, upload-time = "2025-10-02T14:35:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/7e/5e/0138bc4484ea9b897864d59fce9be9086030825bc778b76cb5a33a906d37/xxhash-3.6.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:a40a3d35b204b7cc7643cbcf8c9976d818cb47befcfac8bbefec8038ac363f3e", size = 32754, upload-time = "2025-10-02T14:35:38.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/d7/5dac2eb2ec75fd771957a13e5dda560efb2176d5203f39502a5fc571f899/xxhash-3.6.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a54844be970d3fc22630b32d515e79a90d0a3ddb2644d8d7402e3c4c8da61405", size = 30846, upload-time = "2025-10-02T14:35:39.6Z" }, + { url = "https://files.pythonhosted.org/packages/fe/71/8bc5be2bb00deb5682e92e8da955ebe5fa982da13a69da5a40a4c8db12fb/xxhash-3.6.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:016e9190af8f0a4e3741343777710e3d5717427f175adfdc3e72508f59e2a7f3", size = 194343, upload-time = "2025-10-02T14:35:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/52badfb2aecec2c377ddf1ae75f55db3ba2d321c5e164f14461c90837ef3/xxhash-3.6.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f6f72232f849eb9d0141e2ebe2677ece15adfd0fa599bc058aad83c714bb2c6", size = 213074, upload-time = "2025-10-02T14:35:42.29Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2b/ae46b4e9b92e537fa30d03dbc19cdae57ed407e9c26d163895e968e3de85/xxhash-3.6.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63275a8aba7865e44b1813d2177e0f5ea7eadad3dd063a21f7cf9afdc7054063", size = 212388, upload-time = "2025-10-02T14:35:43.929Z" }, + { url = "https://files.pythonhosted.org/packages/f5/80/49f88d3afc724b4ac7fbd664c8452d6db51b49915be48c6982659e0e7942/xxhash-3.6.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cd01fa2aa00d8b017c97eb46b9a794fbdca53fc14f845f5a328c71254b0abb7", size = 445614, upload-time = "2025-10-02T14:35:45.216Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ba/603ce3961e339413543d8cd44f21f2c80e2a7c5cfe692a7b1f2cccf58f3c/xxhash-3.6.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0226aa89035b62b6a86d3c68df4d7c1f47a342b8683da2b60cedcddb46c4d95b", size = 194024, upload-time = "2025-10-02T14:35:46.959Z" }, + { url = "https://files.pythonhosted.org/packages/78/d1/8e225ff7113bf81545cfdcd79eef124a7b7064a0bba53605ff39590b95c2/xxhash-3.6.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c6e193e9f56e4ca4923c61238cdaced324f0feac782544eb4c6d55ad5cc99ddd", size = 210541, upload-time = "2025-10-02T14:35:48.301Z" }, + { url = "https://files.pythonhosted.org/packages/6f/58/0f89d149f0bad89def1a8dd38feb50ccdeb643d9797ec84707091d4cb494/xxhash-3.6.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9176dcaddf4ca963d4deb93866d739a343c01c969231dbe21680e13a5d1a5bf0", size = 198305, upload-time = "2025-10-02T14:35:49.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/38/5eab81580703c4df93feb5f32ff8fa7fe1e2c51c1f183ee4e48d4bb9d3d7/xxhash-3.6.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c1ce4009c97a752e682b897aa99aef84191077a9433eb237774689f14f8ec152", size = 210848, upload-time = "2025-10-02T14:35:50.877Z" }, + { url = "https://files.pythonhosted.org/packages/5e/6b/953dc4b05c3ce678abca756416e4c130d2382f877a9c30a20d08ee6a77c0/xxhash-3.6.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:8cb2f4f679b01513b7adbb9b1b2f0f9cdc31b70007eaf9d59d0878809f385b11", size = 414142, upload-time = "2025-10-02T14:35:52.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/a9/238ec0d4e81a10eb5026d4a6972677cbc898ba6c8b9dbaec12ae001b1b35/xxhash-3.6.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:653a91d7c2ab54a92c19ccf43508b6a555440b9be1bc8be553376778be7f20b5", size = 191547, upload-time = "2025-10-02T14:35:53.547Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ee/3cf8589e06c2164ac77c3bf0aa127012801128f1feebf2a079272da5737c/xxhash-3.6.0-cp314-cp314-win32.whl", hash = "sha256:a756fe893389483ee8c394d06b5ab765d96e68fbbfe6fde7aa17e11f5720559f", size = 31214, upload-time = "2025-10-02T14:35:54.746Z" }, + { url = "https://files.pythonhosted.org/packages/02/5d/a19552fbc6ad4cb54ff953c3908bbc095f4a921bc569433d791f755186f1/xxhash-3.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:39be8e4e142550ef69629c9cd71b88c90e9a5db703fecbcf265546d9536ca4ad", size = 32290, upload-time = "2025-10-02T14:35:55.791Z" }, + { url = "https://files.pythonhosted.org/packages/b1/11/dafa0643bc30442c887b55baf8e73353a344ee89c1901b5a5c54a6c17d39/xxhash-3.6.0-cp314-cp314-win_arm64.whl", hash = "sha256:25915e6000338999236f1eb68a02a32c3275ac338628a7eaa5a269c401995679", size = 28795, upload-time = "2025-10-02T14:35:57.162Z" }, + { url = "https://files.pythonhosted.org/packages/2c/db/0e99732ed7f64182aef4a6fb145e1a295558deec2a746265dcdec12d191e/xxhash-3.6.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c5294f596a9017ca5a3e3f8884c00b91ab2ad2933cf288f4923c3fd4346cf3d4", size = 32955, upload-time = "2025-10-02T14:35:58.267Z" }, + { url = "https://files.pythonhosted.org/packages/55/f4/2a7c3c68e564a099becfa44bb3d398810cc0ff6749b0d3cb8ccb93f23c14/xxhash-3.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1cf9dcc4ab9cff01dfbba78544297a3a01dafd60f3bde4e2bfd016cf7e4ddc67", size = 31072, upload-time = "2025-10-02T14:35:59.382Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d9/72a29cddc7250e8a5819dad5d466facb5dc4c802ce120645630149127e73/xxhash-3.6.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01262da8798422d0685f7cef03b2bd3f4f46511b02830861df548d7def4402ad", size = 196579, upload-time = "2025-10-02T14:36:00.838Z" }, + { url = "https://files.pythonhosted.org/packages/63/93/b21590e1e381040e2ca305a884d89e1c345b347404f7780f07f2cdd47ef4/xxhash-3.6.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51a73fb7cb3a3ead9f7a8b583ffd9b8038e277cdb8cb87cf890e88b3456afa0b", size = 215854, upload-time = "2025-10-02T14:36:02.207Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b8/edab8a7d4fa14e924b29be877d54155dcbd8b80be85ea00d2be3413a9ed4/xxhash-3.6.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b9c6df83594f7df8f7f708ce5ebeacfc69f72c9fbaaababf6cf4758eaada0c9b", size = 214965, upload-time = "2025-10-02T14:36:03.507Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/dfa980ac7f0d509d54ea0d5a486d2bb4b80c3f1bb22b66e6a05d3efaf6c0/xxhash-3.6.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:627f0af069b0ea56f312fd5189001c24578868643203bca1abbc2c52d3a6f3ca", size = 448484, upload-time = "2025-10-02T14:36:04.828Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/8ffc2cc97e811c0ca5d00ab36604b3ea6f4254f20b7bc658ca825ce6c954/xxhash-3.6.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa912c62f842dfd013c5f21a642c9c10cd9f4c4e943e0af83618b4a404d9091a", size = 196162, upload-time = "2025-10-02T14:36:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/07f0e7a3edd11a6097e990f6e5b815b6592459cb16dae990d967693e6ea9/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b465afd7909db30168ab62afe40b2fcf79eedc0b89a6c0ab3123515dc0df8b99", size = 213007, upload-time = "2025-10-02T14:36:07.733Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d8/bc5fa0d152837117eb0bef6f83f956c509332ce133c91c63ce07ee7c4873/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a881851cf38b0a70e7c4d3ce81fc7afd86fbc2a024f4cfb2a97cf49ce04b75d3", size = 200956, upload-time = "2025-10-02T14:36:09.106Z" }, + { url = "https://files.pythonhosted.org/packages/26/a5/d749334130de9411783873e9b98ecc46688dad5db64ca6e04b02acc8b473/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9b3222c686a919a0f3253cfc12bb118b8b103506612253b5baeaac10d8027cf6", size = 213401, upload-time = "2025-10-02T14:36:10.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/72/abed959c956a4bfc72b58c0384bb7940663c678127538634d896b1195c10/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:c5aa639bc113e9286137cec8fadc20e9cd732b2cc385c0b7fa673b84fc1f2a93", size = 417083, upload-time = "2025-10-02T14:36:12.276Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/62fd2b586283b7d7d665fb98e266decadf31f058f1cf6c478741f68af0cb/xxhash-3.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5c1343d49ac102799905e115aee590183c3921d475356cb24b4de29a4bc56518", size = 193913, upload-time = "2025-10-02T14:36:14.025Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/c19c42c5b3f5a4aad748a6d5b4f23df3bed7ee5445accc65a0fb3ff03953/xxhash-3.6.0-cp314-cp314t-win32.whl", hash = "sha256:5851f033c3030dd95c086b4a36a2683c2ff4a799b23af60977188b057e467119", size = 31586, upload-time = "2025-10-02T14:36:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/4cc450345be9924fd5dc8c590ceda1db5b43a0a889587b0ae81a95511360/xxhash-3.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0444e7967dac37569052d2409b00a8860c2135cff05502df4da80267d384849f", size = 32526, upload-time = "2025-10-02T14:36:16.708Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/8aec23647a34a249f62e2398c42955acd9b4c6ed5cf08cbea94dc46f78d2/xxhash-3.6.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0f7b7e2ec26c1666ad5fc9dbfa426a6a3367ceaf79db5dd76264659d509d73b0", size = 30662, upload-time = "2025-10-02T14:37:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/b14510b38ba91caf43006209db846a696ceea6a847a0c9ba0a5b1adc53d6/xxhash-3.6.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5dc1e14d14fa0f5789ec29a7062004b5933964bb9b02aae6622b8f530dc40296", size = 41056, upload-time = "2025-10-02T14:37:02.879Z" }, + { url = "https://files.pythonhosted.org/packages/50/55/15a7b8a56590e66ccd374bbfa3f9ffc45b810886c8c3b614e3f90bd2367c/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:881b47fc47e051b37d94d13e7455131054b56749b91b508b0907eb07900d1c13", size = 36251, upload-time = "2025-10-02T14:37:04.44Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/5ac99a041a29e58e95f907876b04f7067a0242cb85b5f39e726153981503/xxhash-3.6.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6dc31591899f5e5666f04cc2e529e69b4072827085c1ef15294d91a004bc1bd", size = 32481, upload-time = "2025-10-02T14:37:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/8d95e906764a386a3d3b596f3c68bb63687dfca806373509f51ce8eea81f/xxhash-3.6.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:15e0dac10eb9309508bfc41f7f9deaa7755c69e35af835db9cb10751adebc35d", size = 31565, upload-time = "2025-10-02T14:37:06.966Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] diff --git a/geom/explicit_h/training_config.yaml b/geom/explicit_h/training_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..177f7b33d0d2662100b2bd1512e2b65b7f6b3de2 --- /dev/null +++ b/geom/explicit_h/training_config.yaml @@ -0,0 +1,414 @@ +representation: molecule +seed: 42 +heads: + heads: + _target_: morph.model.layers.heads.MultiHeadModule + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + heads_configs: + graph_aggregation: mean + node_heads: + atom_type_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.atom_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + charge_head: + input_dim: ${model.head_node_dim} + output_dim: ${len:${data.vocab.charge_tokens}} + adapter_dim: ${model.head_node_adapter_dim} + group: state + ins_rate_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_rate: ${model.head_initialization.ins_rate} + do_del_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_del_probability} + do_sub_a_head: + input_dim: ${model.head_node_dim} + output_dim: 1 + adapter_dim: ${model.head_node_adapter_dim} + group: edit + initial_probability: ${model.head_initialization.do_sub_a_probability} + edge_heads: + edge_type_head: + input_dim: ${model.head_edge_dim} + output_dim: ${len:${data.vocab.edge_tokens}} + adapter_dim: ${model.head_edge_adapter_dim} + group: state + do_sub_e_head: + input_dim: ${model.head_edge_dim} + output_dim: 1 + adapter_dim: ${model.head_edge_adapter_dim} + group: state + initial_probability: ${model.head_initialization.do_sub_e_probability} + ins_edge_head: + _target_: morph.model.layers.heads.InsertionEdgeHead + _recursive_: false + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_edge_dim} + output_init_std: ${model.head_initialization.output_std} + n_edge_types: ${len:${data.vocab.edge_tokens}} + n_atom_types: ${len:${data.vocab.atom_tokens}} + n_charge_types: ${len:${data.vocab.charge_tokens}} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false +insertion_head: + mode: unimodal + dimension: 3 + num_components: 1 + model: + _target_: morph.model.layers.heads.UnimodalInsertionHead + hidden_dim: ${model.head_node_dim} + readout_dim: ${model.head_insertion_adapter_dim} + output_init_std: ${model.head_initialization.output_std} + independent_adapters: ${oc.select:model.head_independent_adapters,false} + N_a: ${len:${data.vocab.atom_tokens}} + N_c: ${len:${data.vocab.charge_tokens}} +model: + module: + optimizer_config: + implementation: torch + optimizer: + execution: ${oc.env:MORPH_MUON_EXECUTION,native} + lr: 0.01 + momentum: 0.95 + weight_decay: 0.0 + nesterov: true + ns_steps: 5 + eps: 1.0e-07 + adjust_lr_fn: original + aux_optimizer: + lr: 0.0003 + betas: + - 0.9 + - 0.95 + eps: 1.0e-10 + weight_decay: 0.0 + gradient_clip_val: 1.0 + gradient_clip_algorithm: norm + scheduler: + _target_: morph.utils.lr_schedulers.MultiGroupCosineWarmupLR + warmup_steps: 2000 + total_steps: 200000 + min_lr_fraction: 0.05 + last_epoch: -1 + interval: step + monitor: null + loss_weights: + do_sub_a: 0.2 + sub_a_class: 0.03 + do_sub_e: 0.25 + sub_e_class: 0.5 + do_del: 0.4 + ins_rate: 0.15 + ins_state: 0.01 + ins_e: 0.4 + x: 2.0 + c: 0.5 + ins_e_ii: 0.03 + _target_: morph.model.lightning.base.LightningModuleRates + insertion_head_mode: ${insertion_head.mode} + ins_noise_scale: ${data.ins_noise_scale} + ema_decay: 0.999 + ema_warmup_steps: 5000 + use_ema_for_eval: true + time_dist: ${if:${eq:${model.sample_time_strategy}, log}, ${model.beta_sched}, + ${model.linear_sched}} + integrator: ${model.integrator} + vocab: ${data.vocab} + use_learnable_loss_weights: false + use_time_weights: false + model: + _target_: morph.model.backbones.dit.DiTBackboneWithHeads + _recursive_: false + atom_type_embedding_args: ${embeddings.atom_type_embedding} + edge_type_embedding_args: ${embeddings.edge_type_embedding} + time_embedding_args: ${embeddings.time_embedding} + node_count_embedding_args: ${embeddings.node_count_embedding} + incident_bond_embedding: false + backbone_model_args: ${model.backbone} + heads_args: ${heads.heads} + insertion_head_args: ${insertion_head.model} + ins_edge_head_args: ${heads.ins_edge_head} + rotate: true + n_augmentations: 1 + integrator: ${integrator.integrator} + beta_sched: + _target_: torch.distributions.Beta + concentration1: 2.0 + concentration0: 1.0 + linear_sched: + _target_: torch.distributions.Uniform + low: 0.0 + high: 1.0 + sample_time_strategy: linear + head_node_dim: ${model.backbone.out_node_nf} + head_edge_dim: ${model.backbone.out_edge_nf} + head_node_adapter_dim: 256 + head_edge_adapter_dim: 256 + head_insertion_adapter_dim: 256 + head_insertion_edge_dim: 256 + head_independent_adapters: true + head_initialization: + output_std: 0.001 + coordinate_output_std: 0.0 + do_del_probability: 0.5 + do_sub_a_probability: 0.5 + do_sub_e_probability: 0.5 + ins_rate: 0.6931471805599453 + token_weighting: + type_loss_token_weights: uniform + weight_alpha: 0.4 + lora: + enabled: false + rank: 8 + alpha: 8.0 + dropout: 0.0 + targets: + - qkv + - proj + - mlp.gate_value + - mlp.out_proj + freeze_base: true + backbone: + _target_: morph.model.backbones.dit.DiTBackbone + _recursive_: false + d_input: ${embeddings.atom_type_embedding.out_dim} + d_cond: ${oc.eval:"${embeddings.time_embedding.out_dim} + ${embeddings.node_count_embedding.out_dim}"} + d_model: 512 + out_node_nf: 256 + out_edge_nf: 256 + num_layers: 8 + nhead: 16 + mlp_ratio: 4.0 + ffn_hidden_dim: null + ffn_multiple_of: 64 + qk_norm: true + coordinate_output_init_std: ${model.head_initialization.coordinate_output_std} + center_coordinate_output: true + in_edge_nf: ${embeddings.edge_type_embedding.out_dim} + edge_pair_dim: 256 + pair_bias_hidden_dim: 32 + num_atom_types: ${len:${data.vocab.atom_tokens}} + lora: ${model.lora} + rbf_embedding_args: + _target_: morph.model.layers.embedding.RBFEmbedding + num_rbf: 128 + rbf_dmax: 3.5 + out_dim: 64 + dropout: 0.0 + trainable: false +schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 0.8 +trainer: + trainer: + deterministic: warn + devices: 2 + accelerator: auto + strategy: ddp_find_unused_parameters_false + precision: bf16-mixed + max_epochs: -1 + accumulate_grad_batches: 1 + num_sanity_val_steps: 0 + gradient_clip_val: null + gradient_clip_algorithm: norm + check_val_every_n_epoch: 1 + num_val_samples: 4096 + limit_val_batches: null + limit_predict_batches: 4 + max_time: + hours: 47 + minutes: 15 + do_train: true + do_validate: false + do_predict: false + checkpoint_path: /cluster/project/krause/frankem/morph-chem/outputs/geom_resume_e48_20261008/resume_epoch_048.ckpt + final_checkpoint_path: final.ckpt +callbacks: + monitor_metric: ${if:${oc.eval:"'${representation}' in ('geometric_graph', 'molecule')"},val/validity,val/batch/pair_dist_l1} + monitor_metric_mode: ${if:${oc.eval:"'${representation}' in ('geometric_graph', + 'molecule')"},max,min} + model_checkpoints: + save_top_k: 1 + save_last: false + verbose: false + every_n_epochs: 5 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link + verbose: false + save_on_train_epoch_end: true +logging: + enabled: true + project: morph-${data.dataset_name} + name: ${oc.env:MORPH_RUN_ID} + save_dir: . + offline: false +integrator: + integrator: + _target_: morph.flow_matching.integration.RateIntegrator + vocab: ${data.vocab} + distributions: None + insertion_head_mode: ${insertion_head.mode} + insertion_dimension: ${insertion_head.dimension} + insertion_num_components: ${insertion_head.num_components} + time_strategy: log + time_beta_alpha: 2.0 + time_beta_beta: 1.0 + num_integration_steps: 100 + ins_noise_scale: ${data.ins_noise_scale} + renoise_insertions: true + exact_schedule_integration: true + del_schedule: ${data.interpolator.del_schedule} + ins_schedule: ${data.interpolator.ins_schedule} + sub_schedule: ${data.interpolator.sub_schedule} + sub_e_schedule: ${data.interpolator.sub_e_schedule} +data: + root_path: /cluster/project/krause/frankem/morph-chem/data + preprocess_workers: 10 + preprocess_chunk_size: 128 + statistics_workers: ${data.preprocess_workers} + hydrogen_mode: explicit + cache_name: ${data.dataset_name}${if:${eq:${data.hydrogen_mode},implicit},_implicit_h,''} + source_dir: ${oc.env:GEOM_SOURCE_DIR,null} + max_atoms: 72 + allowed_atom_symbols: + - H + - C + - 'N' + - O + - S + - F + - Cl + - Br + - P + - I + n_atoms_strategy: uniform + uniform_n_atoms_padding: 5 + optimal_transport: equivariant + ins_noise_scale: 0.65 + move_noise_scale: 0.5 + allow_charged: true + preprocessing: + _target_: morph.dataset.core.preprocessing.Preprocessing + _recursive_: false + root: /cluster/project/krause/frankem/morph/data/geom + num_workers: ${data.statistics_workers} + train_dataset: ${data.datamodule.datasets.train} + atom_tokens_path: ${data.preprocessing.root}/processed/atom_tokens.txt + edge_tokens_path: ${data.preprocessing.root}/processed/edge_tokens.txt + charge_tokens_path: ${data.preprocessing.root}/processed/charge_tokens.txt + distributions_path: ${data.preprocessing.root}/processed/distributions.pt + vocab: null + interpolator: + _target_: morph.flow_matching.interpolation.Interpolator + distributions: null + optimal_transport: ${data.optimal_transport} + ins_noise_scale: ${data.ins_noise_scale} + move_noise_scale: ${data.move_noise_scale} + atom_type_noise_prob: 0.0 + pre_align_iters: 2 + c_move: 1.0 + c_sub: 0.0 + c_ins: 6.125 + c_del: 6.125 + del_schedule: ${schedule} + ins_schedule: ${schedule} + sub_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + sub_e_schedule: + _target_: morph.flow_matching.schedules.SmoothstepSchedule + shift: 1.5 + datamodule: + _target_: morph.dataset.core.lightning_datamodule.LightningDataModule + vocab: ${data.vocab} + distributions: null + representation: ${representation} + datasets: + train: + _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + val: + - _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + test: + - _target_: ${data.dataset_target} + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + preprocess_chunk_size: ${data.preprocess_chunk_size} + hydrogen_mode: ${data.hydrogen_mode} + source_dir: ${data.source_dir} + max_atoms: ${data.max_atoms} + allowed_atom_symbols: ${data.allowed_atom_symbols} + vocab: ${data.vocab} + distributions: null + interpolator: ${data.interpolator} + rotate: ${model.rotate} + n_augmentations: ${model.n_augmentations} + num_workers: + train: 20 + val: 0 + test: 0 + batch_size: + train: 320 + val: 32 + test: 32 + n_atoms_strategy: ${data.n_atoms_strategy} + uniform_n_atoms_padding: ${data.uniform_n_atoms_padding} + time_dist: ${model.module.time_dist} + train_prefetch_factor: 1 + dataset_name: geom + dataset_target: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset +embeddings: + atom_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.atom_tokens}} + embedding_dim: 64 + out_dim: 128 + edge_type_embedding: + _target_: morph.model.layers.embedding.Embedding + num_embeddings: ${len:${data.vocab.edge_tokens}} + embedding_dim: 64 + out_dim: 128 + time_embedding: + _target_: morph.model.layers.embedding.TimeEmbedding + embedding_dim: 128 + out_dim: 128 + node_count_embedding: + _target_: morph.model.layers.embedding.ExtrapolatableCountEmbedding + embedding_dim: 64 + out_dim: 128 + min_period: 128.0 + max_period: 1024.0 + max_count: 96.0 diff --git a/geom/explicit_h/validation.json b/geom/explicit_h/validation.json new file mode 100644 index 0000000000000000000000000000000000000000..a4b0e4bb935962373fe29143c0c1753197aab298 --- /dev/null +++ b/geom/explicit_h/validation.json @@ -0,0 +1,17 @@ +{ + "dataset_access": false, + "completed_epochs": 50, + "global_step": 441050, + "all_checkpoint_weights_finite": true, + "strict_load": { + "current": true, + "ema": true + }, + "sampling_smoke": { + "attempts": 1, + "integration_steps": 100, + "validity": [ + true + ] + } +}