diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5e4c1d31aab87791a01412945f637acec850e676 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +--- +library_name: pytorch +tags: + - chemistry + - molecular-generation + - flow-matching + - chembl3d +--- + +# Morph ChEMBL3D checkpoints + +Six checkpoints for unconditional 3D molecular generation, with matching +vocabularies, training distributions, portable inference configurations and +the frozen Morph runtime from each training run. + +| Variant | Completed training epochs | Directory | +| --- | --- | --- | +| Explicit hydrogens | 200, 300, 400 | [chembl3d/explicit_h](chembl3d/explicit_h) | +| Implicit hydrogens (heavy-atom graphs) | 100, 200, 300 | [chembl3d/implicit_h](chembl3d/implicit_h) | + +The `epoch_200.ckpt` name means **200 completed epochs**. The original Lightning +checkpoint stores `epoch=199`. Files are byte-for-byte copies of the selected +training checkpoints, including current weights, EMA weights, and optimizer +state. Sampling uses EMA weights by default. + +Each variant contains: + +- `epoch_NNN.ckpt`: the three selected checkpoints. +- `atom_tokens.txt`, `edge_tokens.txt`, `charge_tokens.txt`: ordered vocabularies. +- `distributions.pt`: the original training statistics, including atom counts, + type frequencies, coordinate scale, and geometry histograms. +- `distributions.json`: a readable export of the same statistics. +- `config.yaml`: portable sampling config with dataset loading disabled. +- `training_config.yaml`: the original Hydra training configuration, for provenance. +- `runtime/`: frozen source/configs and dependency specifications for this run. +- `metadata.json`: epochs, optimizer steps, original filenames, and SHA-256 hashes. +- `validation.json`: strict checkpoint loading, current/EMA forward checks and + a one-molecule end-to-end sampling check. This is a functionality check, not + a validity benchmark. + +## Download and generate + +Download the variant you need (authenticate with `hf auth login` if required): + +```bash +hf download maltefranke/morph --local-dir morph-models \ + --include 'chembl3d/implicit_h/*' --include sample.py --include README.md --include manifest.json +cd morph-models +``` + +Install that variant's runtime in an environment with Python >=3.11 and a +compatible PyTorch 2.9 installation. A CUDA-capable PyTorch build is needed to +use `--device cuda`. The tested package versions are recorded in +`runtime/requirements-tested.txt`; the runtime's `pyproject.toml` specifies the +dependency constraints. + +```bash +python -m pip install ./chembl3d/implicit_h/runtime +python sample.py --variant implicit_h --epoch 300 \ + --num-mols 64 --batch-size 16 --device cuda --output implicit_h.sdf +``` + +For explicit hydrogens, download `chembl3d/explicit_h/*` instead, install its +runtime, and run: + +```bash +python sample.py --variant explicit_h --epoch 400 \ + --num-mols 64 --batch-size 16 --device cuda --output explicit_h.sdf +``` + +The script imports the matching bundled source automatically. Each invocation +uses one variant in a fresh process. No ChEMBL3D dataset or cluster paths are +needed. `--device cpu` is supported, but use a compute worker for substantial +sampling. Compilation is disabled by default; attention uses PyTorch SDPA. + +Generated coordinates are in Angstrom. The SDF contains valid molecules; the +adjacent JSON records every attempted molecule and its validity result, including +failures. Generation uses 100 integration steps and seed 42 by default. + +## Hydrogen handling and training data + +Both variants use the same split membership: 1,474,008 training, 184,295 validation, +and 184,242 test molecules. The dataset was filtered to **at most 100 total atoms, +including hydrogens**, before removing hydrogens for the implicit variant. + +The explicit variant predicts H atoms as graph vertices. The implicit variant +predicts heavy atoms, bonds and formal charges; RDKit infers hydrogens during +conversion and validity checking. Its bundled runtime preserves that behavior. +Do not mix vocabularies or distributions between variants. Geometry statistics +and coordinate normalization were recomputed for the heavy-atom representation. + +The 100-atom cap describes the training filter, not a hard limit imposed on +generated molecules. Sample counts follow the saved training prior settings. + +`manifest.json` lists file sizes and SHA-256 hashes. The original training configs +contain historical cluster paths and should be treated as provenance; use +`sample.py` with the portable `config.yaml` for inference. diff --git a/chembl3d/explicit_h/atom_tokens.txt b/chembl3d/explicit_h/atom_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..2c347a732f33cbb0e312387b0b4f2006a21c4fa1 --- /dev/null +++ b/chembl3d/explicit_h/atom_tokens.txt @@ -0,0 +1,13 @@ +B +Br +C +Cl +F +H +I +N +O +P +S +Se +Si diff --git a/chembl3d/explicit_h/charge_tokens.txt b/chembl3d/explicit_h/charge_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..6f858bd7282478c53d831363fa8d1815d57ce7dc --- /dev/null +++ b/chembl3d/explicit_h/charge_tokens.txt @@ -0,0 +1,3 @@ +-1 +0 +1 diff --git a/chembl3d/explicit_h/config.yaml b/chembl3d/explicit_h/config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2d9545ce360f07daf7a3757af5bbe0c77ffd5d03 --- /dev/null +++ b/chembl3d/explicit_h/config.yaml @@ -0,0 +1,329 @@ +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: 5 + 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: final.ckpt +callbacks: {} +logging: + enabled: false + project: morph-${data.dataset_name} + name: chembl3d-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: + dataset_name: chembl3d + root_path: . + preprocess_workers: 0 + 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: . + 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 + num_workers: 0 + vocab: null + 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} + 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 + source_dir: null + max_total_atoms: 100 +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/chembl3d/explicit_h/distributions.json b/chembl3d/explicit_h/distributions.json new file mode 100644 index 0000000000000000000000000000000000000000..dc705da27099bce3e182530720276d8d38faef46 --- /dev/null +++ b/chembl3d/explicit_h/distributions.json @@ -0,0 +1,25930 @@ +{ + "atom_type_distribution": [ + 2.854285776265897e-05, + 0.0009752951445989311, + 0.40991389751434326, + 0.004494897089898586, + 0.008664441294968128, + 0.44397425651550293, + 8.206591155612841e-05, + 0.06739044934511185, + 0.056073516607284546, + 0.0001633278006920591, + 0.008215019479393959, + 7.3609799073892646e-06, + 1.696768231340684e-05 + ], + "edge_type_distribution": [ + 0.9591846466064453, + 0.03419552743434906, + 0.006565819028764963, + 5.397911081672646e-05 + ], + "charge_type_distribution": [ + 0.0024685619864612818, + 0.9920029044151306, + 0.005528567358851433 + ], + "n_atoms_distribution": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4.748956598632503e-06, + 1.4925291907275096e-05, + 3.459953950368799e-05, + 5.7665900385472924e-05, + 0.00011668865045066923, + 0.00016485663945786655, + 0.00028154527535662055, + 0.00040908867958933115, + 0.0005434163031168282, + 0.000729304039850831, + 0.0009877829579636455, + 0.0012238739291206002, + 0.001612609950825572, + 0.002019663341343403, + 0.002476241672411561, + 0.0030630771070718765, + 0.0038134120404720306, + 0.004666189197450876, + 0.005913808010518551, + 0.006944331340491772, + 0.008516914211213589, + 0.009594249539077282, + 0.011263168416917324, + 0.012805222533643246, + 0.014590829610824585, + 0.016124064102768898, + 0.017809944227337837, + 0.019706813618540764, + 0.021307889372110367, + 0.022756999358534813, + 0.024827545508742332, + 0.02594354934990406, + 0.027485603466629982, + 0.02858871780335903, + 0.02994624152779579, + 0.031030360609292984, + 0.03222506120800972, + 0.03233428671956062, + 0.03235803171992302, + 0.032707419246435165, + 0.03236685320734978, + 0.03156156465411186, + 0.030555464327335358, + 0.02931870147585869, + 0.02827732264995575, + 0.027218984439969063, + 0.026047348976135254, + 0.024683719500899315, + 0.02373935468494892, + 0.02231331169605255, + 0.020867593586444855, + 0.019456475973129272, + 0.018219711259007454, + 0.016735322773456573, + 0.015883224084973335, + 0.014192596077919006, + 0.01316410768777132, + 0.012007397599518299, + 0.010929384268820286, + 0.009763176552951336, + 0.009050833061337471, + 0.008174989372491837, + 0.007456540130078793, + 0.006544740404933691, + 0.006025747396051884, + 0.005196036770939827, + 0.004743529018014669, + 0.004149909596890211, + 0.00381612591445446, + 0.003409750759601593, + 0.003054257482290268, + 0.002665521577000618, + 0.0025990360882133245, + 0.0022571112494915724, + 0.001959283836185932, + 0.0017862861277535558, + 0.0015909004723653197, + 0.0013263156870380044, + 0.0012896809494122863, + 0.0010888678953051567, + 0.0010278099216520786, + 0.0009246896952390671, + 0.0008107147295959294, + 0.0007211629999801517, + 0.0006567128584720194, + 0.0005963332368992269, + 0.0005420594825409353, + 0.0004511508741416037, + 0.00044843717478215694, + 0.00041519448859617114, + 0.0003521011967677623, + 0.00027408264577388763, + 0.0002917216334026307 + ], + "coordinate_std": 2.707319498062134, + "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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 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, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 2.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 5.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 1.0, + 5.0, + 1.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, + 20.0, + 1.0, + 3.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.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, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 1.0, + 0.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.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, + 2053.0, + 20.0, + 0.0, + 0.0, + 0.0, + 0.0, + 7.0, + 549.0, + 1564.0, + 229.0, + 1355.0, + 674.0, + 837.0, + 188.0, + 81.0, + 354.0, + 348.0, + 287.0, + 334.0, + 368.0, + 454.0, + 1124.0, + 164.0, + 1438.0, + 1103.0, + 306.0, + 274.0, + 1018.0, + 962.0, + 404.0, + 505.0, + 307.0, + 336.0, + 308.0, + 233.0, + 534.0, + 525.0, + 364.0, + 285.0, + 342.0, + 325.0, + 399.0, + 299.0, + 555.0, + 338.0, + 292.0, + 272.0, + 404.0, + 396.0, + 421.0, + 362.0, + 417.0, + 394.0, + 358.0, + 341.0, + 323.0, + 296.0, + 307.0, + 340.0, + 324.0, + 301.0, + 368.0, + 332.0, + 282.0, + 286.0, + 257.0, + 234.0, + 245.0, + 236.0, + 231.0, + 272.0, + 287.0, + 245.0, + 226.0, + 280.0, + 232.0, + 278.0, + 203.0, + 134.0, + 153.0, + 148.0, + 154.0, + 207.0, + 194.0, + 188.0, + 180.0, + 179.0, + 138.0, + 145.0, + 162.0, + 161.0, + 127.0, + 102.0, + 97.0, + 97.0, + 101.0, + 88.0, + 88.0, + 95.0, + 94.0, + 114.0, + 70.0, + 65.0, + 62.0, + 71.0, + 61.0, + 65.0, + 58.0, + 80.0, + 60.0, + 51.0, + 31.0, + 55.0, + 62.0, + 62.0, + 47.0, + 54.0, + 38.0, + 42.0, + 57.0, + 35.0, + 37.0, + 20.0, + 40.0, + 29.0, + 19.0, + 8.0, + 22.0, + 26.0, + 23.0, + 26.0, + 24.0, + 22.0, + 17.0, + 420.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 10.0, + 2.0, + 0.0, + 4.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 2.0, + 2.0, + 1.0, + 1.0, + 2.0, + 1.0, + 2.0, + 2.0, + 1.0, + 3.0, + 18.0, + 2.0, + 22.0, + 10.0, + 5.0, + 1.0, + 0.0, + 1.0, + 3.0, + 3.0, + 9.0, + 3.0, + 2.0, + 1.0, + 1.0, + 2.0, + 5.0, + 4.0, + 2.0, + 1.0, + 2.0, + 0.0, + 2.0, + 0.0, + 1.0, + 1.0, + 3.0, + 13.0, + 10.0, + 3.0, + 9.0, + 3.0, + 2.0, + 6.0, + 5.0, + 2.0, + 0.0, + 0.0, + 3.0, + 2.0, + 3.0, + 0.0, + 1.0, + 4.0, + 1.0, + 1.0, + 2.0, + 0.0, + 2.0, + 1.0, + 3.0, + 2.0, + 2.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 3.0, + 5.0, + 1.0, + 2.0, + 2.0, + 2.0, + 2.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 1.0, + 0.0, + 2.0, + 0.0, + 2.0, + 1.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 2.0, + 3.0, + 1.0, + 3.0, + 1.0, + 2.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 23.0 + ], + [ + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 4.0, + 35.0, + 0.0, + 3.0, + 29.0, + 2.0, + 1.0, + 0.0, + 0.0, + 2.0, + 2.0, + 1.0, + 3.0, + 1.0, + 0.0, + 3.0, + 1.0, + 2.0, + 7.0, + 1.0, + 0.0, + 11.0, + 17.0, + 5.0, + 3.0, + 2.0, + 5.0, + 9.0, + 18.0, + 4.0, + 4.0, + 23.0, + 6.0, + 4.0, + 1.0, + 0.0, + 3.0, + 6.0, + 4.0, + 2.0, + 8.0, + 2.0, + 5.0, + 1.0, + 7.0, + 7.0, + 7.0, + 3.0, + 9.0, + 6.0, + 6.0, + 7.0, + 9.0, + 5.0, + 5.0, + 6.0, + 3.0, + 5.0, + 5.0, + 11.0, + 18.0, + 4.0, + 7.0, + 9.0, + 7.0, + 7.0, + 7.0, + 11.0, + 11.0, + 10.0, + 3.0, + 4.0, + 9.0, + 4.0, + 7.0, + 8.0, + 6.0, + 7.0, + 12.0, + 4.0, + 5.0, + 13.0, + 11.0, + 3.0, + 3.0, + 4.0, + 8.0, + 6.0, + 7.0, + 5.0, + 17.0, + 6.0, + 8.0, + 12.0, + 5.0, + 3.0, + 4.0, + 5.0, + 3.0, + 6.0, + 2.0, + 5.0, + 5.0, + 4.0, + 4.0, + 4.0, + 6.0, + 8.0, + 6.0, + 4.0, + 5.0, + 1.0, + 4.0, + 5.0, + 4.0, + 4.0, + 1.0, + 1.0, + 2.0, + 4.0, + 2.0, + 32.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, + 0.0, + 0.0, + 0.0, + 2.0, + 2395.0, + 100.0, + 560.0, + 207.0, + 11.0, + 17.0, + 49.0, + 206.0, + 1270.0, + 902.0, + 562.0, + 2370.0, + 315.0, + 285.0, + 218.0, + 286.0, + 684.0, + 509.0, + 235.0, + 232.0, + 256.0, + 898.0, + 405.0, + 666.0, + 598.0, + 1034.0, + 420.0, + 638.0, + 1201.0, + 669.0, + 467.0, + 480.0, + 595.0, + 681.0, + 1084.0, + 624.0, + 395.0, + 361.0, + 377.0, + 432.0, + 454.0, + 483.0, + 357.0, + 471.0, + 688.0, + 494.0, + 511.0, + 477.0, + 329.0, + 308.0, + 413.0, + 427.0, + 402.0, + 372.0, + 368.0, + 346.0, + 393.0, + 399.0, + 360.0, + 313.0, + 239.0, + 283.0, + 290.0, + 338.0, + 331.0, + 336.0, + 272.0, + 283.0, + 298.0, + 261.0, + 243.0, + 238.0, + 266.0, + 253.0, + 268.0, + 252.0, + 232.0, + 233.0, + 216.0, + 198.0, + 186.0, + 222.0, + 223.0, + 166.0, + 171.0, + 207.0, + 172.0, + 196.0, + 173.0, + 170.0, + 178.0, + 144.0, + 157.0, + 138.0, + 129.0, + 143.0, + 132.0, + 91.0, + 86.0, + 110.0, + 121.0, + 93.0, + 83.0, + 101.0, + 103.0, + 98.0, + 104.0, + 74.0, + 63.0, + 77.0, + 83.0, + 68.0, + 56.0, + 67.0, + 60.0, + 56.0, + 51.0, + 41.0, + 49.0, + 48.0, + 38.0, + 49.0, + 43.0, + 25.0, + 19.0, + 34.0, + 24.0, + 33.0, + 29.0, + 20.0, + 551.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 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, + 15.0, + 0.0, + 6.0, + 6.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 174.0, + 160.0, + 7.0, + 6.0, + 2.0, + 21.0, + 5.0, + 4.0, + 11.0, + 5.0, + 4.0, + 39.0, + 30.0, + 20.0, + 86.0, + 31.0, + 39.0, + 24.0, + 20.0, + 42.0, + 32.0, + 30.0, + 23.0, + 51.0, + 51.0, + 96.0, + 98.0, + 285.0, + 79.0, + 35.0, + 62.0, + 46.0, + 81.0, + 91.0, + 46.0, + 57.0, + 47.0, + 88.0, + 69.0, + 51.0, + 51.0, + 84.0, + 75.0, + 60.0, + 92.0, + 57.0, + 46.0, + 37.0, + 52.0, + 50.0, + 36.0, + 53.0, + 46.0, + 36.0, + 44.0, + 44.0, + 43.0, + 28.0, + 22.0, + 32.0, + 30.0, + 27.0, + 27.0, + 39.0, + 21.0, + 23.0, + 34.0, + 20.0, + 26.0, + 20.0, + 24.0, + 16.0, + 28.0, + 21.0, + 10.0, + 12.0, + 14.0, + 19.0, + 26.0, + 21.0, + 10.0, + 13.0, + 13.0, + 6.0, + 16.0, + 20.0, + 19.0, + 12.0, + 15.0, + 7.0, + 12.0, + 10.0, + 8.0, + 3.0, + 4.0, + 10.0, + 10.0, + 4.0, + 5.0, + 3.0, + 4.0, + 9.0, + 5.0, + 6.0, + 7.0, + 4.0, + 4.0, + 3.0, + 2.0, + 2.0, + 7.0, + 1.0, + 6.0, + 0.0, + 1.0, + 4.0, + 2.0, + 2.0, + 1.0, + 5.0, + 2.0, + 0.0, + 2.0, + 0.0, + 2.0, + 42.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4041.0, + 29.0, + 2.0, + 0.0, + 3.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 19.0, + 37.0, + 32.0, + 25.0, + 41.0, + 18.0, + 23.0, + 86.0, + 36.0, + 36.0, + 34.0, + 65.0, + 55.0, + 44.0, + 52.0, + 56.0, + 42.0, + 62.0, + 71.0, + 45.0, + 26.0, + 36.0, + 16.0, + 64.0, + 35.0, + 49.0, + 111.0, + 197.0, + 54.0, + 91.0, + 63.0, + 52.0, + 135.0, + 60.0, + 46.0, + 61.0, + 45.0, + 69.0, + 53.0, + 39.0, + 65.0, + 100.0, + 102.0, + 97.0, + 75.0, + 52.0, + 46.0, + 40.0, + 53.0, + 49.0, + 48.0, + 42.0, + 30.0, + 68.0, + 43.0, + 42.0, + 32.0, + 28.0, + 25.0, + 28.0, + 41.0, + 37.0, + 32.0, + 23.0, + 29.0, + 21.0, + 11.0, + 42.0, + 29.0, + 49.0, + 35.0, + 34.0, + 21.0, + 16.0, + 24.0, + 17.0, + 14.0, + 17.0, + 16.0, + 12.0, + 14.0, + 15.0, + 14.0, + 15.0, + 14.0, + 22.0, + 18.0, + 20.0, + 13.0, + 9.0, + 11.0, + 9.0, + 11.0, + 15.0, + 10.0, + 12.0, + 10.0, + 13.0, + 9.0, + 8.0, + 3.0, + 11.0, + 14.0, + 16.0, + 8.0, + 13.0, + 3.0, + 10.0, + 10.0, + 5.0, + 5.0, + 2.0, + 8.0, + 13.0, + 3.0, + 3.0, + 6.0, + 3.0, + 5.0, + 4.0, + 3.0, + 4.0, + 6.0, + 5.0, + 3.0, + 92.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 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, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 1.0, + 15.0, + 7.0, + 3.0, + 14.0, + 6.0, + 2.0, + 1.0, + 5.0, + 16.0, + 0.0, + 0.0, + 0.0, + 13.0, + 1.0, + 0.0, + 1.0, + 1.0, + 7.0, + 8.0, + 4.0, + 0.0, + 4.0, + 3.0, + 0.0, + 3.0, + 9.0, + 11.0, + 8.0, + 6.0, + 4.0, + 6.0, + 3.0, + 11.0, + 8.0, + 4.0, + 7.0, + 3.0, + 2.0, + 10.0, + 3.0, + 9.0, + 7.0, + 2.0, + 9.0, + 3.0, + 0.0, + 4.0, + 3.0, + 2.0, + 4.0, + 6.0, + 5.0, + 3.0, + 5.0, + 7.0, + 1.0, + 6.0, + 4.0, + 1.0, + 5.0, + 2.0, + 8.0, + 3.0, + 2.0, + 1.0, + 4.0, + 3.0, + 5.0, + 0.0, + 3.0, + 2.0, + 2.0, + 4.0, + 0.0, + 4.0, + 1.0, + 1.0, + 2.0, + 0.0, + 4.0, + 1.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 5.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 2.0, + 0.0, + 4.0, + 0.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 233.0, + 232.0, + 635.0, + 134.0, + 98.0, + 362.0, + 63.0, + 7.0, + 8.0, + 25.0, + 45.0, + 41.0, + 50.0, + 42.0, + 46.0, + 93.0, + 36.0, + 32.0, + 19.0, + 37.0, + 60.0, + 41.0, + 30.0, + 40.0, + 106.0, + 2184.0, + 942.0, + 91.0, + 53.0, + 74.0, + 45.0, + 31.0, + 34.0, + 37.0, + 355.0, + 68.0, + 28.0, + 45.0, + 32.0, + 36.0, + 41.0, + 33.0, + 34.0, + 39.0, + 30.0, + 41.0, + 39.0, + 30.0, + 40.0, + 45.0, + 68.0, + 28.0, + 31.0, + 29.0, + 33.0, + 84.0, + 52.0, + 31.0, + 45.0, + 33.0, + 43.0, + 39.0, + 37.0, + 26.0, + 46.0, + 46.0, + 25.0, + 50.0, + 33.0, + 37.0, + 38.0, + 40.0, + 47.0, + 42.0, + 47.0, + 38.0, + 18.0, + 31.0, + 31.0, + 29.0, + 18.0, + 26.0, + 24.0, + 20.0, + 22.0, + 34.0, + 26.0, + 35.0, + 33.0, + 26.0, + 32.0, + 31.0, + 19.0, + 12.0, + 17.0, + 21.0, + 29.0, + 28.0, + 53.0, + 12.0, + 19.0, + 27.0, + 12.0, + 14.0, + 18.0, + 12.0, + 19.0, + 20.0, + 13.0, + 15.0, + 10.0, + 7.0, + 14.0, + 15.0, + 8.0, + 8.0, + 14.0, + 9.0, + 213.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 30473.0, + 39773.0, + 80.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 14.0, + 1101.0, + 123978.0, + 8996.0, + 200.0, + 4376.0, + 4533.0, + 2363.0, + 2819.0, + 3397.0, + 2795.0, + 2360.0, + 2341.0, + 4405.0, + 6838.0, + 117565.0, + 8649.0, + 7963.0, + 8292.0, + 10549.0, + 61314.0, + 8673.0, + 6183.0, + 6943.0, + 7130.0, + 9234.0, + 13293.0, + 26852.0, + 14948.0, + 9471.0, + 11384.0, + 9701.0, + 8936.0, + 9815.0, + 11337.0, + 23018.0, + 11639.0, + 12727.0, + 13266.0, + 13554.0, + 11316.0, + 14059.0, + 19467.0, + 18262.0, + 19078.0, + 12746.0, + 11129.0, + 10554.0, + 11476.0, + 11092.0, + 11734.0, + 13494.0, + 11341.0, + 9854.0, + 10246.0, + 12490.0, + 14970.0, + 14215.0, + 12311.0, + 9308.0, + 9870.0, + 10499.0, + 10979.0, + 11300.0, + 10545.0, + 10207.0, + 9982.0, + 9398.0, + 9851.0, + 8569.0, + 7822.0, + 7530.0, + 7184.0, + 7418.0, + 7514.0, + 7263.0, + 7395.0, + 7517.0, + 8189.0, + 7503.0, + 6477.0, + 6526.0, + 5699.0, + 5744.0, + 6005.0, + 5636.0, + 5133.0, + 4726.0, + 4699.0, + 4546.0, + 4606.0, + 4522.0, + 4124.0, + 3972.0, + 3644.0, + 3303.0, + 3424.0, + 3307.0, + 3289.0, + 3172.0, + 2976.0, + 2898.0, + 2908.0, + 2687.0, + 2441.0, + 2450.0, + 2412.0, + 2244.0, + 2261.0, + 2001.0, + 1994.0, + 1797.0, + 1740.0, + 1682.0, + 1639.0, + 1650.0, + 1430.0, + 1417.0, + 1366.0, + 1400.0, + 1381.0, + 1378.0, + 1185.0, + 21874.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 38.0, + 0.0, + 229.0, + 163.0, + 23.0, + 14.0, + 7.0, + 4.0, + 12.0, + 52.0, + 57.0, + 45.0, + 52.0, + 61.0, + 47.0, + 53.0, + 51.0, + 81.0, + 65.0, + 74.0, + 64.0, + 65.0, + 61.0, + 76.0, + 239.0, + 774.0, + 96.0, + 68.0, + 102.0, + 63.0, + 73.0, + 59.0, + 77.0, + 268.0, + 63.0, + 66.0, + 78.0, + 62.0, + 61.0, + 79.0, + 89.0, + 62.0, + 57.0, + 73.0, + 61.0, + 67.0, + 60.0, + 69.0, + 86.0, + 92.0, + 88.0, + 76.0, + 58.0, + 86.0, + 91.0, + 90.0, + 78.0, + 64.0, + 71.0, + 90.0, + 74.0, + 70.0, + 92.0, + 106.0, + 86.0, + 87.0, + 74.0, + 69.0, + 86.0, + 66.0, + 65.0, + 78.0, + 71.0, + 78.0, + 65.0, + 68.0, + 80.0, + 85.0, + 68.0, + 60.0, + 61.0, + 81.0, + 55.0, + 53.0, + 49.0, + 55.0, + 67.0, + 48.0, + 55.0, + 56.0, + 61.0, + 75.0, + 71.0, + 54.0, + 59.0, + 49.0, + 52.0, + 62.0, + 49.0, + 48.0, + 49.0, + 54.0, + 45.0, + 24.0, + 24.0, + 27.0, + 30.0, + 35.0, + 31.0, + 23.0, + 25.0, + 14.0, + 23.0, + 13.0, + 20.0, + 17.0, + 16.0, + 23.0, + 18.0, + 396.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 21.0, + 6.0, + 3.0, + 81.0, + 867.0, + 153.0, + 46.0, + 17.0, + 34.0, + 63.0, + 33.0, + 23.0, + 58.0, + 59.0, + 65.0, + 40.0, + 37.0, + 71.0, + 108.0, + 211.0, + 110.0, + 84.0, + 100.0, + 98.0, + 104.0, + 1535.0, + 298.0, + 132.0, + 163.0, + 137.0, + 131.0, + 159.0, + 130.0, + 480.0, + 127.0, + 131.0, + 135.0, + 150.0, + 155.0, + 123.0, + 137.0, + 179.0, + 174.0, + 139.0, + 169.0, + 118.0, + 151.0, + 125.0, + 120.0, + 143.0, + 124.0, + 134.0, + 95.0, + 106.0, + 167.0, + 140.0, + 119.0, + 127.0, + 166.0, + 135.0, + 141.0, + 140.0, + 120.0, + 125.0, + 167.0, + 117.0, + 143.0, + 132.0, + 109.0, + 104.0, + 111.0, + 121.0, + 88.0, + 96.0, + 106.0, + 125.0, + 141.0, + 113.0, + 112.0, + 88.0, + 120.0, + 92.0, + 83.0, + 101.0, + 114.0, + 114.0, + 122.0, + 123.0, + 99.0, + 101.0, + 92.0, + 105.0, + 92.0, + 87.0, + 129.0, + 134.0, + 68.0, + 73.0, + 77.0, + 85.0, + 83.0, + 62.0, + 91.0, + 63.0, + 42.0, + 69.0, + 57.0, + 64.0, + 37.0, + 59.0, + 65.0, + 52.0, + 39.0, + 28.0, + 41.0, + 32.0, + 40.0, + 39.0, + 44.0, + 49.0, + 68.0, + 39.0, + 33.0, + 664.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 3425.0, + 1384.0, + 2593.0, + 2847.0, + 7956.0, + 93011.0, + 7362.0, + 4665.0, + 4949.0, + 3005.0, + 2548.0, + 1638.0, + 1539.0, + 1883.0, + 3718.0, + 4279.0, + 3853.0, + 3945.0, + 6214.0, + 4815.0, + 5590.0, + 6505.0, + 7155.0, + 7718.0, + 9053.0, + 69849.0, + 24844.0, + 10967.0, + 12966.0, + 11374.0, + 9703.0, + 9613.0, + 10902.0, + 28122.0, + 9862.0, + 11955.0, + 13360.0, + 13802.0, + 14241.0, + 12089.0, + 14363.0, + 19287.0, + 24120.0, + 17331.0, + 16489.0, + 15276.0, + 13009.0, + 13368.0, + 12772.0, + 13037.0, + 14228.0, + 12570.0, + 12442.0, + 13442.0, + 15210.0, + 14505.0, + 12690.0, + 12198.0, + 12278.0, + 12829.0, + 13656.0, + 14658.0, + 13858.0, + 13951.0, + 12886.0, + 12687.0, + 12231.0, + 11427.0, + 10763.0, + 10588.0, + 10056.0, + 9840.0, + 9478.0, + 9471.0, + 9436.0, + 9530.0, + 9110.0, + 9462.0, + 9209.0, + 8955.0, + 8212.0, + 7936.0, + 8259.0, + 8449.0, + 8491.0, + 7925.0, + 7311.0, + 7152.0, + 6836.0, + 6542.0, + 6511.0, + 6364.0, + 5642.0, + 5337.0, + 5234.0, + 5439.0, + 5413.0, + 5003.0, + 4753.0, + 4594.0, + 4562.0, + 4507.0, + 4414.0, + 4412.0, + 4078.0, + 3727.0, + 3789.0, + 3588.0, + 3444.0, + 3200.0, + 3052.0, + 2909.0, + 2944.0, + 2672.0, + 2568.0, + 2373.0, + 2395.0, + 2344.0, + 2253.0, + 2149.0, + 1938.0, + 1906.0, + 1913.0, + 1746.0, + 1796.0, + 34926.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 7.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 3.0, + 2.0, + 1.0, + 0.0, + 1.0, + 1.0, + 1.0, + 2.0, + 0.0, + 1.0, + 2.0, + 3.0, + 0.0, + 2.0, + 3.0, + 1.0, + 2.0, + 19.0, + 4.0, + 2.0, + 4.0, + 6.0, + 1.0, + 1.0, + 2.0, + 1.0, + 5.0, + 6.0, + 3.0, + 5.0, + 1.0, + 2.0, + 1.0, + 1.0, + 3.0, + 7.0, + 2.0, + 3.0, + 4.0, + 4.0, + 1.0, + 3.0, + 2.0, + 1.0, + 1.0, + 7.0, + 1.0, + 1.0, + 2.0, + 4.0, + 2.0, + 3.0, + 0.0, + 3.0, + 4.0, + 0.0, + 1.0, + 1.0, + 4.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 2.0, + 2.0, + 1.0, + 2.0, + 2.0, + 1.0, + 1.0, + 1.0, + 3.0, + 2.0, + 4.0, + 4.0, + 3.0, + 2.0, + 2.0, + 2.0, + 3.0, + 0.0, + 1.0, + 0.0, + 2.0, + 2.0, + 4.0, + 2.0, + 1.0, + 1.0, + 2.0, + 1.0, + 3.0, + 1.0, + 1.0, + 3.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 11.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 5.0, + 1115.0, + 1064.0, + 36.0, + 1643.0, + 2225.0, + 1500.0, + 1202.0, + 1066.0, + 716.0, + 610.0, + 573.0, + 435.0, + 1122.0, + 5269.0, + 5675.0, + 975.0, + 1031.0, + 1157.0, + 1185.0, + 1014.0, + 882.0, + 1070.0, + 1266.0, + 1554.0, + 2180.0, + 4931.0, + 3976.0, + 3533.0, + 2519.0, + 1946.0, + 1810.0, + 2550.0, + 5355.0, + 11569.0, + 2434.0, + 2833.0, + 2958.0, + 4293.0, + 3184.0, + 2220.0, + 2574.0, + 6914.0, + 6387.0, + 3053.0, + 1856.0, + 1916.0, + 2030.0, + 2028.0, + 2271.0, + 2078.0, + 1887.0, + 1998.0, + 1711.0, + 1985.0, + 3087.0, + 3582.0, + 2277.0, + 1702.0, + 1733.0, + 1770.0, + 1837.0, + 1838.0, + 1903.0, + 1774.0, + 1713.0, + 1795.0, + 1950.0, + 1533.0, + 1317.0, + 1150.0, + 1135.0, + 1095.0, + 1067.0, + 983.0, + 1217.0, + 1221.0, + 1242.0, + 1208.0, + 1050.0, + 882.0, + 864.0, + 752.0, + 764.0, + 894.0, + 732.0, + 730.0, + 653.0, + 615.0, + 580.0, + 638.0, + 564.0, + 528.0, + 453.0, + 418.0, + 440.0, + 440.0, + 437.0, + 461.0, + 404.0, + 386.0, + 387.0, + 377.0, + 341.0, + 404.0, + 311.0, + 318.0, + 279.0, + 270.0, + 298.0, + 252.0, + 257.0, + 239.0, + 214.0, + 207.0, + 185.0, + 190.0, + 230.0, + 197.0, + 193.0, + 179.0, + 163.0, + 150.0, + 2893.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4.0, + 1281.0, + 19.0, + 2510.0, + 5107.0, + 1845.0, + 1384.0, + 703.0, + 498.0, + 567.0, + 592.0, + 610.0, + 699.0, + 668.0, + 929.0, + 818.0, + 616.0, + 733.0, + 715.0, + 827.0, + 973.0, + 1217.0, + 1437.0, + 988.0, + 1751.0, + 4076.0, + 3632.0, + 2728.0, + 3805.0, + 2192.0, + 1754.0, + 1672.0, + 1768.0, + 2700.0, + 7880.0, + 1916.0, + 2503.0, + 2561.0, + 3332.0, + 2060.0, + 2590.0, + 2874.0, + 6111.0, + 5661.0, + 4335.0, + 1883.0, + 1683.0, + 1754.0, + 1827.0, + 1808.0, + 2402.0, + 1817.0, + 1849.0, + 1983.0, + 2583.0, + 2335.0, + 2226.0, + 2119.0, + 1805.0, + 1819.0, + 1865.0, + 1610.0, + 1523.0, + 1711.0, + 1509.0, + 1668.0, + 1409.0, + 1293.0, + 1260.0, + 1122.0, + 1133.0, + 1027.0, + 1116.0, + 1068.0, + 1001.0, + 1063.0, + 1041.0, + 1129.0, + 1025.0, + 1048.0, + 880.0, + 803.0, + 844.0, + 803.0, + 951.0, + 874.0, + 833.0, + 722.0, + 769.0, + 678.0, + 707.0, + 690.0, + 701.0, + 567.0, + 575.0, + 529.0, + 617.0, + 647.0, + 756.0, + 481.0, + 480.0, + 468.0, + 495.0, + 471.0, + 439.0, + 403.0, + 443.0, + 429.0, + 361.0, + 345.0, + 331.0, + 277.0, + 308.0, + 304.0, + 275.0, + 291.0, + 270.0, + 235.0, + 228.0, + 226.0, + 247.0, + 223.0, + 217.0, + 4307.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 4.0, + 6.0, + 1.0, + 1.0, + 1.0, + 0.0, + 3.0, + 3.0, + 5.0, + 8.0, + 10.0, + 1.0, + 7.0, + 7.0, + 9.0, + 4.0, + 9.0, + 7.0, + 29.0, + 38.0, + 27.0, + 24.0, + 25.0, + 11.0, + 6.0, + 9.0, + 11.0, + 4.0, + 7.0, + 6.0, + 10.0, + 7.0, + 5.0, + 10.0, + 4.0, + 7.0, + 3.0, + 13.0, + 31.0, + 45.0, + 16.0, + 16.0, + 4.0, + 9.0, + 5.0, + 7.0, + 5.0, + 7.0, + 2.0, + 6.0, + 4.0, + 7.0, + 5.0, + 5.0, + 4.0, + 7.0, + 4.0, + 2.0, + 4.0, + 1.0, + 2.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 3.0, + 5.0, + 0.0, + 1.0, + 0.0, + 3.0, + 2.0, + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 4.0, + 2.0, + 0.0, + 0.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, + 1.0, + 15.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 301.0, + 1381.0, + 37.0, + 296.0, + 337.0, + 124.0, + 108.0, + 152.0, + 90.0, + 134.0, + 86.0, + 70.0, + 125.0, + 579.0, + 244.0, + 169.0, + 105.0, + 101.0, + 91.0, + 63.0, + 101.0, + 132.0, + 198.0, + 224.0, + 301.0, + 1233.0, + 510.0, + 158.0, + 212.0, + 588.0, + 184.0, + 226.0, + 265.0, + 1257.0, + 1550.0, + 226.0, + 239.0, + 208.0, + 298.0, + 568.0, + 572.0, + 375.0, + 348.0, + 318.0, + 253.0, + 216.0, + 200.0, + 330.0, + 300.0, + 314.0, + 230.0, + 236.0, + 211.0, + 218.0, + 348.0, + 529.0, + 736.0, + 287.0, + 210.0, + 270.0, + 287.0, + 280.0, + 298.0, + 226.0, + 169.0, + 229.0, + 202.0, + 256.0, + 174.0, + 163.0, + 142.0, + 142.0, + 174.0, + 112.0, + 127.0, + 106.0, + 182.0, + 143.0, + 135.0, + 146.0, + 112.0, + 97.0, + 89.0, + 93.0, + 121.0, + 90.0, + 62.0, + 81.0, + 71.0, + 80.0, + 76.0, + 63.0, + 49.0, + 48.0, + 53.0, + 51.0, + 40.0, + 61.0, + 73.0, + 38.0, + 40.0, + 53.0, + 47.0, + 43.0, + 38.0, + 44.0, + 49.0, + 40.0, + 35.0, + 31.0, + 25.0, + 33.0, + 31.0, + 20.0, + 20.0, + 26.0, + 11.0, + 14.0, + 32.0, + 346.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 1.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, + 2.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4.0, + 0.0, + 0.0, + 1.0, + 2.0, + 4.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.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, + 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, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 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, + 2.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.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, + 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, + 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, + 9505.0, + 10170.0, + 13796115.0, + 4949870.0, + 7244403.0, + 649.0, + 13.0, + 139.0, + 11.0, + 45017.0, + 510365.0, + 1584130.0, + 6984873.0, + 17126352.0, + 8154223.0, + 1125761.0, + 6790952.0, + 2640507.0, + 1723186.0, + 1968239.0, + 1751057.0, + 1529440.0, + 1423652.0, + 1619744.0, + 2824859.0, + 4692183.0, + 7782452.0, + 3991307.0, + 1649423.0, + 1773346.0, + 3110966.0, + 5336689.0, + 4875812.0, + 4399579.0, + 4075591.0, + 3899547.0, + 4897529.0, + 5335164.0, + 5069866.0, + 5102962.0, + 4225161.0, + 3342561.0, + 3198753.0, + 3213296.0, + 3248635.0, + 3620452.0, + 3633444.0, + 3825808.0, + 3972060.0, + 3962896.0, + 3498132.0, + 3295755.0, + 3708932.0, + 4374756.0, + 3854258.0, + 3320278.0, + 3079121.0, + 2994610.0, + 2948199.0, + 3086206.0, + 3061386.0, + 3035883.0, + 2794333.0, + 2522193.0, + 2406732.0, + 2358864.0, + 2303041.0, + 2357574.0, + 2243622.0, + 2120361.0, + 2052202.0, + 2033551.0, + 2095708.0, + 2133194.0, + 2106088.0, + 2082660.0, + 1961621.0, + 1820226.0, + 1676180.0, + 1594034.0, + 1554650.0, + 1514274.0, + 1409100.0, + 1366654.0, + 1333226.0, + 1281304.0, + 1224654.0, + 1207769.0, + 1192780.0, + 1158164.0, + 1086603.0, + 1021198.0, + 972253.0, + 961525.0, + 972868.0, + 970008.0, + 936962.0, + 882795.0, + 842827.0, + 797370.0, + 760388.0, + 723832.0, + 687951.0, + 650010.0, + 613216.0, + 582544.0, + 558176.0, + 543646.0, + 527282.0, + 514433.0, + 500020.0, + 484777.0, + 459002.0, + 435250.0, + 415897.0, + 406575.0, + 394852.0, + 381312.0, + 357785.0, + 339645.0, + 321276.0, + 306631.0, + 293693.0, + 283528.0, + 269644.0, + 255001.0, + 240897.0, + 228053.0, + 217266.0, + 210166.0, + 200785.0, + 192640.0, + 184991.0, + 177172.0, + 169176.0, + 162724.0, + 158280.0, + 152685.0, + 2897318.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 847.0, + 319533.0, + 3642.0, + 2.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 17.0, + 198371.0, + 415288.0, + 4765.0, + 3810.0, + 44499.0, + 26037.0, + 12583.0, + 18659.0, + 18794.0, + 16958.0, + 17805.0, + 18453.0, + 31331.0, + 454343.0, + 157943.0, + 37691.0, + 48369.0, + 53782.0, + 157535.0, + 211626.0, + 39608.0, + 38525.0, + 43955.0, + 48457.0, + 48421.0, + 95438.0, + 80022.0, + 56792.0, + 55131.0, + 60422.0, + 48443.0, + 48377.0, + 53171.0, + 89719.0, + 95138.0, + 58871.0, + 70248.0, + 74409.0, + 63441.0, + 64328.0, + 88070.0, + 106207.0, + 101828.0, + 74734.0, + 62960.0, + 59482.0, + 57606.0, + 58336.0, + 64375.0, + 65008.0, + 60644.0, + 56600.0, + 54944.0, + 58731.0, + 70930.0, + 79110.0, + 64294.0, + 50358.0, + 48200.0, + 50534.0, + 55131.0, + 56991.0, + 53564.0, + 55631.0, + 53830.0, + 49649.0, + 46569.0, + 45615.0, + 39156.0, + 37893.0, + 36167.0, + 36118.0, + 36693.0, + 35258.0, + 35102.0, + 37632.0, + 37955.0, + 36585.0, + 35410.0, + 31733.0, + 30330.0, + 29152.0, + 29634.0, + 29699.0, + 26598.0, + 25350.0, + 24272.0, + 24094.0, + 23399.0, + 23247.0, + 21654.0, + 20320.0, + 19536.0, + 17937.0, + 16962.0, + 17315.0, + 17796.0, + 17440.0, + 16396.0, + 15626.0, + 14916.0, + 14820.0, + 14751.0, + 14002.0, + 13453.0, + 12258.0, + 12043.0, + 11011.0, + 10807.0, + 10087.0, + 9754.0, + 9402.0, + 9069.0, + 8829.0, + 8213.0, + 7810.0, + 7831.0, + 7384.0, + 7422.0, + 6807.0, + 6699.0, + 6200.0, + 5951.0, + 117835.0 + ], + [ + 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, + 617726.0, + 6980.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 259.0, + 848817.0, + 15215.0, + 497.0, + 13869.0, + 94796.0, + 116953.0, + 127845.0, + 70458.0, + 44357.0, + 34755.0, + 51545.0, + 136474.0, + 181585.0, + 497978.0, + 59112.0, + 44521.0, + 58059.0, + 249095.0, + 266055.0, + 167825.0, + 136535.0, + 101983.0, + 122436.0, + 148378.0, + 153608.0, + 190959.0, + 276187.0, + 124791.0, + 106928.0, + 114664.0, + 104769.0, + 107210.0, + 131284.0, + 151547.0, + 113846.0, + 132536.0, + 123926.0, + 126176.0, + 134350.0, + 156519.0, + 199087.0, + 194389.0, + 145219.0, + 129750.0, + 129576.0, + 129078.0, + 133105.0, + 142100.0, + 141108.0, + 137905.0, + 137925.0, + 127153.0, + 123903.0, + 134014.0, + 138883.0, + 122760.0, + 108658.0, + 108197.0, + 111810.0, + 121587.0, + 129199.0, + 126671.0, + 124212.0, + 119819.0, + 112401.0, + 106762.0, + 103101.0, + 97045.0, + 97797.0, + 92779.0, + 90651.0, + 88384.0, + 88043.0, + 87500.0, + 87930.0, + 86470.0, + 83500.0, + 77964.0, + 76314.0, + 73212.0, + 73615.0, + 74672.0, + 76160.0, + 71623.0, + 68519.0, + 66036.0, + 63593.0, + 61736.0, + 59679.0, + 57659.0, + 55022.0, + 53016.0, + 50247.0, + 47929.0, + 47096.0, + 46403.0, + 46327.0, + 44750.0, + 43200.0, + 41659.0, + 40111.0, + 39392.0, + 37857.0, + 37290.0, + 35743.0, + 34233.0, + 32576.0, + 31036.0, + 29799.0, + 28883.0, + 27817.0, + 26908.0, + 25518.0, + 24161.0, + 23386.0, + 21915.0, + 21840.0, + 21716.0, + 20913.0, + 19586.0, + 19041.0, + 18488.0, + 17374.0, + 17258.0, + 16035.0, + 15315.0, + 311380.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 29440400.0, + 169408.0, + 15.0, + 1.0, + 2.0, + 25.0, + 2.0, + 5.0, + 119266.0, + 380259.0, + 3203430.0, + 40886432.0, + 1862262.0, + 221111.0, + 1435191.0, + 4152394.0, + 9951826.0, + 14553661.0, + 6348623.0, + 4055819.0, + 3358240.0, + 3668228.0, + 7355685.0, + 23629216.0, + 10553054.0, + 2942601.0, + 2979650.0, + 4537848.0, + 15063074.0, + 9513658.0, + 10159456.0, + 9482698.0, + 7412600.0, + 6721697.0, + 8132669.0, + 9410564.0, + 10955124.0, + 9517152.0, + 9546761.0, + 9225582.0, + 9552554.0, + 8245891.0, + 8238695.0, + 8061576.0, + 7078513.0, + 7404560.0, + 8031814.0, + 8175295.0, + 7666593.0, + 7897343.0, + 8810906.0, + 9075356.0, + 9144443.0, + 8588311.0, + 7979517.0, + 7662524.0, + 7580222.0, + 7812243.0, + 8082511.0, + 7881226.0, + 7416081.0, + 7137064.0, + 6870878.0, + 6624668.0, + 6721091.0, + 6521123.0, + 5909772.0, + 5567310.0, + 5501278.0, + 5613501.0, + 5770293.0, + 5878647.0, + 5691759.0, + 5597709.0, + 5458175.0, + 5236871.0, + 4966916.0, + 4776362.0, + 4642270.0, + 4539358.0, + 4404157.0, + 4225000.0, + 4062754.0, + 3893543.0, + 3783962.0, + 3653279.0, + 3553014.0, + 3415404.0, + 3202941.0, + 3068163.0, + 2976764.0, + 2960010.0, + 2973616.0, + 2929892.0, + 2833184.0, + 2698982.0, + 2590735.0, + 2509356.0, + 2421331.0, + 2333449.0, + 2236171.0, + 2134668.0, + 2046270.0, + 1939281.0, + 1845026.0, + 1785808.0, + 1713909.0, + 1650264.0, + 1583556.0, + 1518575.0, + 1454405.0, + 1404001.0, + 1365953.0, + 1332734.0, + 1296331.0, + 1253244.0, + 1205502.0, + 1149937.0, + 1093976.0, + 1049939.0, + 1014164.0, + 971087.0, + 924438.0, + 877131.0, + 831709.0, + 794814.0, + 756616.0, + 727830.0, + 698125.0, + 664698.0, + 634215.0, + 605775.0, + 587412.0, + 567321.0, + 550144.0, + 530177.0, + 506591.0, + 485155.0, + 463394.0, + 9449479.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 10.0, + 4547.0, + 1357.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 475.0, + 10098.0, + 523.0, + 218.0, + 540.0, + 319.0, + 289.0, + 511.0, + 447.0, + 317.0, + 317.0, + 317.0, + 416.0, + 580.0, + 9696.0, + 980.0, + 810.0, + 611.0, + 717.0, + 5148.0, + 922.0, + 782.0, + 666.0, + 713.0, + 876.0, + 1824.0, + 2394.0, + 1155.0, + 805.0, + 948.0, + 874.0, + 826.0, + 851.0, + 893.0, + 1543.0, + 995.0, + 1102.0, + 1223.0, + 989.0, + 935.0, + 1217.0, + 1573.0, + 1487.0, + 1566.0, + 893.0, + 826.0, + 801.0, + 841.0, + 831.0, + 945.0, + 929.0, + 725.0, + 661.0, + 726.0, + 897.0, + 1250.0, + 1238.0, + 940.0, + 760.0, + 691.0, + 807.0, + 855.0, + 791.0, + 751.0, + 738.0, + 800.0, + 821.0, + 799.0, + 631.0, + 607.0, + 518.0, + 550.0, + 541.0, + 498.0, + 504.0, + 573.0, + 602.0, + 534.0, + 536.0, + 530.0, + 468.0, + 449.0, + 435.0, + 498.0, + 467.0, + 376.0, + 326.0, + 360.0, + 346.0, + 385.0, + 288.0, + 300.0, + 275.0, + 259.0, + 275.0, + 224.0, + 250.0, + 243.0, + 238.0, + 216.0, + 181.0, + 197.0, + 202.0, + 199.0, + 175.0, + 166.0, + 170.0, + 165.0, + 137.0, + 118.0, + 135.0, + 121.0, + 141.0, + 115.0, + 105.0, + 100.0, + 101.0, + 76.0, + 94.0, + 78.0, + 1431.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 81020.0, + 375649.0, + 5173444.0, + 3759844.0, + 318828.0, + 8.0, + 1.0, + 0.0, + 6.0, + 45637.0, + 576082.0, + 1158470.0, + 2182208.0, + 6716008.0, + 990571.0, + 312048.0, + 927905.0, + 1041669.0, + 921762.0, + 728714.0, + 591314.0, + 523000.0, + 534969.0, + 985880.0, + 1964187.0, + 3533959.0, + 2242430.0, + 726511.0, + 564379.0, + 998863.0, + 1876764.0, + 2044508.0, + 1584583.0, + 1430615.0, + 1346186.0, + 1548508.0, + 1734848.0, + 1794058.0, + 1595952.0, + 1848400.0, + 1422894.0, + 1128961.0, + 977946.0, + 976627.0, + 1078220.0, + 1227679.0, + 1240060.0, + 1233613.0, + 1357152.0, + 1113783.0, + 981679.0, + 1103320.0, + 1368337.0, + 1412244.0, + 1176236.0, + 1000096.0, + 936569.0, + 893944.0, + 931568.0, + 959707.0, + 900128.0, + 889041.0, + 798115.0, + 767704.0, + 735292.0, + 705139.0, + 730928.0, + 723563.0, + 641433.0, + 591521.0, + 587610.0, + 609184.0, + 627443.0, + 643428.0, + 633769.0, + 602626.0, + 552892.0, + 502496.0, + 464775.0, + 439669.0, + 423666.0, + 407901.0, + 386732.0, + 368518.0, + 355954.0, + 342192.0, + 340782.0, + 352345.0, + 344757.0, + 320404.0, + 289956.0, + 274808.0, + 268616.0, + 269027.0, + 274246.0, + 269614.0, + 251245.0, + 234783.0, + 220804.0, + 208073.0, + 202531.0, + 191446.0, + 179141.0, + 167991.0, + 159752.0, + 153738.0, + 147456.0, + 144205.0, + 141382.0, + 138404.0, + 131238.0, + 124651.0, + 118560.0, + 113785.0, + 112167.0, + 110414.0, + 107397.0, + 100394.0, + 94531.0, + 88013.0, + 82839.0, + 79263.0, + 74471.0, + 71921.0, + 67977.0, + 64042.0, + 60917.0, + 58768.0, + 56767.0, + 55590.0, + 53059.0, + 50480.0, + 47439.0, + 45737.0, + 43770.0, + 42083.0, + 40931.0, + 39336.0, + 749687.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 17164.0, + 1733508.0, + 1529173.0, + 1411141.0, + 23.0, + 6.0, + 2.0, + 0.0, + 1.0, + 562.0, + 86592.0, + 306809.0, + 3196439.0, + 2150649.0, + 216373.0, + 567938.0, + 978039.0, + 1508567.0, + 1217871.0, + 661129.0, + 607112.0, + 569938.0, + 587700.0, + 832887.0, + 1668617.0, + 2975522.0, + 876194.0, + 878662.0, + 678559.0, + 1088748.0, + 2158616.0, + 1530617.0, + 1306079.0, + 1040966.0, + 1107340.0, + 1183006.0, + 1414442.0, + 1338619.0, + 1700958.0, + 1330382.0, + 1439206.0, + 1109036.0, + 865042.0, + 882911.0, + 924152.0, + 1043008.0, + 837368.0, + 950707.0, + 1004762.0, + 911941.0, + 894074.0, + 978561.0, + 1138534.0, + 1167458.0, + 1051859.0, + 928071.0, + 825560.0, + 804557.0, + 826600.0, + 862266.0, + 846580.0, + 829700.0, + 802490.0, + 752551.0, + 698177.0, + 697342.0, + 701286.0, + 680233.0, + 614437.0, + 572731.0, + 580123.0, + 613537.0, + 640831.0, + 642934.0, + 633745.0, + 615561.0, + 576482.0, + 533788.0, + 503400.0, + 465763.0, + 457952.0, + 443342.0, + 424664.0, + 400147.0, + 382184.0, + 378309.0, + 378958.0, + 368609.0, + 354712.0, + 336542.0, + 311438.0, + 300433.0, + 293285.0, + 296375.0, + 302859.0, + 288873.0, + 275442.0, + 259661.0, + 252482.0, + 241758.0, + 231636.0, + 222625.0, + 209934.0, + 199430.0, + 185303.0, + 173557.0, + 165937.0, + 163259.0, + 159432.0, + 154507.0, + 146484.0, + 140771.0, + 137942.0, + 134185.0, + 130807.0, + 129611.0, + 125336.0, + 118612.0, + 111474.0, + 105402.0, + 100052.0, + 96205.0, + 93133.0, + 87492.0, + 83443.0, + 79868.0, + 75125.0, + 72135.0, + 67249.0, + 65644.0, + 63868.0, + 59896.0, + 56598.0, + 54567.0, + 52742.0, + 52372.0, + 50236.0, + 48792.0, + 955550.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 3698.0, + 6783.0, + 445.0, + 3.0, + 0.0, + 0.0, + 2.0, + 29.0, + 856.0, + 11199.0, + 9533.0, + 3767.0, + 715.0, + 731.0, + 1242.0, + 1713.0, + 2399.0, + 3005.0, + 3362.0, + 2617.0, + 2652.0, + 6260.0, + 4144.0, + 5805.0, + 3455.0, + 2308.0, + 2022.0, + 2718.0, + 5510.0, + 5254.0, + 4838.0, + 3954.0, + 4499.0, + 3683.0, + 4206.0, + 5145.0, + 3538.0, + 2621.0, + 2387.0, + 2480.0, + 2386.0, + 2215.0, + 2215.0, + 2492.0, + 2541.0, + 2820.0, + 2395.0, + 2086.0, + 2078.0, + 2358.0, + 2619.0, + 2045.0, + 1861.0, + 1651.0, + 1697.0, + 1715.0, + 1663.0, + 1451.0, + 1454.0, + 1248.0, + 1143.0, + 1168.0, + 1058.0, + 1055.0, + 1072.0, + 1029.0, + 948.0, + 887.0, + 868.0, + 823.0, + 941.0, + 947.0, + 807.0, + 721.0, + 716.0, + 681.0, + 694.0, + 672.0, + 651.0, + 599.0, + 566.0, + 587.0, + 515.0, + 525.0, + 476.0, + 483.0, + 478.0, + 412.0, + 433.0, + 447.0, + 401.0, + 402.0, + 389.0, + 359.0, + 445.0, + 369.0, + 334.0, + 309.0, + 316.0, + 284.0, + 284.0, + 239.0, + 250.0, + 234.0, + 213.0, + 218.0, + 180.0, + 197.0, + 181.0, + 164.0, + 186.0, + 176.0, + 160.0, + 193.0, + 196.0, + 204.0, + 175.0, + 178.0, + 164.0, + 145.0, + 145.0, + 154.0, + 126.0, + 97.0, + 116.0, + 111.0, + 95.0, + 123.0, + 102.0, + 96.0, + 116.0, + 91.0, + 2013.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 299.0, + 40915.0, + 783811.0, + 117819.0, + 28.0, + 0.0, + 0.0, + 0.0, + 3834.0, + 7683.0, + 270185.0, + 304141.0, + 599330.0, + 156654.0, + 20541.0, + 46045.0, + 86282.0, + 83332.0, + 69730.0, + 81593.0, + 82259.0, + 79908.0, + 81748.0, + 183367.0, + 379999.0, + 501518.0, + 116260.0, + 80266.0, + 108087.0, + 235505.0, + 319593.0, + 183711.0, + 156151.0, + 147247.0, + 148140.0, + 191010.0, + 224512.0, + 209807.0, + 155651.0, + 129353.0, + 120329.0, + 110424.0, + 111841.0, + 131106.0, + 131175.0, + 160373.0, + 139268.0, + 168601.0, + 146745.0, + 121054.0, + 123414.0, + 152260.0, + 177971.0, + 147708.0, + 117678.0, + 109217.0, + 105120.0, + 103468.0, + 108098.0, + 108577.0, + 100230.0, + 88609.0, + 81106.0, + 83636.0, + 82274.0, + 82362.0, + 86349.0, + 82294.0, + 69051.0, + 67786.0, + 69609.0, + 74147.0, + 71642.0, + 70749.0, + 67847.0, + 63010.0, + 56586.0, + 52579.0, + 50725.0, + 45623.0, + 42612.0, + 42721.0, + 40273.0, + 38720.0, + 36620.0, + 37025.0, + 36838.0, + 36466.0, + 34532.0, + 32326.0, + 30098.0, + 28279.0, + 28131.0, + 28797.0, + 28948.0, + 25772.0, + 23829.0, + 23064.0, + 21999.0, + 20460.0, + 19665.0, + 17671.0, + 16176.0, + 15295.0, + 14585.0, + 14292.0, + 14110.0, + 13931.0, + 13872.0, + 13413.0, + 12836.0, + 11707.0, + 11124.0, + 10776.0, + 10202.0, + 9982.0, + 9042.0, + 8645.0, + 8083.0, + 7735.0, + 7226.0, + 6719.0, + 6292.0, + 5911.0, + 5744.0, + 5548.0, + 5054.0, + 4840.0, + 4560.0, + 4510.0, + 4492.0, + 4137.0, + 3805.0, + 3687.0, + 62314.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 43.0, + 999.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 39.0, + 1118.0, + 303.0, + 0.0, + 29.0, + 99.0, + 82.0, + 83.0, + 66.0, + 102.0, + 97.0, + 85.0, + 104.0, + 80.0, + 770.0, + 260.0, + 64.0, + 36.0, + 126.0, + 430.0, + 270.0, + 135.0, + 134.0, + 130.0, + 133.0, + 175.0, + 190.0, + 125.0, + 79.0, + 73.0, + 84.0, + 50.0, + 76.0, + 94.0, + 110.0, + 91.0, + 102.0, + 111.0, + 65.0, + 72.0, + 88.0, + 112.0, + 135.0, + 101.0, + 93.0, + 49.0, + 55.0, + 48.0, + 22.0, + 54.0, + 49.0, + 59.0, + 62.0, + 46.0, + 54.0, + 44.0, + 80.0, + 70.0, + 56.0, + 53.0, + 46.0, + 44.0, + 58.0, + 36.0, + 27.0, + 34.0, + 46.0, + 20.0, + 30.0, + 20.0, + 13.0, + 25.0, + 13.0, + 22.0, + 19.0, + 15.0, + 22.0, + 38.0, + 38.0, + 17.0, + 27.0, + 16.0, + 14.0, + 19.0, + 20.0, + 12.0, + 14.0, + 8.0, + 13.0, + 17.0, + 10.0, + 13.0, + 12.0, + 5.0, + 6.0, + 5.0, + 5.0, + 1.0, + 6.0, + 7.0, + 7.0, + 3.0, + 7.0, + 9.0, + 8.0, + 6.0, + 9.0, + 1.0, + 1.0, + 3.0, + 1.0, + 3.0, + 1.0, + 5.0, + 3.0, + 4.0, + 2.0, + 2.0, + 0.0, + 1.0, + 2.0, + 3.0, + 19.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3818.0, + 100.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 5.0, + 192.0, + 1122.0, + 2480.0, + 197.0, + 104.0, + 57.0, + 76.0, + 153.0, + 118.0, + 121.0, + 178.0, + 139.0, + 155.0, + 411.0, + 260.0, + 775.0, + 356.0, + 233.0, + 267.0, + 296.0, + 640.0, + 366.0, + 300.0, + 305.0, + 270.0, + 322.0, + 369.0, + 419.0, + 337.0, + 295.0, + 333.0, + 262.0, + 270.0, + 247.0, + 259.0, + 289.0, + 341.0, + 219.0, + 222.0, + 212.0, + 241.0, + 218.0, + 259.0, + 290.0, + 219.0, + 181.0, + 183.0, + 163.0, + 159.0, + 160.0, + 172.0, + 143.0, + 149.0, + 132.0, + 132.0, + 141.0, + 159.0, + 179.0, + 176.0, + 161.0, + 132.0, + 165.0, + 173.0, + 170.0, + 131.0, + 144.0, + 134.0, + 181.0, + 145.0, + 120.0, + 114.0, + 79.0, + 91.0, + 84.0, + 82.0, + 73.0, + 84.0, + 95.0, + 88.0, + 86.0, + 64.0, + 63.0, + 63.0, + 72.0, + 56.0, + 52.0, + 58.0, + 27.0, + 33.0, + 36.0, + 27.0, + 26.0, + 19.0, + 34.0, + 27.0, + 44.0, + 25.0, + 42.0, + 24.0, + 24.0, + 28.0, + 24.0, + 23.0, + 22.0, + 11.0, + 8.0, + 12.0, + 16.0, + 19.0, + 17.0, + 20.0, + 10.0, + 10.0, + 12.0, + 18.0, + 14.0, + 7.0, + 5.0, + 15.0, + 8.0, + 12.0, + 15.0, + 15.0, + 225.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2802.0, + 1410.0, + 140.0, + 14358.0, + 1732.0, + 425.0, + 551.0, + 136.0, + 148.0, + 130.0, + 216.0, + 295.0, + 128.0, + 135.0, + 356.0, + 303.0, + 195.0, + 79.0, + 122.0, + 261.0, + 435.0, + 331.0, + 276.0, + 570.0, + 276.0, + 18407.0, + 6107.0, + 672.0, + 519.0, + 598.0, + 346.0, + 281.0, + 305.0, + 529.0, + 3331.0, + 398.0, + 427.0, + 420.0, + 434.0, + 412.0, + 361.0, + 355.0, + 319.0, + 350.0, + 342.0, + 313.0, + 288.0, + 325.0, + 321.0, + 368.0, + 348.0, + 596.0, + 409.0, + 355.0, + 390.0, + 487.0, + 492.0, + 382.0, + 421.0, + 353.0, + 286.0, + 310.0, + 292.0, + 321.0, + 286.0, + 322.0, + 311.0, + 360.0, + 296.0, + 256.0, + 273.0, + 276.0, + 283.0, + 306.0, + 329.0, + 266.0, + 210.0, + 245.0, + 253.0, + 209.0, + 159.0, + 200.0, + 221.0, + 204.0, + 178.0, + 157.0, + 211.0, + 171.0, + 166.0, + 179.0, + 181.0, + 174.0, + 151.0, + 142.0, + 153.0, + 176.0, + 160.0, + 180.0, + 184.0, + 135.0, + 127.0, + 122.0, + 163.0, + 143.0, + 115.0, + 103.0, + 102.0, + 91.0, + 96.0, + 98.0, + 86.0, + 82.0, + 73.0, + 77.0, + 83.0, + 64.0, + 67.0, + 69.0, + 69.0, + 63.0, + 77.0, + 1604.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 512.0, + 0.0, + 20.0, + 780.0, + 7048.0, + 4115.0, + 327.0, + 154.0, + 195.0, + 340.0, + 278.0, + 325.0, + 263.0, + 287.0, + 278.0, + 266.0, + 324.0, + 317.0, + 595.0, + 2456.0, + 429.0, + 562.0, + 806.0, + 708.0, + 705.0, + 7889.0, + 914.0, + 1508.0, + 1428.0, + 1012.0, + 1249.0, + 1321.0, + 987.0, + 2767.0, + 1078.0, + 1476.0, + 1309.0, + 1618.0, + 1469.0, + 938.0, + 2235.0, + 2130.0, + 916.0, + 920.0, + 860.0, + 913.0, + 1171.0, + 846.0, + 881.0, + 982.0, + 924.0, + 902.0, + 1151.0, + 1391.0, + 1107.0, + 1045.0, + 895.0, + 949.0, + 1012.0, + 990.0, + 978.0, + 982.0, + 951.0, + 902.0, + 938.0, + 902.0, + 913.0, + 1057.0, + 850.0, + 874.0, + 739.0, + 804.0, + 837.0, + 795.0, + 842.0, + 900.0, + 919.0, + 776.0, + 811.0, + 784.0, + 678.0, + 693.0, + 736.0, + 766.0, + 726.0, + 718.0, + 696.0, + 656.0, + 580.0, + 613.0, + 602.0, + 601.0, + 548.0, + 506.0, + 592.0, + 589.0, + 564.0, + 557.0, + 493.0, + 537.0, + 473.0, + 456.0, + 514.0, + 446.0, + 430.0, + 456.0, + 382.0, + 363.0, + 362.0, + 386.0, + 304.0, + 321.0, + 305.0, + 295.0, + 277.0, + 266.0, + 244.0, + 273.0, + 252.0, + 281.0, + 284.0, + 285.0, + 256.0, + 198.0, + 4950.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 11.0, + 107.0, + 12783.0, + 6308.0, + 14417.0, + 15531.0, + 49951.0, + 405159.0, + 30352.0, + 22692.0, + 17877.0, + 14103.0, + 15192.0, + 13212.0, + 10763.0, + 16796.0, + 15034.0, + 17811.0, + 22069.0, + 27785.0, + 36718.0, + 27989.0, + 28932.0, + 33974.0, + 41056.0, + 50606.0, + 51989.0, + 421454.0, + 58430.0, + 62082.0, + 61936.0, + 51017.0, + 46798.0, + 48259.0, + 171805.0, + 54932.0, + 53168.0, + 60217.0, + 76502.0, + 69326.0, + 62096.0, + 64684.0, + 79732.0, + 122044.0, + 100293.0, + 79872.0, + 76019.0, + 70835.0, + 66189.0, + 69945.0, + 70393.0, + 68098.0, + 68573.0, + 67464.0, + 67679.0, + 71493.0, + 74940.0, + 67426.0, + 61322.0, + 61971.0, + 65385.0, + 68230.0, + 72820.0, + 73190.0, + 71263.0, + 69606.0, + 67354.0, + 66548.0, + 62248.0, + 56384.0, + 55760.0, + 53745.0, + 53599.0, + 50364.0, + 50708.0, + 50680.0, + 48921.0, + 49170.0, + 48760.0, + 49329.0, + 45319.0, + 42992.0, + 40801.0, + 41060.0, + 42644.0, + 42630.0, + 42291.0, + 39510.0, + 37414.0, + 35280.0, + 34173.0, + 33253.0, + 33139.0, + 31717.0, + 29493.0, + 28343.0, + 27721.0, + 26640.0, + 26630.0, + 25821.0, + 24505.0, + 23804.0, + 23586.0, + 23808.0, + 23447.0, + 22514.0, + 21576.0, + 20758.0, + 19790.0, + 18613.0, + 17916.0, + 17100.0, + 16370.0, + 15916.0, + 15238.0, + 14681.0, + 13833.0, + 13309.0, + 12966.0, + 12627.0, + 11834.0, + 11444.0, + 10869.0, + 10609.0, + 10325.0, + 9816.0, + 9876.0, + 9564.0, + 191296.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 4.0, + 24.0, + 21.0, + 5.0, + 3.0, + 0.0, + 1.0, + 0.0, + 1.0, + 3.0, + 6.0, + 2.0, + 4.0, + 7.0, + 5.0, + 7.0, + 9.0, + 10.0, + 7.0, + 4.0, + 9.0, + 7.0, + 8.0, + 42.0, + 18.0, + 3.0, + 5.0, + 7.0, + 8.0, + 2.0, + 4.0, + 27.0, + 23.0, + 12.0, + 7.0, + 6.0, + 5.0, + 3.0, + 8.0, + 8.0, + 5.0, + 3.0, + 4.0, + 5.0, + 5.0, + 4.0, + 2.0, + 11.0, + 7.0, + 5.0, + 7.0, + 2.0, + 4.0, + 8.0, + 1.0, + 12.0, + 4.0, + 10.0, + 10.0, + 7.0, + 4.0, + 2.0, + 6.0, + 9.0, + 5.0, + 6.0, + 4.0, + 7.0, + 15.0, + 13.0, + 7.0, + 12.0, + 12.0, + 10.0, + 9.0, + 9.0, + 18.0, + 5.0, + 5.0, + 5.0, + 6.0, + 10.0, + 9.0, + 7.0, + 7.0, + 5.0, + 8.0, + 5.0, + 3.0, + 8.0, + 8.0, + 3.0, + 1.0, + 2.0, + 3.0, + 11.0, + 11.0, + 6.0, + 5.0, + 4.0, + 7.0, + 3.0, + 4.0, + 3.0, + 2.0, + 2.0, + 2.0, + 3.0, + 8.0, + 6.0, + 3.0, + 2.0, + 3.0, + 0.0, + 0.0, + 3.0, + 64.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 51.0, + 67.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 6689.0, + 8960.0, + 532.0, + 380.0, + 17728.0, + 11660.0, + 7807.0, + 9599.0, + 9371.0, + 6314.0, + 5428.0, + 6040.0, + 5126.0, + 10052.0, + 29493.0, + 6552.0, + 7160.0, + 7073.0, + 7879.0, + 12356.0, + 7720.0, + 6342.0, + 7555.0, + 9430.0, + 10680.0, + 26466.0, + 37932.0, + 13523.0, + 12980.0, + 10149.0, + 9176.0, + 9383.0, + 15076.0, + 23576.0, + 34687.0, + 11721.0, + 13309.0, + 15226.0, + 15332.0, + 11620.0, + 13037.0, + 25208.0, + 42845.0, + 24366.0, + 11861.0, + 10646.0, + 10194.0, + 12209.0, + 11196.0, + 10755.0, + 10283.0, + 10638.0, + 10713.0, + 10383.0, + 12057.0, + 17695.0, + 17017.0, + 9796.0, + 9102.0, + 9533.0, + 8909.0, + 9580.0, + 11694.0, + 10158.0, + 10480.0, + 10132.0, + 9358.0, + 8834.0, + 6814.0, + 6510.0, + 5940.0, + 6109.0, + 5558.0, + 5680.0, + 6050.0, + 6902.0, + 6613.0, + 6609.0, + 5684.0, + 5670.0, + 4656.0, + 4405.0, + 4625.0, + 4316.0, + 4249.0, + 4077.0, + 3734.0, + 3693.0, + 3556.0, + 3504.0, + 3322.0, + 3185.0, + 3032.0, + 2727.0, + 2705.0, + 2760.0, + 2727.0, + 2731.0, + 2709.0, + 2298.0, + 2470.0, + 2315.0, + 2238.0, + 2564.0, + 2094.0, + 1977.0, + 1862.0, + 1653.0, + 1566.0, + 1541.0, + 1587.0, + 1527.0, + 1468.0, + 1332.0, + 1165.0, + 1226.0, + 1116.0, + 1093.0, + 1090.0, + 1153.0, + 916.0, + 912.0, + 921.0, + 912.0, + 16802.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 462.0, + 70.0, + 118.0, + 12038.0, + 14407.0, + 6509.0, + 5773.0, + 3787.0, + 3110.0, + 4216.0, + 4896.0, + 5184.0, + 5196.0, + 4404.0, + 4542.0, + 5149.0, + 4847.0, + 4488.0, + 5108.0, + 5232.0, + 5528.0, + 9015.0, + 5618.0, + 6217.0, + 11406.0, + 20097.0, + 13161.0, + 11755.0, + 12728.0, + 8613.0, + 8252.0, + 7822.0, + 8932.0, + 28596.0, + 7489.0, + 8214.0, + 8604.0, + 11896.0, + 11264.0, + 9768.0, + 12897.0, + 24431.0, + 21360.0, + 24947.0, + 9705.0, + 8301.0, + 7925.0, + 8189.0, + 8763.0, + 8196.0, + 8448.0, + 7828.0, + 8132.0, + 9564.0, + 9165.0, + 9394.0, + 9230.0, + 8778.0, + 8343.0, + 8851.0, + 8729.0, + 7803.0, + 8433.0, + 8319.0, + 8194.0, + 7948.0, + 7493.0, + 6862.0, + 6501.0, + 5938.0, + 5826.0, + 5831.0, + 5826.0, + 5777.0, + 6066.0, + 5910.0, + 5844.0, + 5654.0, + 5383.0, + 5302.0, + 4781.0, + 4298.0, + 4573.0, + 4738.0, + 4929.0, + 4724.0, + 4486.0, + 3941.0, + 3838.0, + 3840.0, + 3577.0, + 3342.0, + 3158.0, + 3037.0, + 3043.0, + 2989.0, + 2924.0, + 3322.0, + 2746.0, + 2630.0, + 2561.0, + 2377.0, + 2329.0, + 2356.0, + 2256.0, + 2166.0, + 2147.0, + 1922.0, + 1777.0, + 1744.0, + 1645.0, + 1645.0, + 1562.0, + 1560.0, + 1497.0, + 1383.0, + 1386.0, + 1283.0, + 1217.0, + 1218.0, + 1260.0, + 1180.0, + 1068.0, + 964.0, + 21521.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 40.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 64.0, + 20.0, + 1.0, + 22.0, + 11.0, + 11.0, + 28.0, + 11.0, + 16.0, + 23.0, + 41.0, + 49.0, + 48.0, + 50.0, + 34.0, + 50.0, + 22.0, + 27.0, + 67.0, + 72.0, + 44.0, + 66.0, + 73.0, + 31.0, + 28.0, + 42.0, + 18.0, + 31.0, + 14.0, + 22.0, + 37.0, + 34.0, + 21.0, + 27.0, + 31.0, + 14.0, + 24.0, + 20.0, + 24.0, + 97.0, + 97.0, + 32.0, + 22.0, + 19.0, + 19.0, + 23.0, + 17.0, + 32.0, + 30.0, + 17.0, + 18.0, + 19.0, + 23.0, + 32.0, + 27.0, + 37.0, + 30.0, + 26.0, + 24.0, + 14.0, + 13.0, + 12.0, + 17.0, + 18.0, + 10.0, + 5.0, + 8.0, + 19.0, + 20.0, + 17.0, + 20.0, + 9.0, + 12.0, + 8.0, + 7.0, + 15.0, + 12.0, + 12.0, + 11.0, + 8.0, + 7.0, + 6.0, + 8.0, + 4.0, + 7.0, + 5.0, + 4.0, + 7.0, + 6.0, + 5.0, + 3.0, + 7.0, + 6.0, + 5.0, + 5.0, + 5.0, + 4.0, + 3.0, + 7.0, + 2.0, + 2.0, + 3.0, + 2.0, + 2.0, + 2.0, + 3.0, + 5.0, + 5.0, + 2.0, + 0.0, + 2.0, + 2.0, + 1.0, + 2.0, + 3.0, + 1.0, + 2.0, + 2.0, + 1.0, + 3.0, + 43.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 50.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 19.0, + 3701.0, + 123.0, + 546.0, + 4454.0, + 1162.0, + 594.0, + 441.0, + 816.0, + 867.0, + 809.0, + 700.0, + 573.0, + 854.0, + 2270.0, + 892.0, + 883.0, + 894.0, + 819.0, + 804.0, + 865.0, + 966.0, + 828.0, + 1147.0, + 1505.0, + 3902.0, + 4672.0, + 2101.0, + 1043.0, + 1658.0, + 1199.0, + 1198.0, + 1178.0, + 1917.0, + 9766.0, + 1175.0, + 1150.0, + 1310.0, + 1392.0, + 2121.0, + 2732.0, + 2261.0, + 2384.0, + 1445.0, + 1467.0, + 1202.0, + 1204.0, + 1221.0, + 1511.0, + 1494.0, + 1331.0, + 1264.0, + 1265.0, + 1108.0, + 1205.0, + 1768.0, + 3072.0, + 1508.0, + 1109.0, + 1108.0, + 1266.0, + 1179.0, + 1322.0, + 1228.0, + 1150.0, + 1063.0, + 1007.0, + 1325.0, + 1007.0, + 716.0, + 663.0, + 746.0, + 680.0, + 667.0, + 682.0, + 881.0, + 800.0, + 697.0, + 698.0, + 638.0, + 565.0, + 525.0, + 537.0, + 508.0, + 452.0, + 520.0, + 492.0, + 494.0, + 474.0, + 404.0, + 418.0, + 404.0, + 376.0, + 321.0, + 316.0, + 293.0, + 327.0, + 339.0, + 408.0, + 266.0, + 241.0, + 269.0, + 247.0, + 220.0, + 235.0, + 231.0, + 189.0, + 184.0, + 170.0, + 172.0, + 185.0, + 149.0, + 153.0, + 156.0, + 118.0, + 109.0, + 113.0, + 94.0, + 112.0, + 119.0, + 94.0, + 1706.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 2.0, + 7.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 12.0, + 8.0, + 2.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 1.0, + 8.0, + 3.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 2.0, + 13.0, + 0.0, + 0.0, + 0.0, + 14.0, + 21.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 2.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, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 9.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 2.0, + 2.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 1.0, + 2.0, + 3.0, + 2.0, + 1.0, + 3.0, + 4.0, + 1.0, + 3.0, + 2.0, + 0.0, + 1.0, + 1.0, + 4.0, + 2.0, + 2.0, + 5.0, + 3.0, + 3.0, + 1.0, + 4.0, + 0.0, + 2.0, + 0.0, + 3.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 1.0, + 2.0, + 2.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 3.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 1.0, + 1.0, + 3.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 3.0, + 0.0, + 1.0, + 1.0, + 2.0, + 2.0, + 1.0, + 0.0, + 0.0, + 2.0, + 2.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.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, + 1.0, + 1.0, + 0.0, + 2.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 335097.0, + 36.0, + 1.0, + 20.0, + 332.0, + 14155.0, + 8899.0, + 3766.0, + 681.0, + 418.0, + 239.0, + 251.0, + 525.0, + 2091.0, + 2217.0, + 765.0, + 536.0, + 496.0, + 781.0, + 2370.0, + 8244.0, + 1339.0, + 466.0, + 706.0, + 997.0, + 24429.0, + 7803.0, + 1815.0, + 1636.0, + 2558.0, + 2257.0, + 1974.0, + 2311.0, + 7855.0, + 1777.0, + 1848.0, + 1863.0, + 2080.0, + 3855.0, + 3470.0, + 2747.0, + 3378.0, + 3812.0, + 3953.0, + 3444.0, + 1943.0, + 1822.0, + 2772.0, + 1989.0, + 2002.0, + 2441.0, + 1580.0, + 1401.0, + 1486.0, + 1608.0, + 1409.0, + 1326.0, + 1334.0, + 1459.0, + 1449.0, + 1539.0, + 1742.0, + 1708.0, + 1535.0, + 1508.0, + 1588.0, + 1360.0, + 1367.0, + 1489.0, + 1612.0, + 1684.0, + 1594.0, + 1463.0, + 1347.0, + 1347.0, + 1486.0, + 1357.0, + 1383.0, + 1271.0, + 1238.0, + 1239.0, + 1205.0, + 1196.0, + 1333.0, + 1300.0, + 1272.0, + 1333.0, + 1221.0, + 1256.0, + 1232.0, + 1359.0, + 1429.0, + 1446.0, + 1189.0, + 1132.0, + 1145.0, + 1130.0, + 1154.0, + 1033.0, + 1077.0, + 1052.0, + 1057.0, + 1112.0, + 1119.0, + 976.0, + 1039.0, + 845.0, + 818.0, + 851.0, + 813.0, + 820.0, + 849.0, + 774.0, + 790.0, + 755.0, + 668.0, + 595.0, + 560.0, + 633.0, + 549.0, + 594.0, + 528.0, + 558.0, + 500.0, + 546.0, + 547.0, + 462.0, + 415.0, + 9514.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, + 9.0, + 33.0, + 13389.0, + 18194.0, + 3208.0, + 15226.0, + 48181.0, + 83900.0, + 406830.0, + 122311.0, + 61589.0, + 43897.0, + 32792.0, + 33766.0, + 34244.0, + 61668.0, + 34477.0, + 29019.0, + 34294.0, + 67600.0, + 99160.0, + 91577.0, + 123575.0, + 71989.0, + 67405.0, + 63248.0, + 65444.0, + 405084.0, + 131648.0, + 114245.0, + 141881.0, + 116418.0, + 131541.0, + 109789.0, + 200400.0, + 92501.0, + 99935.0, + 122058.0, + 136437.0, + 121460.0, + 158355.0, + 129014.0, + 205468.0, + 234299.0, + 165317.0, + 139185.0, + 141011.0, + 149002.0, + 141069.0, + 151394.0, + 157555.0, + 155096.0, + 153669.0, + 148978.0, + 140198.0, + 143395.0, + 141603.0, + 133547.0, + 125271.0, + 122317.0, + 125105.0, + 129873.0, + 137263.0, + 138144.0, + 140695.0, + 139771.0, + 137465.0, + 139452.0, + 133780.0, + 127886.0, + 128991.0, + 125767.0, + 124242.0, + 122051.0, + 116887.0, + 113580.0, + 110856.0, + 109366.0, + 104871.0, + 103674.0, + 98776.0, + 96035.0, + 94187.0, + 93466.0, + 96011.0, + 96236.0, + 94236.0, + 91275.0, + 88723.0, + 85633.0, + 84048.0, + 81161.0, + 80080.0, + 77346.0, + 73670.0, + 70734.0, + 69293.0, + 68141.0, + 65314.0, + 62958.0, + 60759.0, + 59241.0, + 57957.0, + 56965.0, + 56208.0, + 55372.0, + 54048.0, + 52438.0, + 50494.0, + 48261.0, + 47572.0, + 45912.0, + 43689.0, + 42188.0, + 40306.0, + 38497.0, + 37197.0, + 35898.0, + 35230.0, + 33439.0, + 32376.0, + 31056.0, + 29871.0, + 28822.0, + 28068.0, + 27534.0, + 26593.0, + 25822.0, + 25022.0, + 23967.0, + 23281.0, + 22217.0, + 496498.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 2.0, + 3.0, + 18.0, + 7.0, + 4.0, + 0.0, + 1.0, + 3.0, + 3.0, + 3.0, + 1.0, + 0.0, + 0.0, + 2.0, + 1.0, + 3.0, + 3.0, + 3.0, + 5.0, + 6.0, + 5.0, + 8.0, + 3.0, + 11.0, + 289.0, + 11.0, + 12.0, + 4.0, + 8.0, + 9.0, + 8.0, + 6.0, + 20.0, + 19.0, + 31.0, + 39.0, + 15.0, + 16.0, + 18.0, + 28.0, + 18.0, + 7.0, + 15.0, + 8.0, + 5.0, + 15.0, + 8.0, + 5.0, + 2.0, + 10.0, + 9.0, + 12.0, + 3.0, + 6.0, + 9.0, + 7.0, + 15.0, + 53.0, + 37.0, + 26.0, + 20.0, + 14.0, + 11.0, + 16.0, + 14.0, + 5.0, + 9.0, + 9.0, + 5.0, + 9.0, + 11.0, + 15.0, + 16.0, + 13.0, + 18.0, + 11.0, + 20.0, + 11.0, + 20.0, + 10.0, + 13.0, + 6.0, + 16.0, + 14.0, + 14.0, + 7.0, + 16.0, + 15.0, + 10.0, + 18.0, + 10.0, + 6.0, + 12.0, + 6.0, + 4.0, + 10.0, + 5.0, + 8.0, + 7.0, + 7.0, + 2.0, + 4.0, + 7.0, + 8.0, + 3.0, + 5.0, + 8.0, + 5.0, + 6.0, + 5.0, + 5.0, + 5.0, + 3.0, + 3.0, + 2.0, + 4.0, + 5.0, + 2.0, + 2.0, + 0.0, + 45.0 + ], + [ + 0.0, + 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, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3388.0, + 160.0, + 25.0, + 924.0, + 11605.0, + 31718.0, + 32965.0, + 23408.0, + 15341.0, + 12006.0, + 8895.0, + 14120.0, + 12670.0, + 31627.0, + 15879.0, + 6882.0, + 7500.0, + 9707.0, + 18585.0, + 23582.0, + 23613.0, + 20248.0, + 19859.0, + 19859.0, + 19166.0, + 40511.0, + 30970.0, + 39176.0, + 30703.0, + 30224.0, + 24304.0, + 22186.0, + 33933.0, + 43961.0, + 24232.0, + 30498.0, + 31050.0, + 27057.0, + 33433.0, + 33557.0, + 49140.0, + 67907.0, + 34611.0, + 27793.0, + 26107.0, + 25689.0, + 26842.0, + 32658.0, + 32025.0, + 27374.0, + 28185.0, + 28326.0, + 24931.0, + 25926.0, + 29433.0, + 28585.0, + 22071.0, + 22378.0, + 21678.0, + 21364.0, + 23772.0, + 27509.0, + 27583.0, + 24330.0, + 22608.0, + 21205.0, + 19382.0, + 17703.0, + 17519.0, + 17402.0, + 16084.0, + 15778.0, + 15509.0, + 15852.0, + 15794.0, + 16430.0, + 15491.0, + 14808.0, + 13831.0, + 13294.0, + 12796.0, + 12970.0, + 13276.0, + 12847.0, + 11927.0, + 11586.0, + 11305.0, + 11096.0, + 10268.0, + 10330.0, + 9613.0, + 9117.0, + 8671.0, + 8444.0, + 8563.0, + 8119.0, + 8093.0, + 8084.0, + 7600.0, + 7215.0, + 6993.0, + 6731.0, + 6915.0, + 6583.0, + 6463.0, + 6502.0, + 6007.0, + 5593.0, + 5644.0, + 5457.0, + 5290.0, + 4920.0, + 4865.0, + 4369.0, + 4201.0, + 4038.0, + 4111.0, + 3867.0, + 3844.0, + 3570.0, + 3403.0, + 3244.0, + 3304.0, + 3209.0, + 3029.0, + 2887.0, + 2667.0, + 50372.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 13071.0, + 24077.0, + 596.0, + 34.0, + 1053.0, + 13078.0, + 22847.0, + 14610.0, + 10820.0, + 7630.0, + 5044.0, + 4999.0, + 6513.0, + 6516.0, + 13501.0, + 4042.0, + 6598.0, + 4962.0, + 4872.0, + 8374.0, + 10552.0, + 10678.0, + 14798.0, + 12396.0, + 14933.0, + 19361.0, + 24995.0, + 17801.0, + 21991.0, + 19781.0, + 16447.0, + 13845.0, + 13646.0, + 22108.0, + 13907.0, + 14238.0, + 14797.0, + 16491.0, + 20855.0, + 19015.0, + 27025.0, + 37069.0, + 27731.0, + 31792.0, + 18537.0, + 15959.0, + 17224.0, + 20149.0, + 21631.0, + 21189.0, + 21744.0, + 19542.0, + 17329.0, + 18630.0, + 18066.0, + 17913.0, + 16938.0, + 14723.0, + 13903.0, + 15216.0, + 15479.0, + 15155.0, + 16376.0, + 18233.0, + 15453.0, + 15416.0, + 14550.0, + 13633.0, + 13293.0, + 13366.0, + 13685.0, + 13126.0, + 12401.0, + 12153.0, + 12171.0, + 11623.0, + 12008.0, + 11489.0, + 11339.0, + 10931.0, + 11106.0, + 10285.0, + 10279.0, + 10761.0, + 10459.0, + 9980.0, + 9578.0, + 9202.0, + 8668.0, + 8780.0, + 8354.0, + 8011.0, + 7602.0, + 7349.0, + 7492.0, + 7293.0, + 6952.0, + 7577.0, + 6699.0, + 6513.0, + 6156.0, + 6154.0, + 5900.0, + 5880.0, + 5805.0, + 5702.0, + 5718.0, + 5337.0, + 5113.0, + 4924.0, + 4716.0, + 4786.0, + 4647.0, + 4527.0, + 4208.0, + 3970.0, + 3996.0, + 3613.0, + 3443.0, + 3486.0, + 3127.0, + 3312.0, + 3169.0, + 3321.0, + 3015.0, + 2931.0, + 2675.0, + 2696.0, + 54983.0 + ], + [ + 0.0, + 0.0, + 0.0, + 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, + 12.0, + 402.0, + 299.0, + 3.0, + 9.0, + 11.0, + 13.0, + 9.0, + 12.0, + 21.0, + 21.0, + 29.0, + 21.0, + 8.0, + 9.0, + 27.0, + 36.0, + 38.0, + 37.0, + 24.0, + 21.0, + 27.0, + 43.0, + 48.0, + 68.0, + 33.0, + 37.0, + 37.0, + 32.0, + 37.0, + 38.0, + 45.0, + 39.0, + 55.0, + 31.0, + 37.0, + 38.0, + 35.0, + 71.0, + 106.0, + 101.0, + 97.0, + 100.0, + 45.0, + 47.0, + 46.0, + 45.0, + 57.0, + 31.0, + 47.0, + 49.0, + 25.0, + 31.0, + 40.0, + 20.0, + 38.0, + 30.0, + 26.0, + 28.0, + 20.0, + 23.0, + 19.0, + 29.0, + 27.0, + 30.0, + 17.0, + 16.0, + 26.0, + 10.0, + 17.0, + 16.0, + 8.0, + 15.0, + 15.0, + 13.0, + 16.0, + 11.0, + 6.0, + 6.0, + 12.0, + 7.0, + 23.0, + 12.0, + 14.0, + 20.0, + 6.0, + 11.0, + 7.0, + 7.0, + 6.0, + 8.0, + 10.0, + 14.0, + 18.0, + 25.0, + 8.0, + 13.0, + 6.0, + 13.0, + 10.0, + 13.0, + 6.0, + 9.0, + 10.0, + 4.0, + 14.0, + 11.0, + 3.0, + 4.0, + 8.0, + 3.0, + 6.0, + 7.0, + 6.0, + 8.0, + 5.0, + 8.0, + 6.0, + 8.0, + 3.0, + 4.0, + 6.0, + 6.0, + 3.0, + 136.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 280.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 718.0, + 3659.0, + 116.0, + 359.0, + 3183.0, + 2537.0, + 1737.0, + 774.0, + 526.0, + 772.0, + 1369.0, + 1518.0, + 1273.0, + 1236.0, + 1340.0, + 604.0, + 883.0, + 1153.0, + 1297.0, + 1873.0, + 2043.0, + 1971.0, + 1700.0, + 1436.0, + 1867.0, + 4931.0, + 3171.0, + 3290.0, + 2864.0, + 2249.0, + 2377.0, + 2190.0, + 2481.0, + 8442.0, + 2409.0, + 2400.0, + 2873.0, + 3323.0, + 3984.0, + 4059.0, + 4396.0, + 7502.0, + 2819.0, + 2552.0, + 2745.0, + 2523.0, + 2334.0, + 2897.0, + 2682.0, + 2708.0, + 2888.0, + 2817.0, + 2310.0, + 2283.0, + 2681.0, + 3425.0, + 2682.0, + 2043.0, + 2005.0, + 2167.0, + 2043.0, + 2298.0, + 2428.0, + 2482.0, + 2026.0, + 1910.0, + 2094.0, + 2049.0, + 1655.0, + 1814.0, + 1533.0, + 1494.0, + 1396.0, + 1465.0, + 1728.0, + 1620.0, + 1564.0, + 1432.0, + 1441.0, + 1233.0, + 1219.0, + 1163.0, + 1269.0, + 1180.0, + 1151.0, + 1234.0, + 1188.0, + 1136.0, + 969.0, + 921.0, + 962.0, + 842.0, + 864.0, + 827.0, + 996.0, + 874.0, + 902.0, + 854.0, + 700.0, + 676.0, + 621.0, + 580.0, + 619.0, + 623.0, + 621.0, + 542.0, + 594.0, + 574.0, + 464.0, + 480.0, + 440.0, + 393.0, + 394.0, + 399.0, + 332.0, + 345.0, + 336.0, + 310.0, + 317.0, + 292.0, + 269.0, + 231.0, + 208.0, + 219.0, + 3629.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 56.0, + 0.0, + 0.0, + 0.0, + 2.0, + 3.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 3.0, + 2.0, + 4.0, + 21.0, + 8.0, + 6.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 3.0, + 6.0, + 1.0, + 1.0, + 3.0, + 2.0, + 1.0, + 0.0, + 26.0, + 1.0, + 6.0, + 7.0, + 7.0, + 26.0, + 3.0, + 3.0, + 3.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.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, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.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, + 1.0, + 5.0, + 7.0, + 4.0, + 1.0, + 8.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 3.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 26.0, + 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, + 0.0, + 5.0, + 2.0, + 0.0, + 2.0, + 0.0, + 3.0, + 1.0, + 0.0, + 1.0, + 1.0, + 5.0, + 6.0, + 2.0, + 0.0, + 1.0, + 1.0, + 5.0, + 0.0, + 1.0, + 1.0, + 2.0, + 4.0, + 3.0, + 1.0, + 2.0, + 0.0, + 1.0, + 4.0, + 5.0, + 0.0, + 5.0, + 6.0, + 5.0, + 4.0, + 2.0, + 21.0, + 4.0, + 13.0, + 11.0, + 4.0, + 3.0, + 2.0, + 5.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 4.0, + 25.0, + 0.0, + 2.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.0, + 5.0, + 1.0, + 1.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 2.0, + 1.0, + 2.0, + 1.0, + 2.0, + 2.0, + 1.0, + 1.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 6.0, + 2.0, + 1.0, + 4.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 3.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 13.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4.0, + 93.0, + 192551.0, + 12412586.0, + 174695.0, + 88962.0, + 313902.0, + 929022.0, + 2129051.0, + 4200516.0, + 14762319.0, + 5933212.0, + 2106146.0, + 1702322.0, + 1660533.0, + 2599942.0, + 6434859.0, + 1927861.0, + 1240209.0, + 1288300.0, + 1735746.0, + 3001431.0, + 4935946.0, + 6762089.0, + 3212952.0, + 3357441.0, + 3790643.0, + 4451367.0, + 8672921.0, + 4548173.0, + 4730524.0, + 5082269.0, + 5740135.0, + 5513091.0, + 5333356.0, + 7046241.0, + 3864201.0, + 3662328.0, + 3768289.0, + 4101049.0, + 4320496.0, + 3977518.0, + 3694881.0, + 4317971.0, + 5347035.0, + 5149391.0, + 4593211.0, + 4393523.0, + 4153028.0, + 4190092.0, + 4530600.0, + 4521398.0, + 4527607.0, + 4496022.0, + 4362174.0, + 4273255.0, + 4275765.0, + 4137577.0, + 3796387.0, + 3579186.0, + 3468883.0, + 3494174.0, + 3602863.0, + 3671686.0, + 3681642.0, + 3532411.0, + 3458790.0, + 3407496.0, + 3352653.0, + 3200681.0, + 3121009.0, + 3103300.0, + 3107082.0, + 3057355.0, + 2997191.0, + 2872662.0, + 2756960.0, + 2662521.0, + 2570550.0, + 2474679.0, + 2374731.0, + 2280919.0, + 2191621.0, + 2137477.0, + 2114694.0, + 2103447.0, + 2054902.0, + 1987858.0, + 1914716.0, + 1848226.0, + 1802683.0, + 1747343.0, + 1712208.0, + 1673495.0, + 1613262.0, + 1550063.0, + 1494001.0, + 1444001.0, + 1387624.0, + 1324072.0, + 1260954.0, + 1207050.0, + 1162475.0, + 1124520.0, + 1092663.0, + 1058868.0, + 1026456.0, + 991534.0, + 961411.0, + 923711.0, + 885247.0, + 849497.0, + 826943.0, + 803813.0, + 774241.0, + 749186.0, + 718249.0, + 686347.0, + 659993.0, + 631820.0, + 600402.0, + 574534.0, + 546554.0, + 521044.0, + 500127.0, + 482403.0, + 466796.0, + 452575.0, + 433391.0, + 417465.0, + 400063.0, + 385677.0, + 368586.0, + 355244.0, + 343058.0, + 7371414.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 52.0, + 548.0, + 191.0, + 227.0, + 210.0, + 3214.0, + 4732.0, + 394.0, + 426.0, + 381.0, + 213.0, + 183.0, + 222.0, + 371.0, + 520.0, + 400.0, + 483.0, + 446.0, + 403.0, + 512.0, + 575.0, + 784.0, + 633.0, + 779.0, + 761.0, + 1188.0, + 5496.0, + 1403.0, + 919.0, + 1202.0, + 862.0, + 803.0, + 825.0, + 797.0, + 2725.0, + 883.0, + 1051.0, + 1178.0, + 1371.0, + 1160.0, + 1004.0, + 1243.0, + 1756.0, + 2157.0, + 1670.0, + 1339.0, + 1222.0, + 1136.0, + 1114.0, + 1093.0, + 998.0, + 1110.0, + 1029.0, + 951.0, + 973.0, + 1071.0, + 1340.0, + 982.0, + 868.0, + 880.0, + 1035.0, + 1027.0, + 1093.0, + 1033.0, + 1088.0, + 966.0, + 892.0, + 936.0, + 828.0, + 807.0, + 765.0, + 708.0, + 685.0, + 679.0, + 669.0, + 623.0, + 675.0, + 715.0, + 711.0, + 774.0, + 753.0, + 605.0, + 651.0, + 636.0, + 668.0, + 612.0, + 575.0, + 586.0, + 562.0, + 497.0, + 442.0, + 422.0, + 462.0, + 495.0, + 435.0, + 408.0, + 439.0, + 439.0, + 364.0, + 370.0, + 333.0, + 324.0, + 320.0, + 300.0, + 332.0, + 288.0, + 277.0, + 301.0, + 285.0, + 247.0, + 279.0, + 196.0, + 203.0, + 221.0, + 198.0, + 180.0, + 201.0, + 183.0, + 184.0, + 172.0, + 140.0, + 146.0, + 131.0, + 149.0, + 2396.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2462.0, + 2007760.0, + 193.0, + 3.0, + 21.0, + 48.0, + 714.0, + 3497.0, + 6195.0, + 29753.0, + 42313.0, + 5272115.0, + 2421139.0, + 79080.0, + 210657.0, + 580344.0, + 1124513.0, + 2722649.0, + 2821587.0, + 1189254.0, + 701209.0, + 594097.0, + 1070074.0, + 1479394.0, + 2921672.0, + 1006392.0, + 524918.0, + 629908.0, + 909504.0, + 1483639.0, + 1696414.0, + 1739331.0, + 1573827.0, + 1327085.0, + 1466517.0, + 1451869.0, + 2695668.0, + 1824187.0, + 1573377.0, + 1623872.0, + 1545640.0, + 1422946.0, + 1476541.0, + 1640273.0, + 1201256.0, + 1303686.0, + 1359286.0, + 1437000.0, + 1407501.0, + 1231856.0, + 1297969.0, + 1643014.0, + 1794179.0, + 1484127.0, + 1342059.0, + 1267067.0, + 1198041.0, + 1215855.0, + 1311199.0, + 1291161.0, + 1197108.0, + 1135730.0, + 1110470.0, + 1104068.0, + 1103539.0, + 1059668.0, + 936926.0, + 841691.0, + 826573.0, + 837727.0, + 860045.0, + 932801.0, + 932004.0, + 894162.0, + 880678.0, + 844531.0, + 798307.0, + 726798.0, + 690646.0, + 679356.0, + 670124.0, + 650721.0, + 613632.0, + 578609.0, + 559615.0, + 551285.0, + 541593.0, + 507782.0, + 478546.0, + 446142.0, + 428242.0, + 417172.0, + 424383.0, + 431861.0, + 423943.0, + 399422.0, + 377765.0, + 363501.0, + 350552.0, + 334079.0, + 316389.0, + 301063.0, + 285937.0, + 270969.0, + 262199.0, + 253947.0, + 242433.0, + 230761.0, + 219551.0, + 209163.0, + 199960.0, + 194101.0, + 188646.0, + 185276.0, + 182596.0, + 176939.0, + 171251.0, + 160152.0, + 151324.0, + 145005.0, + 138138.0, + 132862.0, + 127490.0, + 119705.0, + 113519.0, + 108427.0, + 104495.0, + 100462.0, + 96190.0, + 91257.0, + 85967.0, + 81805.0, + 80545.0, + 77101.0, + 75252.0, + 72343.0, + 70304.0, + 67356.0, + 64124.0, + 61656.0, + 1244447.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 385469.0, + 7826.0, + 19.0, + 18.0, + 114.0, + 1312.0, + 5490.0, + 16813.0, + 30526.0, + 49559.0, + 70808.0, + 2974705.0, + 122283.0, + 299751.0, + 602478.0, + 1098059.0, + 1906069.0, + 1804534.0, + 1459345.0, + 710233.0, + 519310.0, + 620343.0, + 1304335.0, + 839320.0, + 1193296.0, + 481585.0, + 550782.0, + 625366.0, + 766829.0, + 1168229.0, + 1240608.0, + 1177672.0, + 1194691.0, + 1050733.0, + 1072693.0, + 1640209.0, + 1959801.0, + 1333776.0, + 1406169.0, + 1434788.0, + 1342345.0, + 1152801.0, + 1127152.0, + 1253749.0, + 939267.0, + 1001551.0, + 1094086.0, + 1130571.0, + 1191420.0, + 1047066.0, + 1298367.0, + 1499641.0, + 1500227.0, + 1288979.0, + 1089535.0, + 1015225.0, + 1017257.0, + 1106197.0, + 1115888.0, + 1111752.0, + 1066525.0, + 1025074.0, + 983960.0, + 1003496.0, + 993436.0, + 892991.0, + 815974.0, + 769359.0, + 766952.0, + 770964.0, + 794326.0, + 821154.0, + 821623.0, + 801079.0, + 788809.0, + 766640.0, + 728694.0, + 697630.0, + 680311.0, + 677568.0, + 668411.0, + 652119.0, + 618002.0, + 588803.0, + 567361.0, + 553606.0, + 525191.0, + 494455.0, + 475255.0, + 454032.0, + 437553.0, + 427856.0, + 429596.0, + 432057.0, + 424405.0, + 407453.0, + 391643.0, + 380154.0, + 363853.0, + 350768.0, + 341871.0, + 329671.0, + 309411.0, + 300458.0, + 286848.0, + 277180.0, + 263532.0, + 250569.0, + 238179.0, + 226839.0, + 220488.0, + 213737.0, + 210210.0, + 206535.0, + 199129.0, + 190865.0, + 185425.0, + 176644.0, + 169771.0, + 165431.0, + 160046.0, + 151931.0, + 146791.0, + 140037.0, + 133473.0, + 125979.0, + 121459.0, + 115875.0, + 110453.0, + 104585.0, + 98113.0, + 94100.0, + 91500.0, + 89664.0, + 87531.0, + 84877.0, + 80021.0, + 77219.0, + 74298.0, + 70868.0, + 1489618.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 21.0, + 39.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 29.0, + 5023.0, + 1458.0, + 8686.0, + 3583.0, + 992.0, + 2657.0, + 5278.0, + 9175.0, + 9315.0, + 4828.0, + 2775.0, + 2331.0, + 2512.0, + 3546.0, + 3620.0, + 2677.0, + 3639.0, + 2582.0, + 2941.0, + 4087.0, + 6787.0, + 3634.0, + 3272.0, + 3225.0, + 2774.0, + 3281.0, + 6271.0, + 4577.0, + 5314.0, + 3171.0, + 3655.0, + 3508.0, + 3958.0, + 4051.0, + 3797.0, + 4605.0, + 2996.0, + 3065.0, + 3024.0, + 2734.0, + 2731.0, + 3917.0, + 3188.0, + 2582.0, + 2547.0, + 2473.0, + 2982.0, + 2484.0, + 2547.0, + 2600.0, + 2605.0, + 2243.0, + 2089.0, + 2084.0, + 1857.0, + 1642.0, + 1787.0, + 1890.0, + 1524.0, + 1424.0, + 1372.0, + 1478.0, + 1397.0, + 1330.0, + 1253.0, + 1128.0, + 1208.0, + 1132.0, + 1040.0, + 1111.0, + 1041.0, + 920.0, + 988.0, + 893.0, + 824.0, + 785.0, + 719.0, + 830.0, + 740.0, + 669.0, + 604.0, + 609.0, + 637.0, + 674.0, + 688.0, + 638.0, + 686.0, + 578.0, + 556.0, + 618.0, + 620.0, + 571.0, + 504.0, + 478.0, + 423.0, + 421.0, + 391.0, + 414.0, + 362.0, + 313.0, + 360.0, + 351.0, + 313.0, + 299.0, + 274.0, + 288.0, + 266.0, + 294.0, + 288.0, + 267.0, + 276.0, + 263.0, + 262.0, + 263.0, + 260.0, + 206.0, + 197.0, + 177.0, + 170.0, + 187.0, + 164.0, + 142.0, + 156.0, + 146.0, + 139.0, + 144.0, + 142.0, + 151.0, + 3568.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 13380.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 7.0, + 230.0, + 4934.0, + 100436.0, + 300587.0, + 185019.0, + 23241.0, + 109788.0, + 184560.0, + 383993.0, + 160967.0, + 195935.0, + 112812.0, + 79012.0, + 64912.0, + 71833.0, + 194516.0, + 161649.0, + 114047.0, + 67871.0, + 80462.0, + 127726.0, + 107459.0, + 138962.0, + 168110.0, + 174548.0, + 171969.0, + 194621.0, + 255636.0, + 399318.0, + 225619.0, + 175403.0, + 182912.0, + 164133.0, + 164132.0, + 164229.0, + 207003.0, + 170564.0, + 139320.0, + 148396.0, + 163594.0, + 146677.0, + 158715.0, + 156190.0, + 175741.0, + 215013.0, + 198140.0, + 155959.0, + 153936.0, + 141370.0, + 136246.0, + 141022.0, + 150019.0, + 138050.0, + 121670.0, + 117135.0, + 118577.0, + 122473.0, + 129247.0, + 118499.0, + 100064.0, + 95574.0, + 101429.0, + 103747.0, + 109618.0, + 111416.0, + 100809.0, + 100778.0, + 99216.0, + 94580.0, + 92830.0, + 80970.0, + 75886.0, + 71980.0, + 70562.0, + 67860.0, + 64604.0, + 63475.0, + 61636.0, + 60415.0, + 57866.0, + 54846.0, + 51603.0, + 47515.0, + 46048.0, + 46283.0, + 47159.0, + 47194.0, + 44407.0, + 41205.0, + 40250.0, + 38836.0, + 35822.0, + 33154.0, + 31827.0, + 29978.0, + 27516.0, + 26584.0, + 25620.0, + 24853.0, + 24165.0, + 22883.0, + 21395.0, + 20244.0, + 19557.0, + 20163.0, + 19265.0, + 18049.0, + 17653.0, + 17076.0, + 16973.0, + 14938.0, + 14119.0, + 13525.0, + 12532.0, + 12468.0, + 11593.0, + 10924.0, + 10574.0, + 9770.0, + 9296.0, + 8994.0, + 8363.0, + 7979.0, + 7583.0, + 7278.0, + 7105.0, + 6868.0, + 6545.0, + 6285.0, + 107354.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 20.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 446.0, + 565.0, + 5.0, + 27.0, + 81.0, + 648.0, + 392.0, + 169.0, + 78.0, + 43.0, + 41.0, + 37.0, + 41.0, + 82.0, + 169.0, + 61.0, + 47.0, + 59.0, + 60.0, + 84.0, + 57.0, + 248.0, + 120.0, + 122.0, + 85.0, + 215.0, + 674.0, + 135.0, + 152.0, + 123.0, + 112.0, + 119.0, + 112.0, + 281.0, + 77.0, + 102.0, + 85.0, + 85.0, + 96.0, + 101.0, + 81.0, + 153.0, + 192.0, + 150.0, + 107.0, + 113.0, + 131.0, + 116.0, + 84.0, + 70.0, + 57.0, + 55.0, + 75.0, + 73.0, + 103.0, + 89.0, + 57.0, + 31.0, + 50.0, + 65.0, + 60.0, + 49.0, + 57.0, + 82.0, + 74.0, + 55.0, + 59.0, + 55.0, + 72.0, + 58.0, + 49.0, + 27.0, + 43.0, + 43.0, + 28.0, + 25.0, + 30.0, + 18.0, + 28.0, + 18.0, + 12.0, + 32.0, + 28.0, + 20.0, + 23.0, + 21.0, + 19.0, + 19.0, + 23.0, + 16.0, + 26.0, + 18.0, + 24.0, + 8.0, + 9.0, + 22.0, + 13.0, + 19.0, + 12.0, + 18.0, + 14.0, + 19.0, + 16.0, + 7.0, + 10.0, + 12.0, + 16.0, + 7.0, + 10.0, + 4.0, + 5.0, + 6.0, + 1.0, + 2.0, + 10.0, + 5.0, + 4.0, + 7.0, + 3.0, + 4.0, + 7.0, + 2.0, + 1.0, + 2.0, + 3.0, + 38.0 + ], + [ + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 32.0, + 99.0, + 7333.0, + 429.0, + 22.0, + 106.0, + 300.0, + 2204.0, + 3144.0, + 542.0, + 117.0, + 187.0, + 176.0, + 320.0, + 137.0, + 1737.0, + 852.0, + 227.0, + 292.0, + 271.0, + 313.0, + 302.0, + 258.0, + 524.0, + 301.0, + 322.0, + 402.0, + 573.0, + 593.0, + 403.0, + 356.0, + 342.0, + 391.0, + 350.0, + 330.0, + 415.0, + 264.0, + 337.0, + 316.0, + 289.0, + 311.0, + 377.0, + 399.0, + 426.0, + 504.0, + 336.0, + 368.0, + 328.0, + 267.0, + 268.0, + 269.0, + 295.0, + 276.0, + 245.0, + 235.0, + 263.0, + 232.0, + 246.0, + 243.0, + 226.0, + 245.0, + 223.0, + 213.0, + 228.0, + 278.0, + 253.0, + 278.0, + 245.0, + 222.0, + 231.0, + 218.0, + 202.0, + 174.0, + 131.0, + 154.0, + 143.0, + 167.0, + 150.0, + 126.0, + 136.0, + 118.0, + 187.0, + 136.0, + 138.0, + 127.0, + 129.0, + 130.0, + 121.0, + 98.0, + 116.0, + 100.0, + 86.0, + 78.0, + 64.0, + 56.0, + 63.0, + 53.0, + 51.0, + 54.0, + 51.0, + 27.0, + 20.0, + 35.0, + 28.0, + 40.0, + 36.0, + 30.0, + 35.0, + 35.0, + 32.0, + 18.0, + 22.0, + 29.0, + 18.0, + 17.0, + 28.0, + 17.0, + 23.0, + 17.0, + 18.0, + 14.0, + 17.0, + 9.0, + 12.0, + 11.0, + 14.0, + 12.0, + 283.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 30.0, + 13.0, + 0.0, + 1.0, + 11.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 4.0, + 4.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 4.0, + 0.0, + 5.0, + 2.0, + 7.0, + 92.0, + 425.0, + 33.0, + 26.0, + 15.0, + 12.0, + 2.0, + 4.0, + 5.0, + 3.0, + 27.0, + 33.0, + 6.0, + 3.0, + 3.0, + 4.0, + 1.0, + 0.0, + 1.0, + 2.0, + 1.0, + 3.0, + 1.0, + 3.0, + 4.0, + 2.0, + 0.0, + 2.0, + 0.0, + 0.0, + 1.0, + 2.0, + 3.0, + 11.0, + 6.0, + 0.0, + 5.0, + 0.0, + 1.0, + 2.0, + 3.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 10.0, + 1.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + 1.0, + 4.0, + 1.0, + 2.0, + 2.0, + 3.0, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 1.0, + 4.0, + 1.0, + 0.0, + 1.0, + 0.0, + 3.0, + 2.0, + 1.0, + 2.0, + 2.0, + 0.0, + 0.0, + 2.0, + 1.0, + 1.0, + 16.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 1.0, + 170.0, + 180.0, + 68.0, + 315.0, + 72.0, + 43.0, + 58.0, + 77.0, + 116.0, + 74.0, + 46.0, + 45.0, + 168.0, + 622.0, + 438.0, + 50.0, + 62.0, + 65.0, + 115.0, + 135.0, + 88.0, + 78.0, + 116.0, + 191.0, + 221.0, + 331.0, + 296.0, + 375.0, + 211.0, + 205.0, + 124.0, + 141.0, + 301.0, + 1285.0, + 219.0, + 216.0, + 285.0, + 364.0, + 304.0, + 132.0, + 183.0, + 495.0, + 362.0, + 175.0, + 154.0, + 150.0, + 132.0, + 159.0, + 126.0, + 170.0, + 135.0, + 158.0, + 102.0, + 143.0, + 203.0, + 299.0, + 169.0, + 121.0, + 106.0, + 104.0, + 124.0, + 101.0, + 109.0, + 125.0, + 134.0, + 137.0, + 152.0, + 92.0, + 74.0, + 61.0, + 70.0, + 61.0, + 43.0, + 66.0, + 77.0, + 80.0, + 72.0, + 71.0, + 98.0, + 71.0, + 61.0, + 45.0, + 43.0, + 63.0, + 59.0, + 49.0, + 36.0, + 41.0, + 37.0, + 36.0, + 44.0, + 40.0, + 29.0, + 36.0, + 16.0, + 21.0, + 26.0, + 57.0, + 54.0, + 24.0, + 42.0, + 29.0, + 20.0, + 30.0, + 18.0, + 14.0, + 17.0, + 17.0, + 14.0, + 15.0, + 11.0, + 18.0, + 21.0, + 11.0, + 13.0, + 15.0, + 7.0, + 13.0, + 15.0, + 11.0, + 187.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 18.0, + 37.0, + 917.0, + 500.0, + 186.0, + 87.0, + 75.0, + 171.0, + 97.0, + 66.0, + 57.0, + 54.0, + 33.0, + 91.0, + 95.0, + 84.0, + 69.0, + 74.0, + 85.0, + 84.0, + 95.0, + 95.0, + 89.0, + 143.0, + 487.0, + 407.0, + 292.0, + 453.0, + 217.0, + 164.0, + 182.0, + 184.0, + 326.0, + 508.0, + 166.0, + 218.0, + 221.0, + 437.0, + 218.0, + 235.0, + 255.0, + 518.0, + 422.0, + 418.0, + 184.0, + 178.0, + 146.0, + 176.0, + 176.0, + 173.0, + 172.0, + 165.0, + 178.0, + 175.0, + 189.0, + 170.0, + 180.0, + 173.0, + 169.0, + 168.0, + 128.0, + 116.0, + 178.0, + 163.0, + 151.0, + 94.0, + 117.0, + 129.0, + 125.0, + 99.0, + 90.0, + 90.0, + 86.0, + 82.0, + 106.0, + 90.0, + 106.0, + 73.0, + 87.0, + 91.0, + 68.0, + 67.0, + 92.0, + 57.0, + 83.0, + 70.0, + 81.0, + 64.0, + 70.0, + 106.0, + 54.0, + 45.0, + 57.0, + 52.0, + 63.0, + 42.0, + 59.0, + 53.0, + 52.0, + 45.0, + 37.0, + 34.0, + 40.0, + 40.0, + 22.0, + 20.0, + 34.0, + 24.0, + 18.0, + 24.0, + 24.0, + 23.0, + 25.0, + 23.0, + 18.0, + 20.0, + 21.0, + 16.0, + 22.0, + 18.0, + 304.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 1.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, + 0.0, + 3.0, + 3.0, + 6.0, + 4.0, + 4.0, + 3.0, + 3.0, + 0.0, + 4.0, + 2.0, + 1.0, + 3.0, + 0.0, + 2.0, + 2.0, + 2.0, + 1.0, + 0.0, + 1.0, + 2.0, + 0.0, + 0.0, + 1.0, + 0.0, + 2.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 3.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 5.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.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, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 49.0, + 19.0, + 19.0, + 19.0, + 5.0, + 3.0, + 6.0, + 3.0, + 8.0, + 3.0, + 7.0, + 11.0, + 8.0, + 15.0, + 5.0, + 43.0, + 2.0, + 4.0, + 4.0, + 7.0, + 4.0, + 11.0, + 34.0, + 17.0, + 25.0, + 40.0, + 64.0, + 17.0, + 9.0, + 9.0, + 9.0, + 20.0, + 10.0, + 79.0, + 43.0, + 9.0, + 18.0, + 10.0, + 28.0, + 45.0, + 44.0, + 32.0, + 27.0, + 9.0, + 18.0, + 16.0, + 13.0, + 9.0, + 21.0, + 13.0, + 8.0, + 12.0, + 10.0, + 21.0, + 47.0, + 58.0, + 62.0, + 20.0, + 10.0, + 11.0, + 18.0, + 13.0, + 23.0, + 13.0, + 11.0, + 7.0, + 12.0, + 22.0, + 12.0, + 0.0, + 8.0, + 9.0, + 10.0, + 7.0, + 12.0, + 11.0, + 15.0, + 6.0, + 9.0, + 13.0, + 21.0, + 7.0, + 3.0, + 3.0, + 5.0, + 4.0, + 5.0, + 20.0, + 29.0, + 3.0, + 6.0, + 8.0, + 2.0, + 3.0, + 1.0, + 4.0, + 7.0, + 5.0, + 8.0, + 6.0, + 8.0, + 7.0, + 6.0, + 5.0, + 5.0, + 4.0, + 1.0, + 3.0, + 2.0, + 3.0, + 2.0, + 2.0, + 2.0, + 0.0, + 2.0, + 0.0, + 0.0, + 35.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.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, + 0.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.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, + 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, + 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, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 20.0, + 1097.0, + 45923.0, + 412373.0, + 12094.0, + 23.0, + 13.0, + 0.0, + 0.0, + 0.0, + 7381.0, + 176338.0, + 405104.0, + 700717.0, + 86391.0, + 7038.0, + 62880.0, + 133016.0, + 217452.0, + 91501.0, + 60581.0, + 43252.0, + 35218.0, + 44732.0, + 104945.0, + 243898.0, + 244831.0, + 69855.0, + 26171.0, + 46353.0, + 142899.0, + 171411.0, + 153373.0, + 120329.0, + 124232.0, + 146181.0, + 143194.0, + 125098.0, + 128777.0, + 109116.0, + 92966.0, + 78125.0, + 76631.0, + 72570.0, + 64676.0, + 87856.0, + 98707.0, + 79982.0, + 84383.0, + 78524.0, + 71658.0, + 71006.0, + 83424.0, + 117191.0, + 120003.0, + 87104.0, + 67064.0, + 67154.0, + 63799.0, + 69160.0, + 70429.0, + 62913.0, + 54723.0, + 52264.0, + 51585.0, + 50828.0, + 53354.0, + 62184.0, + 49161.0, + 41399.0, + 40747.0, + 40613.0, + 41601.0, + 44694.0, + 51451.0, + 49288.0, + 45304.0, + 40164.0, + 38211.0, + 35317.0, + 29912.0, + 28547.0, + 28428.0, + 26461.0, + 25398.0, + 24057.0, + 23382.0, + 24161.0, + 25243.0, + 23541.0, + 21992.0, + 18824.0, + 17982.0, + 18570.0, + 19931.0, + 21997.0, + 19454.0, + 16390.0, + 16046.0, + 14591.0, + 13853.0, + 13358.0, + 13253.0, + 12636.0, + 11773.0, + 10532.0, + 9813.0, + 9733.0, + 9637.0, + 9538.0, + 8953.0, + 8288.0, + 7748.0, + 7610.0, + 7425.0, + 7774.0, + 7748.0, + 7549.0, + 6809.0, + 6205.0, + 5716.0, + 5755.0, + 5202.0, + 5196.0, + 4718.0, + 4440.0, + 4151.0, + 4065.0, + 3951.0, + 3800.0, + 3604.0, + 3508.0, + 3360.0, + 3109.0, + 2892.0, + 3048.0, + 2846.0, + 2702.0, + 2739.0, + 56104.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 284.0, + 120819.0, + 79093.0, + 18739.0, + 2.0, + 0.0, + 4.0, + 0.0, + 0.0, + 103.0, + 46561.0, + 1313470.0, + 179642.0, + 269606.0, + 107514.0, + 166924.0, + 237142.0, + 236567.0, + 130314.0, + 81635.0, + 64108.0, + 70450.0, + 89132.0, + 145454.0, + 235783.0, + 148288.0, + 63260.0, + 52667.0, + 85236.0, + 210303.0, + 211103.0, + 196522.0, + 156835.0, + 169602.0, + 199157.0, + 183448.0, + 215936.0, + 198220.0, + 165493.0, + 168699.0, + 157931.0, + 129654.0, + 119616.0, + 130483.0, + 186473.0, + 112869.0, + 117128.0, + 123676.0, + 129550.0, + 143374.0, + 134795.0, + 179165.0, + 268862.0, + 207121.0, + 172576.0, + 116241.0, + 110145.0, + 111885.0, + 122414.0, + 123794.0, + 120010.0, + 111334.0, + 107837.0, + 98102.0, + 96193.0, + 107009.0, + 104331.0, + 81897.0, + 71854.0, + 73550.0, + 76425.0, + 78209.0, + 86711.0, + 91186.0, + 88042.0, + 86659.0, + 77818.0, + 74440.0, + 64974.0, + 61262.0, + 60104.0, + 54449.0, + 51720.0, + 47606.0, + 47422.0, + 47243.0, + 46091.0, + 44802.0, + 41361.0, + 39294.0, + 36346.0, + 35057.0, + 35617.0, + 38133.0, + 39120.0, + 36093.0, + 32845.0, + 30982.0, + 30248.0, + 28446.0, + 26463.0, + 25675.0, + 25405.0, + 23374.0, + 21066.0, + 20892.0, + 20190.0, + 19469.0, + 18630.0, + 17088.0, + 16159.0, + 16290.0, + 15796.0, + 16089.0, + 15765.0, + 14911.0, + 14657.0, + 13485.0, + 12405.0, + 11710.0, + 11147.0, + 10803.0, + 10178.0, + 9942.0, + 9817.0, + 9198.0, + 8801.0, + 8348.0, + 8095.0, + 7830.0, + 7522.0, + 6827.0, + 6671.0, + 6561.0, + 6299.0, + 6091.0, + 5838.0, + 5653.0, + 107113.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 111.0, + 2405.0, + 89.0, + 5.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 105.0, + 1531.0, + 741.0, + 197.0, + 66.0, + 358.0, + 292.0, + 162.0, + 266.0, + 380.0, + 268.0, + 239.0, + 331.0, + 283.0, + 387.0, + 353.0, + 286.0, + 339.0, + 286.0, + 285.0, + 410.0, + 411.0, + 458.0, + 515.0, + 650.0, + 535.0, + 473.0, + 495.0, + 373.0, + 382.0, + 399.0, + 359.0, + 339.0, + 348.0, + 464.0, + 339.0, + 296.0, + 395.0, + 376.0, + 382.0, + 406.0, + 382.0, + 424.0, + 394.0, + 304.0, + 328.0, + 372.0, + 247.0, + 278.0, + 198.0, + 216.0, + 190.0, + 163.0, + 137.0, + 126.0, + 146.0, + 143.0, + 130.0, + 159.0, + 140.0, + 197.0, + 172.0, + 162.0, + 182.0, + 159.0, + 84.0, + 91.0, + 100.0, + 86.0, + 84.0, + 71.0, + 59.0, + 62.0, + 65.0, + 64.0, + 56.0, + 39.0, + 49.0, + 62.0, + 59.0, + 49.0, + 42.0, + 36.0, + 37.0, + 48.0, + 33.0, + 36.0, + 30.0, + 46.0, + 30.0, + 47.0, + 39.0, + 38.0, + 32.0, + 30.0, + 29.0, + 28.0, + 39.0, + 27.0, + 25.0, + 42.0, + 24.0, + 33.0, + 21.0, + 13.0, + 19.0, + 20.0, + 23.0, + 16.0, + 20.0, + 13.0, + 7.0, + 16.0, + 13.0, + 18.0, + 13.0, + 15.0, + 22.0, + 18.0, + 13.0, + 12.0, + 7.0, + 3.0, + 7.0, + 102.0 + ], + [ + 0.0, + 0.0, + 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, + 7537.0, + 171861.0, + 1662.0, + 4.0, + 2.0, + 0.0, + 0.0, + 0.0, + 17.0, + 2710.0, + 129778.0, + 179321.0, + 118432.0, + 12644.0, + 11881.0, + 24829.0, + 20929.0, + 17279.0, + 15451.0, + 16604.0, + 19993.0, + 15111.0, + 20307.0, + 65158.0, + 63337.0, + 28204.0, + 9534.0, + 11807.0, + 24398.0, + 38837.0, + 26213.0, + 21628.0, + 21647.0, + 25366.0, + 33141.0, + 31595.0, + 32680.0, + 23689.0, + 20911.0, + 19915.0, + 15342.0, + 14842.0, + 18746.0, + 25336.0, + 36402.0, + 17600.0, + 20204.0, + 18005.0, + 16650.0, + 15259.0, + 16805.0, + 21816.0, + 27490.0, + 19365.0, + 14482.0, + 13733.0, + 13269.0, + 13289.0, + 13722.0, + 13582.0, + 12329.0, + 11782.0, + 11145.0, + 9919.0, + 11159.0, + 12519.0, + 13071.0, + 9390.0, + 8309.0, + 8650.0, + 8406.0, + 9000.0, + 9106.0, + 8490.0, + 8174.0, + 7779.0, + 7066.0, + 6660.0, + 5892.0, + 4991.0, + 5047.0, + 5252.0, + 4757.0, + 4957.0, + 4483.0, + 4901.0, + 4921.0, + 4645.0, + 4272.0, + 3895.0, + 3335.0, + 2952.0, + 3512.0, + 3540.0, + 2997.0, + 2931.0, + 2569.0, + 2431.0, + 2345.0, + 2275.0, + 2228.0, + 2059.0, + 1874.0, + 1635.0, + 1569.0, + 1503.0, + 1664.0, + 1753.0, + 1618.0, + 1352.0, + 1350.0, + 1191.0, + 1135.0, + 1223.0, + 1140.0, + 1030.0, + 1026.0, + 935.0, + 897.0, + 817.0, + 801.0, + 762.0, + 625.0, + 688.0, + 527.0, + 567.0, + 656.0, + 576.0, + 523.0, + 535.0, + 444.0, + 421.0, + 376.0, + 339.0, + 7259.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 44.0, + 72.0, + 1.0, + 15.0, + 8.0, + 24.0, + 46.0, + 10.0, + 20.0, + 17.0, + 16.0, + 4.0, + 33.0, + 19.0, + 44.0, + 15.0, + 27.0, + 16.0, + 18.0, + 38.0, + 72.0, + 40.0, + 10.0, + 28.0, + 32.0, + 5.0, + 24.0, + 14.0, + 5.0, + 19.0, + 18.0, + 3.0, + 1.0, + 19.0, + 31.0, + 45.0, + 5.0, + 14.0, + 8.0, + 2.0, + 21.0, + 17.0, + 26.0, + 33.0, + 14.0, + 15.0, + 9.0, + 1.0, + 5.0, + 6.0, + 2.0, + 6.0, + 2.0, + 5.0, + 1.0, + 5.0, + 6.0, + 0.0, + 1.0, + 2.0, + 1.0, + 1.0, + 5.0, + 4.0, + 0.0, + 0.0, + 1.0, + 4.0, + 1.0, + 2.0, + 0.0, + 0.0, + 1.0, + 2.0, + 1.0, + 1.0, + 4.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 2.0, + 2.0, + 0.0, + 1.0, + 2.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 21.0, + 2.0, + 1.0, + 0.0, + 1.0, + 5.0, + 0.0, + 6.0, + 9.0, + 1.0, + 12.0, + 33.0, + 5.0, + 0.0, + 28.0, + 11.0, + 30.0, + 8.0, + 5.0, + 5.0, + 20.0, + 27.0, + 44.0, + 39.0, + 44.0, + 68.0, + 34.0, + 38.0, + 54.0, + 72.0, + 51.0, + 56.0, + 44.0, + 64.0, + 36.0, + 59.0, + 71.0, + 61.0, + 56.0, + 72.0, + 99.0, + 72.0, + 60.0, + 55.0, + 57.0, + 40.0, + 42.0, + 35.0, + 29.0, + 43.0, + 31.0, + 72.0, + 52.0, + 53.0, + 33.0, + 22.0, + 33.0, + 27.0, + 19.0, + 21.0, + 21.0, + 16.0, + 26.0, + 27.0, + 26.0, + 21.0, + 20.0, + 13.0, + 10.0, + 29.0, + 13.0, + 27.0, + 6.0, + 13.0, + 10.0, + 11.0, + 20.0, + 13.0, + 5.0, + 6.0, + 3.0, + 7.0, + 6.0, + 5.0, + 5.0, + 9.0, + 7.0, + 8.0, + 9.0, + 4.0, + 8.0, + 11.0, + 5.0, + 6.0, + 7.0, + 3.0, + 3.0, + 3.0, + 2.0, + 2.0, + 1.0, + 3.0, + 1.0, + 2.0, + 2.0, + 2.0, + 0.0, + 3.0, + 1.0, + 2.0, + 12.0, + 2.0, + 0.0, + 0.0, + 0.0, + 4.0, + 2.0, + 1.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 2.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 2.0, + 19.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 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, + 1673.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 77606.0, + 323872.0, + 32635.0, + 233592.0, + 82367.0, + 115978.0, + 84871.0, + 75682.0, + 40817.0, + 28279.0, + 28205.0, + 39925.0, + 58114.0, + 63685.0, + 91345.0, + 45399.0, + 29394.0, + 30439.0, + 33392.0, + 60052.0, + 61174.0, + 63066.0, + 58092.0, + 64764.0, + 133663.0, + 117513.0, + 140650.0, + 93826.0, + 82566.0, + 89174.0, + 79582.0, + 59809.0, + 60673.0, + 62974.0, + 68091.0, + 48004.0, + 50343.0, + 55760.0, + 69725.0, + 67242.0, + 70781.0, + 102165.0, + 132727.0, + 107112.0, + 75101.0, + 57423.0, + 59890.0, + 68752.0, + 72341.0, + 69677.0, + 66753.0, + 60973.0, + 56655.0, + 58567.0, + 61070.0, + 61328.0, + 51120.0, + 43785.0, + 40346.0, + 39921.0, + 41839.0, + 41800.0, + 49091.0, + 56429.0, + 45495.0, + 47436.0, + 45176.0, + 39472.0, + 34635.0, + 32496.0, + 32153.0, + 31971.0, + 30699.0, + 27543.0, + 27212.0, + 27185.0, + 26898.0, + 25509.0, + 23054.0, + 21988.0, + 20974.0, + 20840.0, + 21352.0, + 22167.0, + 22824.0, + 23390.0, + 21434.0, + 19409.0, + 19501.0, + 17778.0, + 16911.0, + 16282.0, + 15048.0, + 14324.0, + 12777.0, + 12761.0, + 11987.0, + 12302.0, + 12996.0, + 11311.0, + 10701.0, + 10476.0, + 10772.0, + 11175.0, + 10464.0, + 9862.0, + 9674.0, + 9268.0, + 8431.0, + 8092.0, + 7714.0, + 7345.0, + 7124.0, + 6816.0, + 6359.0, + 6075.0, + 5659.0, + 5196.0, + 5021.0, + 5578.0, + 4659.0, + 4552.0, + 4515.0, + 4179.0, + 4114.0, + 4091.0, + 4026.0, + 3886.0, + 80225.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 8897.0, + 20982.0, + 2381.0, + 436.0, + 26.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 200.0, + 874.0, + 289.0, + 129.0, + 231.0, + 343.0, + 547.0, + 544.0, + 640.0, + 730.0, + 1025.0, + 999.0, + 950.0, + 799.0, + 706.0, + 604.0, + 520.0, + 736.0, + 938.0, + 1075.0, + 501.0, + 406.0, + 406.0, + 490.0, + 460.0, + 442.0, + 383.0, + 451.0, + 388.0, + 491.0, + 331.0, + 471.0, + 474.0, + 476.0, + 440.0, + 440.0, + 471.0, + 410.0, + 365.0, + 399.0, + 391.0, + 372.0, + 372.0, + 366.0, + 349.0, + 301.0, + 328.0, + 375.0, + 321.0, + 239.0, + 287.0, + 281.0, + 224.0, + 221.0, + 182.0, + 167.0, + 164.0, + 160.0, + 151.0, + 158.0, + 142.0, + 156.0, + 182.0, + 147.0, + 166.0, + 154.0, + 156.0, + 168.0, + 221.0, + 134.0, + 87.0, + 114.0, + 84.0, + 83.0, + 74.0, + 60.0, + 51.0, + 48.0, + 49.0, + 53.0, + 68.0, + 49.0, + 65.0, + 32.0, + 53.0, + 54.0, + 72.0, + 51.0, + 49.0, + 41.0, + 39.0, + 27.0, + 24.0, + 20.0, + 20.0, + 23.0, + 23.0, + 21.0, + 22.0, + 15.0, + 27.0, + 23.0, + 25.0, + 19.0, + 20.0, + 35.0, + 34.0, + 22.0, + 15.0, + 20.0, + 20.0, + 20.0, + 22.0, + 14.0, + 12.0, + 15.0, + 17.0, + 12.0, + 12.0, + 12.0, + 15.0, + 9.0, + 6.0, + 14.0, + 237.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 424049.0, + 4454.0, + 3376.0, + 1.0, + 2.0, + 2.0, + 1.0, + 5.0, + 19.0, + 29.0, + 152.0, + 2965.0, + 22147.0, + 11147.0, + 12539.0, + 35225.0, + 23441.0, + 16433.0, + 17054.0, + 14236.0, + 11446.0, + 11800.0, + 13041.0, + 16458.0, + 36326.0, + 23471.0, + 11786.0, + 13116.0, + 12559.0, + 17059.0, + 28576.0, + 37730.0, + 21623.0, + 15573.0, + 25818.0, + 42270.0, + 23143.0, + 22167.0, + 22938.0, + 20904.0, + 15616.0, + 15257.0, + 15168.0, + 15983.0, + 30633.0, + 15509.0, + 15382.0, + 16203.0, + 19833.0, + 18443.0, + 18711.0, + 20439.0, + 26210.0, + 30553.0, + 23153.0, + 15898.0, + 14485.0, + 14229.0, + 14586.0, + 15656.0, + 14154.0, + 14041.0, + 12392.0, + 11778.0, + 11570.0, + 13043.0, + 13374.0, + 11687.0, + 10322.0, + 9345.0, + 9783.0, + 10422.0, + 10992.0, + 11425.0, + 10898.0, + 10092.0, + 9296.0, + 8687.0, + 7967.0, + 7355.0, + 6719.0, + 6274.0, + 6607.0, + 5791.0, + 5582.0, + 5526.0, + 5178.0, + 5195.0, + 5496.0, + 4717.0, + 4476.0, + 4117.0, + 4115.0, + 4075.0, + 4092.0, + 4178.0, + 3947.0, + 3473.0, + 3372.0, + 3069.0, + 3027.0, + 2843.0, + 2815.0, + 2445.0, + 2248.0, + 2247.0, + 2182.0, + 2239.0, + 2159.0, + 2066.0, + 1813.0, + 1787.0, + 1707.0, + 1669.0, + 1733.0, + 1635.0, + 1610.0, + 1463.0, + 1416.0, + 1378.0, + 1373.0, + 1170.0, + 1146.0, + 1147.0, + 993.0, + 932.0, + 910.0, + 828.0, + 741.0, + 848.0, + 723.0, + 720.0, + 625.0, + 620.0, + 660.0, + 11151.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 6.0, + 3.0, + 34.0, + 11.0, + 31.0, + 53.0, + 15.0, + 18.0, + 4.0, + 9.0, + 12.0, + 4.0, + 6.0, + 42.0, + 36.0, + 18.0, + 7.0, + 10.0, + 5.0, + 9.0, + 21.0, + 16.0, + 6.0, + 7.0, + 64.0, + 30.0, + 16.0, + 28.0, + 14.0, + 15.0, + 15.0, + 6.0, + 15.0, + 44.0, + 6.0, + 8.0, + 0.0, + 33.0, + 26.0, + 15.0, + 10.0, + 16.0, + 18.0, + 71.0, + 23.0, + 11.0, + 13.0, + 17.0, + 19.0, + 14.0, + 19.0, + 8.0, + 5.0, + 20.0, + 13.0, + 22.0, + 20.0, + 13.0, + 20.0, + 10.0, + 3.0, + 6.0, + 0.0, + 5.0, + 10.0, + 1.0, + 2.0, + 7.0, + 3.0, + 8.0, + 6.0, + 8.0, + 6.0, + 6.0, + 8.0, + 0.0, + 6.0, + 0.0, + 5.0, + 9.0, + 3.0, + 8.0, + 8.0, + 2.0, + 1.0, + 3.0, + 1.0, + 1.0, + 1.0, + 3.0, + 4.0, + 2.0, + 1.0, + 3.0, + 3.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 2.0, + 2.0, + 0.0, + 3.0, + 1.0, + 1.0, + 0.0, + 13.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 892.0, + 13.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 1.0, + 12.0, + 9.0, + 12.0, + 22.0, + 24.0, + 21.0, + 34.0, + 12.0, + 7.0, + 21.0, + 51.0, + 35.0, + 44.0, + 65.0, + 49.0, + 94.0, + 79.0, + 98.0, + 97.0, + 59.0, + 74.0, + 75.0, + 71.0, + 101.0, + 96.0, + 140.0, + 44.0, + 68.0, + 35.0, + 45.0, + 52.0, + 49.0, + 76.0, + 136.0, + 72.0, + 55.0, + 44.0, + 30.0, + 57.0, + 71.0, + 61.0, + 99.0, + 67.0, + 48.0, + 35.0, + 42.0, + 46.0, + 22.0, + 28.0, + 31.0, + 39.0, + 31.0, + 32.0, + 34.0, + 32.0, + 25.0, + 24.0, + 25.0, + 21.0, + 28.0, + 15.0, + 19.0, + 16.0, + 22.0, + 20.0, + 8.0, + 14.0, + 20.0, + 19.0, + 18.0, + 19.0, + 16.0, + 19.0, + 15.0, + 16.0, + 11.0, + 8.0, + 5.0, + 8.0, + 9.0, + 7.0, + 9.0, + 7.0, + 6.0, + 9.0, + 19.0, + 10.0, + 10.0, + 11.0, + 14.0, + 16.0, + 17.0, + 6.0, + 10.0, + 10.0, + 2.0, + 2.0, + 7.0, + 3.0, + 6.0, + 2.0, + 5.0, + 2.0, + 2.0, + 8.0, + 2.0, + 2.0, + 1.0, + 5.0, + 2.0, + 5.0, + 3.0, + 4.0, + 1.0, + 1.0, + 4.0, + 2.0, + 7.0, + 2.0, + 2.0, + 3.0, + 1.0, + 29.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 29.0, + 165.0, + 116.0, + 160.0, + 57.0, + 87.0, + 140.0, + 11.0, + 0.0, + 3.0, + 0.0, + 0.0, + 6.0, + 11.0, + 5.0, + 1.0, + 4.0, + 10.0, + 7.0, + 5.0, + 3.0, + 2.0, + 11.0, + 2.0, + 2.0, + 4.0, + 2.0, + 11.0, + 6.0, + 10.0, + 4.0, + 8.0, + 7.0, + 9.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 3.0, + 3.0, + 2.0, + 1.0, + 7.0, + 7.0, + 2.0, + 1.0, + 1.0, + 4.0, + 4.0, + 1.0, + 2.0, + 2.0, + 1.0, + 4.0, + 1.0, + 0.0, + 0.0, + 2.0, + 2.0, + 2.0, + 1.0, + 0.0, + 1.0, + 0.0, + 2.0, + 1.0, + 1.0, + 0.0, + 3.0, + 0.0, + 2.0, + 1.0, + 1.0, + 1.0, + 2.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 2.0, + 0.0, + 0.0, + 0.0, + 0.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, + 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, + 528.0, + 139.0, + 45.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 42.0, + 68.0, + 32.0, + 12.0, + 17.0, + 21.0, + 17.0, + 38.0, + 34.0, + 42.0, + 31.0, + 35.0, + 31.0, + 52.0, + 38.0, + 50.0, + 57.0, + 57.0, + 41.0, + 45.0, + 59.0, + 42.0, + 42.0, + 55.0, + 27.0, + 27.0, + 29.0, + 27.0, + 21.0, + 31.0, + 24.0, + 8.0, + 21.0, + 34.0, + 17.0, + 13.0, + 21.0, + 17.0, + 25.0, + 30.0, + 63.0, + 75.0, + 87.0, + 67.0, + 39.0, + 48.0, + 47.0, + 23.0, + 10.0, + 16.0, + 14.0, + 7.0, + 11.0, + 9.0, + 17.0, + 44.0, + 9.0, + 5.0, + 11.0, + 3.0, + 12.0, + 11.0, + 20.0, + 11.0, + 5.0, + 4.0, + 7.0, + 6.0, + 6.0, + 3.0, + 5.0, + 3.0, + 10.0, + 9.0, + 13.0, + 7.0, + 9.0, + 9.0, + 6.0, + 7.0, + 5.0, + 0.0, + 2.0, + 0.0, + 3.0, + 1.0, + 2.0, + 1.0, + 0.0, + 1.0, + 3.0, + 0.0, + 3.0, + 1.0, + 2.0, + 3.0, + 1.0, + 2.0, + 1.0, + 1.0, + 3.0, + 0.0, + 1.0, + 3.0, + 2.0, + 1.0, + 1.0, + 2.0, + 5.0, + 2.0, + 0.0, + 4.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 19.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 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, + 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, + 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, + 1.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 12.0, + 1672.0, + 168.0, + 2.0, + 1.0, + 0.0, + 1.0, + 20.0, + 114.0, + 520.0, + 9822.0, + 10533.0, + 606.0, + 723.0, + 915.0, + 403.0, + 614.0, + 1071.0, + 1120.0, + 700.0, + 880.0, + 960.0, + 1891.0, + 3342.0, + 1879.0, + 1048.0, + 699.0, + 808.0, + 1578.0, + 1483.0, + 1544.0, + 1588.0, + 1472.0, + 2846.0, + 4619.0, + 1902.0, + 2279.0, + 1432.0, + 1368.0, + 1156.0, + 1080.0, + 1296.0, + 2107.0, + 2330.0, + 1209.0, + 1332.0, + 1575.0, + 1359.0, + 1050.0, + 1369.0, + 1431.0, + 1399.0, + 1233.0, + 1058.0, + 884.0, + 740.0, + 830.0, + 998.0, + 1020.0, + 883.0, + 730.0, + 697.0, + 659.0, + 631.0, + 857.0, + 1103.0, + 683.0, + 502.0, + 519.0, + 607.0, + 582.0, + 682.0, + 824.0, + 466.0, + 394.0, + 321.0, + 441.0, + 367.0, + 292.0, + 270.0, + 247.0, + 254.0, + 317.0, + 327.0, + 326.0, + 247.0, + 242.0, + 226.0, + 210.0, + 230.0, + 185.0, + 197.0, + 198.0, + 188.0, + 160.0, + 146.0, + 166.0, + 149.0, + 129.0, + 131.0, + 115.0, + 114.0, + 77.0, + 103.0, + 95.0, + 97.0, + 90.0, + 152.0, + 92.0, + 98.0, + 85.0, + 70.0, + 81.0, + 86.0, + 73.0, + 79.0, + 65.0, + 32.0, + 69.0, + 64.0, + 36.0, + 33.0, + 38.0, + 30.0, + 32.0, + 36.0, + 22.0, + 43.0, + 38.0, + 37.0, + 546.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 6.0, + 1.0, + 7.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 8.0, + 3.0, + 1.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 7.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 26.0, + 0.0, + 2.0, + 3.0, + 6.0, + 2.0, + 2.0, + 0.0, + 0.0, + 1.0, + 6.0, + 7.0, + 7.0, + 1.0, + 0.0, + 0.0, + 2.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 3.0, + 4.0, + 3.0, + 2.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 5.0, + 3.0, + 0.0, + 0.0, + 0.0, + 1.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, + 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, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 6.0, + 13.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 4.0, + 4.0, + 3.0, + 10.0, + 38.0, + 43.0, + 8.0, + 3.0, + 5.0, + 8.0, + 8.0, + 9.0, + 5.0, + 15.0, + 5.0, + 4.0, + 2.0, + 3.0, + 7.0, + 5.0, + 2.0, + 3.0, + 6.0, + 3.0, + 4.0, + 5.0, + 17.0, + 11.0, + 4.0, + 1.0, + 3.0, + 2.0, + 0.0, + 1.0, + 3.0, + 4.0, + 2.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 4.0, + 3.0, + 1.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 3.0, + 2.0, + 0.0, + 0.0, + 2.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 5.0, + 0.0, + 0.0, + 1.0, + 2.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, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 4.0, + 1.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 2.0, + 6.0, + 26.0, + 11.0, + 0.0, + 2.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 2.0, + 8.0, + 1.0, + 2.0, + 3.0, + 0.0, + 2.0, + 3.0, + 1.0, + 2.0, + 2.0, + 2.0, + 5.0, + 3.0, + 1.0, + 3.0, + 2.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 4.0, + 3.0, + 2.0, + 14.0, + 15.0, + 9.0, + 11.0, + 4.0, + 1.0, + 3.0, + 0.0, + 0.0, + 0.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, + 2.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, + 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, + 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 + ] + ] + ], + "radius_of_gyration_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, + 7.0, + 30.0, + 58.0, + 156.0, + 232.0, + 493.0, + 848.0, + 1253.0, + 1696.0, + 2246.0, + 2950.0, + 4197.0, + 6454.0, + 9835.0, + 15192.0, + 21757.0, + 29407.0, + 38762.0, + 49011.0, + 59243.0, + 67058.0, + 73419.0, + 78017.0, + 79946.0, + 81217.0, + 79661.0, + 76893.0, + 72673.0, + 68667.0, + 63281.0, + 58120.0, + 53268.0, + 48249.0, + 42181.0, + 37632.0, + 33093.0, + 29424.0, + 25827.0, + 22701.0, + 19761.0, + 16883.0, + 14574.0, + 12353.0, + 10936.0, + 9592.0, + 8097.0, + 6743.0, + 5709.0, + 4962.0, + 4169.0, + 3669.0, + 3041.0, + 2701.0, + 2266.0, + 1908.0, + 1614.0, + 1383.0, + 1177.0, + 1017.0, + 859.0, + 704.0, + 709.0, + 4027.0 + ] +} diff --git a/chembl3d/explicit_h/distributions.pt b/chembl3d/explicit_h/distributions.pt new file mode 100644 index 0000000000000000000000000000000000000000..873b94b997d24e0f4e51a05131d863b8bb356955 --- /dev/null +++ b/chembl3d/explicit_h/distributions.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c92bdce3e70763f23b3132a910a9939791fd147f98e11f7d6ea9c0e6ef9622b4 +size 105093 diff --git a/chembl3d/explicit_h/edge_tokens.txt b/chembl3d/explicit_h/edge_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..59a3b678b20991c4db5881d2f6b9f0f2fc214357 --- /dev/null +++ b/chembl3d/explicit_h/edge_tokens.txt @@ -0,0 +1,4 @@ + +1 +2 +3 diff --git a/chembl3d/explicit_h/epoch_200.ckpt b/chembl3d/explicit_h/epoch_200.ckpt new file mode 100644 index 0000000000000000000000000000000000000000..fcfc97b9b14807e3131e3edfbeceb25f22743202 --- /dev/null +++ b/chembl3d/explicit_h/epoch_200.ckpt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:087f67faf38847d34780d061b0d192e61f8b4365d50cab6c32e0cc7dc5368743 +size 509357065 diff --git a/chembl3d/explicit_h/epoch_300.ckpt b/chembl3d/explicit_h/epoch_300.ckpt new file mode 100644 index 0000000000000000000000000000000000000000..fa9d91f5ac3c7c90969278b4b2c2571aac3a1d90 --- /dev/null +++ b/chembl3d/explicit_h/epoch_300.ckpt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad7b4535cf8d40ead915c59e525fbe3ec555e6f03885b78ed54c45497793f32b +size 509357129 diff --git a/chembl3d/explicit_h/epoch_400.ckpt b/chembl3d/explicit_h/epoch_400.ckpt new file mode 100644 index 0000000000000000000000000000000000000000..01f6a22a954b1e1487a1a9bdee1e531724a8968a --- /dev/null +++ b/chembl3d/explicit_h/epoch_400.ckpt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efb5c2f85972df19eca85d2d69cb56f6f52453d91ee26293b3e68b299e2bac8f +size 509357129 diff --git a/chembl3d/explicit_h/metadata.json b/chembl3d/explicit_h/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..0f66f6581132c45adf215438f5562dd37b4bab9e --- /dev/null +++ b/chembl3d/explicit_h/metadata.json @@ -0,0 +1,71 @@ +{ + "dataset": "ChEMBL3D", + "variant": "explicit_h", + "includes_explicit_hydrogens": true, + "source_max_total_atoms_including_h": 100, + "training_run": "chembl3d-max100-2x4090-15313268", + "source_snapshot": "chembl3d_recovery_20260927_v2", + "checkpoint_format": "Original Lightning checkpoint, current + EMA + optimizer state", + "epoch_numbering": "Completed epochs (one-based); original checkpoint epoch is zero-based", + "split_molecules": { + "train": 1474008, + "val": 184295, + "test": 184242 + }, + "checkpoints": [ + { + "filename": "epoch_200.ckpt", + "original_filename": "epoch=199-step=460600.ckpt", + "completed_epochs": 200, + "checkpoint_epoch_index": 199, + "bytes": 509357065, + "sha256": "087f67faf38847d34780d061b0d192e61f8b4365d50cab6c32e0cc7dc5368743", + "global_step": 460600 + }, + { + "filename": "epoch_300.ckpt", + "original_filename": "epoch=299-step=690900.ckpt", + "completed_epochs": 300, + "checkpoint_epoch_index": 299, + "bytes": 509357129, + "sha256": "ad7b4535cf8d40ead915c59e525fbe3ec555e6f03885b78ed54c45497793f32b", + "global_step": 690900 + }, + { + "filename": "epoch_400.ckpt", + "original_filename": "epoch=399-step=921200.ckpt", + "completed_epochs": 400, + "checkpoint_epoch_index": 399, + "bytes": 509357129, + "sha256": "efb5c2f85972df19eca85d2d69cb56f6f52453d91ee26293b3e68b299e2bac8f", + "global_step": 921200 + } + ], + "coordinate_std_angstrom": 2.707319498062134, + "atom_tokens": [ + "B", + "Br", + "C", + "Cl", + "F", + "H", + "I", + "N", + "O", + "P", + "S", + "Se", + "Si" + ], + "edge_tokens": [ + "", + "1", + "2", + "3" + ], + "charge_tokens": [ + "-1", + "0", + "1" + ] +} diff --git a/chembl3d/explicit_h/runtime/README.md b/chembl3d/explicit_h/runtime/README.md new file mode 100644 index 0000000000000000000000000000000000000000..0f6380f453d4c7f8bddf1de42afe3286a426c0bd --- /dev/null +++ b/chembl3d/explicit_h/runtime/README.md @@ -0,0 +1,3 @@ +# Morph runtime for ChEMBL3D explicit_h + +Frozen training source. See the model repository README and sample.py. diff --git a/chembl3d/explicit_h/runtime/configs/callbacks/callbacks.yaml b/chembl3d/explicit_h/runtime/configs/callbacks/callbacks.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1fe407944f322f2e45a3f2789d65ac6dbcfbc112 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/data/chembl3d/chembl3d.yaml b/chembl3d/explicit_h/runtime/configs/data/chembl3d/chembl3d.yaml new file mode 100644 index 0000000000000000000000000000000000000000..216c77bf4e98d233c6a870596c13efebf3dd69cd --- /dev/null +++ b/chembl3d/explicit_h/runtime/configs/data/chembl3d/chembl3d.yaml @@ -0,0 +1,36 @@ +# LoQI Release 2 -> Morph LMDB, using the shared drug-like training setup. +defaults: + - /data/geom/geom@_here_ + - _self_ + +dataset_name: chembl3d +# Optional directory with the downloaded train_h.pt / val_h.pt / test_h.pt. +# When unset, missing files are downloaded to /raw/. +source_dir: ${oc.env:CHEMBL3D_SOURCE_DIR,null} +vocab: null + +preprocessing: + num_workers: ${data.preprocess_workers} + root: ${data.root_path}/chembl3d + +datamodule: + distributions: null + datasets: + train: + _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + source_dir: ${data.source_dir} + distributions: null + val: + - _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + source_dir: ${data.source_dir} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: null + test: + - _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + source_dir: ${data.source_dir} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: null diff --git a/chembl3d/explicit_h/runtime/configs/data/chembl3d/max100.yaml b/chembl3d/explicit_h/runtime/configs/data/chembl3d/max100.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4fe5b881f2621e58c9e287ab6dcf8673114942ff --- /dev/null +++ b/chembl3d/explicit_h/runtime/configs/data/chembl3d/max100.yaml @@ -0,0 +1,8 @@ +# Dataset cache built by morph.dataset.unconditional.chembl3d_filter. +defaults: + - /data/chembl3d/chembl3d@_here_ + - _self_ + +max_total_atoms: 100 # Inclusive, with explicit hydrogens retained. +preprocessing: + root: ${data.root_path}/chembl3d_max100 diff --git a/chembl3d/explicit_h/runtime/configs/data/geom/geom.yaml b/chembl3d/explicit_h/runtime/configs/data/geom/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8e04bb92820a2c9b62523035b4cf80c4a3cb9af1 --- /dev/null +++ b/chembl3d/explicit_h/runtime/configs/data/geom/geom.yaml @@ -0,0 +1,113 @@ +## GEOM-Drugs data and preprocessing configuration. + +dataset_name: geom +root_path: ${oc.env:PROJECT_ROOT}/data +preprocess_workers: 10 + +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 + +# GEOM-Drugs contains charged species; allow them when scoring validity. +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}/geom + 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: 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} + # 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: None + representation: ${representation} + + datasets: + train: + _target_: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: None + + val: + - _target_: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: None + + test: + - _target_: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + 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: 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/chembl3d/explicit_h/runtime/configs/data/geom/scaffold_decoration.yaml b/chembl3d/explicit_h/runtime/configs/data/geom/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..784e3b44c463f4885489f28b09a956b01a5e9580 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/data/qm9/qm9.yaml b/chembl3d/explicit_h/runtime/configs/data/qm9/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..992049b566f18acb2744568adcf61e47ecfc2342 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/data/qm9/scaffold_decoration.yaml b/chembl3d/explicit_h/runtime/configs/data/qm9/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8c636cf25fc5a740906f6210a7430457794b92fe --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/default.yaml b/chembl3d/explicit_h/runtime/configs/default.yaml new file mode 100644 index 0000000000000000000000000000000000000000..62ac4294601048087b24a7825a283abf44a2d68e --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml b/chembl3d/explicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml new file mode 100644 index 0000000000000000000000000000000000000000..da60c3219bac2bd1cc23d5cf1eaca85084c2cb89 --- /dev/null +++ b/chembl3d/explicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml @@ -0,0 +1,5 @@ +# @package _global_ +defaults: + - core + - /data: chembl3d/chembl3d + - /embeddings: geom diff --git a/chembl3d/explicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml b/chembl3d/explicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..89b7e5c2aad3b9500fff82dd958690e28bd650c6 --- /dev/null +++ b/chembl3d/explicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml @@ -0,0 +1,41 @@ +# @package _global_ +defaults: + - core + - /data: chembl3d/max100 + - /embeddings: geom + - _self_ + +# Preserve the base recipe's effective global batch: 2 GPUs * 80 * 4 = 640. +data: + datamodule: + train_prefetch_factor: 1 + batch_size: + train: 80 + val: 32 + test: 32 + num_workers: + train: 20 + val: 2 + test: 2 + +trainer: + trainer: + accelerator: gpu + devices: 2 + accumulate_grad_batches: 4 + max_epochs: -1 + check_val_every_n_epoch: 5 + 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: + runtime_monitor: + _target_: morph.utils.training_monitor.TrainingMonitor + every_n_batches: 100 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link diff --git a/chembl3d/explicit_h/runtime/configs/defaults/core.yaml b/chembl3d/explicit_h/runtime/configs/defaults/core.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a8a7114d33bddc4ec3a5011213e925285a426e40 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/defaults/geom/geom.yaml b/chembl3d/explicit_h/runtime/configs/defaults/geom/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c3231855aa9a8e3551ca11a261e05949ebb2820b --- /dev/null +++ b/chembl3d/explicit_h/runtime/configs/defaults/geom/geom.yaml @@ -0,0 +1,6 @@ +# @package _global_ +# GEOM preset: shared core + GEOM data and embeddings. +defaults: + - core + - /data: geom/geom + - /embeddings: geom diff --git a/chembl3d/explicit_h/runtime/configs/defaults/geom/scaffold_decoration.yaml b/chembl3d/explicit_h/runtime/configs/defaults/geom/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c2fd2d1ba1d5386433bc15d97cdbd0b77b04296b --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/defaults/qm9/qm9.yaml b/chembl3d/explicit_h/runtime/configs/defaults/qm9/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a43bdfbcd6bb9fb6964e0be073e11d28f2f399bc --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/defaults/qm9/scaffold_decoration.yaml b/chembl3d/explicit_h/runtime/configs/defaults/qm9/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cf721f81b4422726df6382286e23c38787f75cce --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/embeddings/geom.yaml b/chembl3d/explicit_h/runtime/configs/embeddings/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..51d9fda0862b44cff795661c2f1e91217c7743ab --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/embeddings/qm9.yaml b/chembl3d/explicit_h/runtime/configs/embeddings/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4dcc81608316e2105165576aaf8d915e2317f97d --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/heads/head.yaml b/chembl3d/explicit_h/runtime/configs/heads/head.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bef544d639b98055af99cb253ea8dc0a82486473 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/insertion_head/gmm.yaml b/chembl3d/explicit_h/runtime/configs/insertion_head/gmm.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5dedca7c0ff62a5d3fcfaf43de5aa79a161d813f --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/insertion_head/unimodal.yaml b/chembl3d/explicit_h/runtime/configs/insertion_head/unimodal.yaml new file mode 100644 index 0000000000000000000000000000000000000000..85148799b512c78531aa577a18d36bbdc70f9cba --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/integrator/integrator.yaml b/chembl3d/explicit_h/runtime/configs/integrator/integrator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d0c167b0138fc54d8413743649543e4b2428c05a --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/logging/wandb_logger.yaml b/chembl3d/explicit_h/runtime/configs/logging/wandb_logger.yaml new file mode 100644 index 0000000000000000000000000000000000000000..162b88b38e78138334d36469b72bccb633924517 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/loss_weights/calibrated_geom.yaml b/chembl3d/explicit_h/runtime/configs/loss_weights/calibrated_geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ddc9b38bc200adc1832e091277ad3aeb88e89a5d --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/loss_weights/calibrated_geom_frozen.yaml b/chembl3d/explicit_h/runtime/configs/loss_weights/calibrated_geom_frozen.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d0e9e3b6f7e1814dce089db7a5b9497f3e7fd860 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/loss_weights/tuned_weights.yaml b/chembl3d/explicit_h/runtime/configs/loss_weights/tuned_weights.yaml new file mode 100644 index 0000000000000000000000000000000000000000..681de9e73d6cd6836226366549221edb368be363 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/loss_weights/uniform.yaml b/chembl3d/explicit_h/runtime/configs/loss_weights/uniform.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fb279b4f7a159f8098ad5aa9b06059570949c270 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/model/backbones/dit.yaml b/chembl3d/explicit_h/runtime/configs/model/backbones/dit.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5393a940e3628f9d0b299eb543b8e619ad03adaf --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/model/finetune/lora_finetune.yaml b/chembl3d/explicit_h/runtime/configs/model/finetune/lora_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ae37397e74c5720f8f1c93835bc67e71f6157b29 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/model/finetune/scaffold_finetune.yaml b/chembl3d/explicit_h/runtime/configs/model/finetune/scaffold_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..02cb1b117c555e97a2f2e5296403900fd09871b3 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/optimizer/muon_aux_adam.yaml b/chembl3d/explicit_h/runtime/configs/optimizer/muon_aux_adam.yaml new file mode 100644 index 0000000000000000000000000000000000000000..94cf30139d2d0799df91519152dc7a18c6380355 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/schedule/beta.yaml b/chembl3d/explicit_h/runtime/configs/schedule/beta.yaml new file mode 100644 index 0000000000000000000000000000000000000000..282746a14cd54cfa9b5560340f90cde6d8762f2f --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/schedule/cubic.yaml b/chembl3d/explicit_h/runtime/configs/schedule/cubic.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fd5cf2d9e5dcb3213e5f25acddbf86209620219a --- /dev/null +++ b/chembl3d/explicit_h/runtime/configs/schedule/cubic.yaml @@ -0,0 +1 @@ +_target_: morph.flow_matching.schedules.CubicSchedule diff --git a/chembl3d/explicit_h/runtime/configs/schedule/fast_power.yaml b/chembl3d/explicit_h/runtime/configs/schedule/fast_power.yaml new file mode 100644 index 0000000000000000000000000000000000000000..21757bb0f0037500dc106d10f3a01b1fd4655e22 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/configs/schedule/linear.yaml b/chembl3d/explicit_h/runtime/configs/schedule/linear.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f63c2e226275c32dc406db96f21c3aa488054a80 --- /dev/null +++ b/chembl3d/explicit_h/runtime/configs/schedule/linear.yaml @@ -0,0 +1 @@ +_target_: morph.flow_matching.schedules.LinearSchedule diff --git a/chembl3d/explicit_h/runtime/configs/schedule/smoothstep.yaml b/chembl3d/explicit_h/runtime/configs/schedule/smoothstep.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ed29d5013930dafd40a1090a40d08b638976e3ba --- /dev/null +++ b/chembl3d/explicit_h/runtime/configs/schedule/smoothstep.yaml @@ -0,0 +1,2 @@ +_target_: morph.flow_matching.schedules.SmoothstepSchedule +shift: 0.8 diff --git a/chembl3d/explicit_h/runtime/configs/trainer/trainer.yaml b/chembl3d/explicit_h/runtime/configs/trainer/trainer.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a894707dd858be394921733728c16e523ed8810b --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/pyproject.toml b/chembl3d/explicit_h/runtime/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..4ce2767599273c900bf54add69d218f31baedea0 --- /dev/null +++ b/chembl3d/explicit_h/runtime/pyproject.toml @@ -0,0 +1,136 @@ +[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", + "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/chembl3d/explicit_h/runtime/requirements-tested.txt b/chembl3d/explicit_h/runtime/requirements-tested.txt new file mode 100644 index 0000000000000000000000000000000000000000..a850aee476957f3d21efdd6575f1cef7f0c8064f --- /dev/null +++ b/chembl3d/explicit_h/runtime/requirements-tested.txt @@ -0,0 +1,19 @@ +torch==2.9.0 +numpy==2.4.2 +rdkit==2025.9.6 +lightning==2.6.1 +pytorch-lightning==2.6.1 +torch-geometric==2.7.0 +torchmetrics==1.8.2 +hydra-core==1.3.2 +omegaconf==2.3.0 +scipy==1.17.1 +pandas==3.0.1 +wandb==0.25.0 +posebusters==0.6.5 +matplotlib==3.10.9 +lmdb==2.2.0 +click==8.3.1 +py3dmol==2.5.4 +ipykernel==7.2.0 +ase==3.28.0 diff --git a/chembl3d/explicit_h/runtime/src/morph/__init__.py b/chembl3d/explicit_h/runtime/src/morph/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..320a78e02ba300f6433f6b0131e073b0517f6353 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/__init__.py @@ -0,0 +1 @@ +"""morph — flow-matching generative modeling for molecules and materials.""" diff --git a/chembl3d/explicit_h/runtime/src/morph/__main__.py b/chembl3d/explicit_h/runtime/src/morph/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..bfc34c1fb7a9b7c2a1f0ec010fc40eb07495c7b1 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/_vendor/__init__.py b/chembl3d/explicit_h/runtime/src/morph/_vendor/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..24bba35366c470be8093d2f9f133cb384a991740 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/_vendor/geom_drugs_preprocessing.py b/chembl3d/explicit_h/runtime/src/morph/_vendor/geom_drugs_preprocessing.py new file mode 100644 index 0000000000000000000000000000000000000000..b6037a4822dc690f1913505d661da1e11a488d96 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/_vendor/muon.py b/chembl3d/explicit_h/runtime/src/morph/_vendor/muon.py new file mode 100644 index 0000000000000000000000000000000000000000..92e596caf3d6194a5829602568048ea0680f6893 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/cli.py b/chembl3d/explicit_h/runtime/src/morph/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..0d558802812799bf5468090730771d6cfadb86cc --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/cli.py @@ -0,0 +1,133 @@ +# 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 + +The model registry is intentionally empty until a checkpoint has been trained +with the current architecture. Conditional scaffold subcommands (dataset-free, +molecule/scaffold+mask driven) are coming next. +""" + +from __future__ import annotations + +import click + + +@click.group() +@click.version_option(package_name="morph") +def cli() -> None: + """Morph — molecular generation CLI.""" + + +@cli.command() +@click.option( + "-b", + "--batch", + "n_mols", + default=16, + show_default=True, + help="Number of molecules to generate.", +) +@click.option( + "-o", + "--output", + type=click.Path(), + show_default=True, + default="eval_outputs/geom_denovo/denovo.sdf", + help="Output SDF of final-frame molecules (3D coords + SMILES/logP).", +) +@click.option( + "--model", + "model_name", + default=None, + help="Named current-architecture model from the registry.", +) +@click.option( + "--weights-dir", + default=None, + help="Downloaded bundle dir (checkpoint + vocab + dist).", +) +@click.option( + "--checkpoint", + default=None, + help="Override the checkpoint path (else the bundle's default file).", +) +@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, +) -> None: + """Generate molecules de-novo with an unconditional checkpoint. + + Current and EMA models compile by default. Set MORPH_COMPILE=0 to disable. + """ + from pathlib import Path + + import torch + + from morph.inference.generate import generate_denovo, record_mol, write_sdf + from morph.inference.registry import resolve + + m = resolve("generate", model_name, checkpoint, weights_dir) + click.echo( + f"[morph generate] model={m['name']} ckpt={m['checkpoint']} n={n_mols}" + ) + + trajectories, is_valid, vocab, _coord_std = generate_denovo( + m["checkpoint"], + m["overrides"], + 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/chembl3d/explicit_h/runtime/src/morph/config.py b/chembl3d/explicit_h/runtime/src/morph/config.py new file mode 100644 index 0000000000000000000000000000000000000000..da5bb482ee9d174a998bdb034503cd5cb02b8741 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/__init__.py b/chembl3d/explicit_h/runtime/src/morph/dataset/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..10e5dc5a50476b29d2ae1d166f14155adb49730d --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/__init__.py b/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d905b6cdbea624870c29d8776265d212acf02755 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/__init__.py b/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7cf1ec8108fa1e6beef793cfdc5adf792c1c941d --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/cutters.py b/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/cutters.py new file mode 100644 index 0000000000000000000000000000000000000000..de0a5d2d1b24fc710e7d6164e2c176afdfe5f1a1 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/datamodule.py b/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/datamodule.py new file mode 100644 index 0000000000000000000000000000000000000000..2cd77ef47e018297b248a50fccf4850ab70ec741 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/lmdb.py b/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/lmdb.py new file mode 100644 index 0000000000000000000000000000000000000000..669931f4d3ca0400cb18c5596bc1435ef8b4d3bb --- /dev/null +++ b/chembl3d/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.unconditional.geom 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/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/wrappers.py b/chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/wrappers.py new file mode 100644 index 0000000000000000000000000000000000000000..8cc5ad15e367400a5cffbd36b61e05e856546c57 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/core/__init__.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a62d4083497c96476cf11f482ad769736b7a8e6d --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/core/atomic_data.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/atomic_data.py new file mode 100644 index 0000000000000000000000000000000000000000..776461c5440a93e54ace90df1713bcdb505886db --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/core/batch.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/batch.py new file mode 100644 index 0000000000000000000000000000000000000000..74b1069b098f3fc9f52ed0f3efdfe1aaf64cf1ed --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/core/containers.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/containers.py new file mode 100644 index 0000000000000000000000000000000000000000..ce3942e9fe3f04b64e8026a89531aacc11ac1b3b --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/dataset/core/containers.py @@ -0,0 +1,505 @@ +"""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 = 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): + """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() + 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, + ): + """Convert this graph to an RDKit molecule.""" + 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() + 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, + ) + + 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/chembl3d/explicit_h/runtime/src/morph/dataset/core/flow_matching_wrapper.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/flow_matching_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..7fa8efe43e66c34041cdb98ba9e1201ea42047a2 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/core/insertion_data.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/insertion_data.py new file mode 100644 index 0000000000000000000000000000000000000000..d8415a4e5bd055330292aab3a0f59dec913cc372 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/core/insertion_targets.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/insertion_targets.py new file mode 100644 index 0000000000000000000000000000000000000000..e0d32eef34d675e0ae42db78aacee9e8ecf9dd40 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/core/lightning_datamodule.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/lightning_datamodule.py new file mode 100644 index 0000000000000000000000000000000000000000..339b1def0e98ea033858e2bf4ada4e338ce27ed7 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/core/preprocessing.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/preprocessing.py new file mode 100644 index 0000000000000000000000000000000000000000..49704e0c9b7e1aee7c0db28839be688141a722df --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/dataset/core/preprocessing.py @@ -0,0 +1,542 @@ +"""Preprocessing class for calculating token distributions from datasets.""" + +import importlib +import os +import tempfile +from multiprocessing import get_context + +import torch +from torch_geometric.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) + worker = object.__new__(Preprocessing) + worker.train_dataset = dataset + worker.vocab = vocab + _PREPROCESSING_WORKER = worker + + +def _preprocessing_chunk(task): + method, indices = task + return getattr(_PREPROCESSING_WORKER, method)(indices) + + +class Preprocessing: + """ + Preprocessing class that computes token distributions from a training dataset. + + This class should be instantiated before the datamodule to compute distributions + from the training dataset only. The computed tokens and distributions are then + passed to the datamodule and model. + + Tokens are discovered from the data and saved to a file. On subsequent runs, + tokens are loaded from the file if it exists. + """ + + # Special tokens that must always be present + 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, + ): + """ + Initialize preprocessing. + + Args: + root: Root directory path for the dataset + train_dataset: Training dataset instance or Hydra config for lazy + instantiation. It is only instantiated when preprocessing files are + missing. + atom_tokens_path: Path to save/load atom tokens. + If None, uses root/atom_tokens.txt + edge_tokens_path: Path to save/load edge tokens. + If None, uses root/edge_tokens.txt + charge_tokens_path: Path to save/load charge tokens. + If None, uses root/charge_tokens.txt + distributions_path: Path to save/load distributions. + If None, uses root/distributions.pt + num_workers: Processes for vocabulary and training statistics. + """ + if num_workers < 0: + raise ValueError("num_workers must be non-negative") + self.num_workers = num_workers + self.root = root + self._train_dataset_cfg = train_dataset + self.train_dataset = None + + # Set default tokens path if not provided + if atom_tokens_path is None: + atom_tokens_path = os.path.join(self.root, "atom_tokens.txt") + + # Set default edge tokens path if not provided + if edge_tokens_path is None: + edge_tokens_path = os.path.join(self.root, "edge_tokens.txt") + + # Set default charge tokens path if not provided + if charge_tokens_path is None: + charge_tokens_path = os.path.join(self.root, "charge_tokens.txt") + + # Set default distributions path if not provided + if distributions_path is None: + distributions_path = os.path.join(self.root, "distributions.pt") + + self.atom_tokens_path = atom_tokens_path + self.edge_tokens_path = edge_tokens_path + self.charge_tokens_path = charge_tokens_path + self.distributions_path = 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() + + # Load or compute tokens (both computed together if either is missing) + self.vocab = None + self.vocab = self._load_or_compute_tokens() + self.distributions = self._load_or_compute_distributions( + force=not self._artifacts_were_complete + ) + + # remove train dataset to free memory + 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 tokens from files if they exist, + otherwise compute both from data in a single loop and save. + + Returns: + Vocab object + """ + # Try to load both from files + 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: + # Compute both from data in a single loop + 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) + + token_dict = { + "atom_tokens": atom_tokens, + "edge_tokens": edge_tokens, + "charge_tokens": charge_tokens, + } + + vocab = Vocab(**token_dict) + return vocab + + def _compute_tokens_from_data( + self, + ) -> tuple[list[str], list[str], list[str]]: + """ + Compute both tokens and edge tokens by extracting unique types + from training data in a single loop. + + Returns: + Tuple of (tokens, edge_tokens, charge_tokens) + """ + 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: + yield getattr(self, method)(range(length)) + return + workers = min(workers, length) + chunk_size = min(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 _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 _save_tokens(self, path: str, tokens: list[str]): + """Save tokens to file. + + Args: + path: Path to the tokens file + tokens: List of tokens to save + """ + 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]: + """Load tokens from file. + + Args: + path: Path to the tokens file + + Returns: + List of tokens loaded from file + """ + 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 distributions from file if exists, otherwise compute from data and save. + + Returns: + Distributions object + """ + # Try to load from file + if os.path.exists(self.distributions_path) and not force: + distributions = self._load_distributions() + else: + # Compute distributions from data + distributions = self._compute_distributions() + + # Save distributions to file + self._save_distributions(distributions) + + distributions = Distributions(**distributions) + return distributions + + 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 ( + hasattr(data, "edge_attr") + and data.edge_attr is not None + and data.edge_attr.numel() > 0 + ): + 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, + } + + 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: + 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/chembl3d/explicit_h/runtime/src/morph/dataset/core/properties.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/properties.py new file mode 100644 index 0000000000000000000000000000000000000000..c606bbda163ca46a357d71817de2515485dcb480 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/core/representation.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/representation.py new file mode 100644 index 0000000000000000000000000000000000000000..ccfde2cf7c47e9e77338016fa8f8a4269ff8b4a4 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/core/tokenized_decode.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/tokenized_decode.py new file mode 100644 index 0000000000000000000000000000000000000000..7b29af15aa4402cb83ce5da7b1224327458f24bb --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/dataset/core/tokenized_decode.py @@ -0,0 +1,83 @@ +"""Decode stored QM9/GEOM 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.utils.vocab_utils import z_to_atom_types + + +class TokenizedDecodeMixin: + """Stored PyG ``Data`` -> token-index ``ChemAtomicData`` decode, shared by + :class:`FlowMatchingGEOMDataset` and :class:`FlowMatchingQM9Dataset`. + """ + + def _build_token_maps(self, vocab) -> None: + """Build tensor lookup tables for atomic numbers and formal charges.""" + 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 + + 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=self._z_to_atom_idx[data.z.long()], + e=data.edge_attr.long(), + c=self._charge_to_idx[data.charges.long() - self._charge_min], + edge_index=data.edge_index, + **self._build_conditions(data, index), + ) diff --git a/chembl3d/explicit_h/runtime/src/morph/dataset/core/vocab.py b/chembl3d/explicit_h/runtime/src/morph/dataset/core/vocab.py new file mode 100644 index 0000000000000000000000000000000000000000..af28186c1c928a0a56b35e4858f1ddaa525eeb8b --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/__init__.py b/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6bcfd2cf5eb8e220756c471312279d00685a1f32 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py b/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py new file mode 100644 index 0000000000000000000000000000000000000000..bb777c579d87aa5e096af855da7ddd489f2adab4 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py @@ -0,0 +1,370 @@ +"""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 argparse +import hashlib +import os +import tempfile +from multiprocessing import get_context +from pathlib import Path +from typing import TYPE_CHECKING +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.representation import Capabilities +from morph.dataset.core.tokenized_decode import TokenizedDecodeMixin +from morph.dataset.unconditional.geom import ( + GEOM, + mol_to_bytes, + read_lmdb_length, + write_bytes_to_lmdb, +) +from morph.utils.rdkit_utils import ( + BOND_IDX_MAP, + IDX_BOND_MAP, + mol_is_valid, + sanitize_mol_correctly, + smiles_from_mol, +) + +if TYPE_CHECKING: + from morph.dataset.core.vocab import Distributions, Vocab + + +# 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, +) +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) -> 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. + """ + 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]") + if (labels < 1).any() or (labels > 8).any(): + raise ValueError("Unknown LoQI edge label (expected physical 1--4/stereo 5--8)") + if (edges < 0).any() or (edges >= n_atoms).any(): + raise ValueError("Edge index outside the molecule") + + physical = labels <= 4 + 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() + if not mol_is_valid(mol, allow_charged=True): + return None + 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 + smiles = smiles_from_mol(mol, canonical=True) + if not smiles: + return None + + pairs, bond_types = [], [] + for bond in mol.GetBonds(): + i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + pairs.extend(((i, j), (j, i))) + bond_types.extend([BOND_IDX_MAP[bond.GetBondType()]] * 2) + edge_index = torch.tensor(pairs, dtype=torch.long).reshape(-1, 2).t() + edge_attr = torch.tensor(bond_types, dtype=torch.long) + order = (edge_index[0] * n_atoms + edge_index[1]).argsort() + return Data( + z=z, + pos=pos, + charges=charges, + edge_index=edge_index[:, order].contiguous(), + edge_attr=edge_attr[order], + smiles=smiles, + ) + + +def _convert_to_bytes(record: Data) -> bytes | None: + data = convert_record(record) + return None if data is None else mol_to_bytes(data) + + +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(GEOM): + """LoQI ChEMBL3D splits with the GEOM LMDB storage and lazy reader contract. + + ``source_dir`` may contain ``train_h.pt`` directly, or an extracted + ``processed/`` or ``chembl3d_stereo/processed/`` directory. Without it, + look under the dataset root and download missing splits into ``raw/``. + Converted splits are published atomically in ``processed/``. GEOM's + downloader, prefilter and molecule-size policy are not used. + """ + + def __init__( + self, + root: str, + split: str = "train", + vocab: Vocab | None = None, + distributions: Distributions | None = None, + preprocess_workers: int | None = None, + source_dir: str | None = None, + ): + if split not in RELEASE_FILES: + raise ValueError( + f"Invalid split {split!r}; expected {tuple(RELEASE_FILES)}" + ) + if preprocess_workers is not None and preprocess_workers < 0: + raise ValueError("preprocess_workers must be non-negative") + self.root = os.fspath(root) + self.split = split + self.vocab = vocab + self.distributions = distributions + self.preprocess_workers = preprocess_workers + self.source_dir = source_dir + 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 = None + self._txn = None + self._txn_pid = None + os.makedirs(self.processed_dir, exist_ok=True) + if not os.path.exists(self.lmdb_path): + self.download() + self.process() + self._length = read_lmdb_length(self.lmdb_path) + sidecar = Path(self.processed_dir) / f"{split}_smiles.txt" + if not sidecar.is_file(): + self._write_smiles_sidecar() + + 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) + workers = self.preprocess_workers or 0 + records = _iter_records(data, slices, length) + with tempfile.TemporaryDirectory( + prefix=f".{self.split}_", dir=self.processed_dir + ) as staging: + output = str(Path(staging) / "data.lmdb") + if workers > 1: + with get_context("spawn").Pool(workers) as pool: + results = pool.imap(_convert_to_bytes, records, chunksize=128) + written, failed = write_bytes_to_lmdb(results, output, total=length) + else: + written, failed = write_bytes_to_lmdb( + map(_convert_to_bytes, records), output, total=length + ) + if written == 0: + raise ValueError(f"No valid molecules in {source}") + os.rename(output, self.lmdb_path) + print(f"{self.split}: wrote {written} molecules; skipped {failed}") + + +class FlowMatchingChEMBL3DDataset(TokenizedDecodeMixin, ChEMBL3D): + """Decode ChEMBL3D LMDB records into Morph's flow-matching targets.""" + + CAPABILITIES = Capabilities(provides_charges=True, provides_topology=True) + + def __init__( + self, + root, + vocab: Vocab | None, + distributions: Distributions | None, + transform=None, + pre_transform=None, + split="train", + compute_properties: bool = False, + property_names: tuple[str, ...] = ("qed", "logp"), + preprocess_workers: int | None = None, + source_dir: str | None = None, + ): + super().__init__( + root, + split=split, + vocab=vocab, + distributions=distributions, + preprocess_workers=preprocess_workers, + source_dir=source_dir, + ) + if vocab is not None: + self._build_token_maps(vocab) + self._load_property_arrays(compute_properties, property_names, split) + + def __getitem__(self, index): + """Return tokenized atom, bond and charge labels and normalized 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): + """Return raw PyG fields for shared vocabulary/statistics preprocessing.""" + return super().__getitem__(index) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", default="data/chembl3d", help="Morph dataset output root" + ) + parser.add_argument("--source-dir", help="Existing LoQI release directory") + parser.add_argument( + "--splits", nargs="+", choices=tuple(RELEASE_FILES), default=list(RELEASE_FILES) + ) + parser.add_argument("--workers", type=int, default=0) + args = parser.parse_args() + if args.workers < 0: + parser.error("--workers must be non-negative") + for split in args.splits: + ChEMBL3D( + args.root, + split=split, + source_dir=args.source_dir, + preprocess_workers=args.workers, + ) + + +if __name__ == "__main__": + main() diff --git a/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d_filter.py b/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d_filter.py new file mode 100644 index 0000000000000000000000000000000000000000..7cee1c365c5a31975412707456d05b52f4da2734 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d_filter.py @@ -0,0 +1,199 @@ +"""Create an atom-count-limited copy of an already processed ChEMBL3D dataset.""" + +from __future__ import annotations + +import argparse +import json +import os +import pickle +import tempfile +from collections import Counter +from multiprocessing import get_context +from pathlib import Path + +import numpy as np +import torch + +from morph.dataset.core.preprocessing import Preprocessing +from morph.dataset.unconditional.geom import ( + _int_key, + open_read_env, + read_lmdb_length, + write_bytes_to_lmdb, +) + +_FILTER_ENV = None +_FILTER_MAX_ATOMS = None + + +def _init_filter_worker(path, max_atoms): + global _FILTER_ENV, _FILTER_MAX_ATOMS # noqa: PLW0603 + torch.set_num_threads(1) + _FILTER_ENV = open_read_env(path) + _FILTER_MAX_ATOMS = max_atoms + + +def _select_chunk(task): + """Inspect all atoms, including H, without altering a retained record.""" + start, stop = task + indices, smiles, counts = [], [], Counter() + with _FILTER_ENV.begin() as txn: + for index in range(start, stop): + raw = txn.get(_int_key(index)) + if raw is None: + raise RuntimeError(f"Missing source record {index}") + record = pickle.loads(raw) + n = int(record["z"].numel()) + counts[n] += 1 + if n <= _FILTER_MAX_ATOMS: + indices.append(index) + smiles.append(record["smiles"]) + return indices, smiles, counts + + +def filter_split(source: Path, output: Path, split: str, max_atoms: int, workers: int): + """Filter in parallel, then copy eligible bytes in their original order.""" + source_lmdb = source / f"{split}_data.lmdb" + length = read_lmdb_length(str(source_lmdb)) + tasks = [(start, min(start + 4096, length)) for start in range(0, length, 4096)] + selected, smiles, counts = [], set(), Counter() + # This reader belongs only to the parent; spawned workers open their own. + env = open_read_env(str(source_lmdb)) + + def retained_records(results): + with env.begin() as txn: + for indices, chunk_smiles, histogram in results: + selected.extend(indices) + smiles.update(chunk_smiles) + counts.update(histogram) + for index in indices: + yield bytes(txn.get(_int_key(index))) + + try: + if workers > 1: + with get_context("spawn").Pool( + workers, + initializer=_init_filter_worker, + initargs=(str(source_lmdb), max_atoms), + ) as pool: + written, _ = write_bytes_to_lmdb( + retained_records(pool.imap(_select_chunk, tasks)), + str(output / f"{split}_data.lmdb"), + desc=f"Copying {split} <= {max_atoms} atoms", + ) + else: + # Reuse the parent env in serial mode (LMDB disallows a second open). + global _FILTER_ENV, _FILTER_MAX_ATOMS # noqa: PLW0603 + _FILTER_ENV, _FILTER_MAX_ATOMS = env, max_atoms + written, _ = write_bytes_to_lmdb( + retained_records(map(_select_chunk, tasks)), + str(output / f"{split}_data.lmdb"), + desc=f"Copying {split} <= {max_atoms} atoms", + ) + finally: + env.close() + if workers <= 1: + _FILTER_ENV = None + if written == 0: + raise ValueError(f"Cutoff removed every molecule in {split}") + assert written == len(selected) + assert sum(counts.values()) == length + (output / f"{split}_smiles.txt").write_text("\n".join(sorted(smiles))) + np.save( + output / f"{split}_source_indices.npy", np.asarray(selected, dtype=np.int64) + ) + return { + "source_molecules": length, + "retained": written, + "excluded": length - written, + "retained_fraction": written / length, + "source_atom_counts": dict(sorted(counts.items())), + "max_retained_atoms": max(n for n in counts if n <= max_atoms), + } + + +def filter_dataset(source_root: Path, root: Path, max_atoms: int, workers: int = 0): + """Publish all splits, sidecars and recomputed statistics atomically.""" + source_root, root = source_root.resolve(), root.resolve() + if max_atoms < 1 or workers < 0: + raise ValueError("max_atoms must be positive and workers non-negative") + if source_root == root: + raise ValueError("Filtered output must use a separate dataset root") + source, output = source_root / "processed", root / "processed" + fingerprint = {} + for split in ("train", "val", "test"): + path = source / f"{split}_data.lmdb" / "data.mdb" + stat = path.stat() + fingerprint[split] = {"bytes": stat.st_size, "mtime_ns": stat.st_mtime_ns} + identity = { + "source_root": str(source_root), + "source_files": fingerprint, + "max_total_atoms": max_atoms, + "includes_hydrogens": True, + } + if output.exists(): + summary_path = output / "preprocessing_summary.json" + summary = json.loads(summary_path.read_text()) if summary_path.is_file() else {} + if summary.get("status") == "ready" and all( + summary.get(k) == v for k, v in identity.items() + ): + print("Reusing completed filtered dataset", root, flush=True) + return summary + raise FileExistsError( + f"Output cache is incomplete or has a different cutoff/source: {output}" + ) + root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".filter_", dir=root) as temporary: + staging = Path(temporary) + processed = staging / "processed" + processed.mkdir() + splits = {} + for split in ("train", "val", "test"): + splits[split] = filter_split(source, processed, split, max_atoms, workers) + print(split, json.dumps(splits[split]), flush=True) + preprocessing = Preprocessing( + root=str(staging), + train_dataset={ + "_target_": ( + "morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset" + ), + "root": str(staging), + "vocab": None, + "distributions": None, + }, + atom_tokens_path=str(processed / "atom_tokens.txt"), + edge_tokens_path=str(processed / "edge_tokens.txt"), + charge_tokens_path=str(processed / "charge_tokens.txt"), + distributions_path=str(processed / "distributions.pt"), + num_workers=workers, + ) + support = preprocessing.distributions.n_atoms_distribution.nonzero().flatten() + assert int(support.max()) <= max_atoms + summary = { + **identity, + "status": "ready", + "job_id": os.environ.get("SLURM_JOB_ID"), + "molecules": {s: v["retained"] for s, v in splits.items()}, + "splits": splits, + "coordinate_std_A": float(preprocessing.distributions.coordinate_std), + } + (processed / "preprocessing_summary.json").write_text( + json.dumps(summary, indent=2) + "\n" + ) + os.rename(processed, output) + print(json.dumps(summary, indent=2), flush=True) + return summary + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--max-atoms", type=int, default=100) + parser.add_argument("--workers", type=int, default=0) + args = parser.parse_args() + filter_dataset(args.source_root, args.root, args.max_atoms, args.workers) + + +if __name__ == "__main__": + main() diff --git a/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/geom.py b/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/geom.py new file mode 100644 index 0000000000000000000000000000000000000000..e3ec6169f043803f3f0ada457332e53ab48ca567 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/geom.py @@ -0,0 +1,552 @@ +import os +import pickle +from collections.abc import Iterable +from multiprocessing import get_context + +import lmdb +import torch +from rdkit import Chem +from torch.utils.data import Dataset +from torch_geometric.data import Data, download_url +from tqdm import tqdm + +from morph._vendor.geom_drugs_preprocessing import process_geom_drugs +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, +) + +# 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", +} + +PICKLE_PROTOCOL = 4 + +# 128 GiB virtual address reservation for the LMDB file. This is NOT disk +# usage — the file grows sparsely with actual data. Large enough to hold the +# full processed GEOM train split with headroom. +LMDB_MAP_SIZE = 128 * (1024**3) + +# Reserved metadata key for entry count. Integer data keys are 8-byte +# big-endian, so this textual key cannot collide with them. +LEN_KEY = b"__len__" + +# FlowMol3 atom vocabulary. Conformers containing any element outside this set +# are dropped during processing. The outlier elements observed in the raw GEOM +# train SMILES are vanishingly rare compared to the kept set (counts from +# notebooks/atom_type_counts.ipynb): B=14, Si=8, Bi=3 — i.e. ≤2e-6 of all +# atoms, vs. ≥200 for the rarest kept element (I). +ALLOWED_ATOM_SYMBOLS = frozenset({"H", "C", "N", "O", "S", "F", "Cl", "Br", "P", "I"}) + + +def _int_key(i: int) -> bytes: + return i.to_bytes(8, "big") + + +def process_one_conformer(mol: Chem.Mol): + """Process a single RDKit conformer and extract features. + + Returns a Data object or None if processing fails. + """ + if mol is None or not mol_is_valid(mol, allow_charged=True): + return None + + mol = sanitize_mol_correctly(mol) + if mol is None: + return None + + # Force Kekulé form so aromatic ring bonds become SINGLE/DOUBLE rather than + # BondType.AROMATIC. Sanitization above re-perceives aromaticity, undoing + # the kekulization applied by the GEOM-Drugs Revisited pre-filter; this + # call restores it so AROMATIC never reaches the bond-type tensor (Nikitin + # et al., 2025 — kekulization is the paper's recommended fix for the + # valency ambiguity of aromatic bonds). + try: + Chem.Kekulize(mol, clearAromaticFlags=True) + except Exception: + return None + + try: + N = mol.GetNumAtoms() + if N >= 72: + return None + if any(a.GetSymbol() not in ALLOWED_ATOM_SYMBOLS for a in mol.GetAtoms()): + return None + conf = mol.GetConformer() + pos = conf.GetPositions() + pos = torch.tensor(pos, dtype=torch.float) + + charges = [] + atomic_number = [] + for atom in mol.GetAtoms(): + atomic_number.append(atom.GetAtomicNum()) + charges.append(atom.GetFormalCharge()) + + z = torch.tensor(atomic_number, dtype=torch.long) + charges = torch.tensor(charges, dtype=torch.int64) + + rows, cols, edge_types = [], [], [] + for bond in mol.GetBonds(): + start, end = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + rows += [start, end] + cols += [end, start] + edge_types += 2 * [BOND_IDX_MAP[bond.GetBondType()]] + + if len(rows) == 0: + edge_index = torch.tensor([[], []], dtype=torch.long) + edge_type = torch.tensor([], dtype=torch.long) + else: + edge_index = torch.tensor([rows, cols], dtype=torch.long) + edge_type = torch.tensor(edge_types, dtype=torch.long) + + perm = (edge_index[0] * N + edge_index[1]).argsort() + edge_index = edge_index[:, perm] + edge_type = edge_type[perm] + + smiles = smiles_from_mol(mol, canonical=True) or "" + + data = Data( + z=z, + pos=pos, + charges=charges, + edge_index=edge_index, + smiles=smiles, + edge_attr=edge_type, + ) + + return data + except Exception as e: + print(f"Error processing molecule: {e}") + return None + + +def mol_to_bytes(data: Data) -> bytes: + """Serialize a processed molecule Data object to compact pickle bytes. + + Stores only the raw tensor data as a plain dict, avoiding the overhead + of pickling full PyG Data objects. + """ + dict_repr = { + "z": data.z, + "pos": data.pos, + "charges": data.charges, + "edge_index": data.edge_index, + "edge_attr": data.edge_attr, + "smiles": data.smiles, + } + return pickle.dumps(dict_repr, protocol=PICKLE_PROTOCOL) + + +def mol_from_bytes(data: bytes) -> Data: + """Deserialize pickle bytes back into a PyG Data object.""" + obj = pickle.loads(data) + return Data( + z=obj["z"], + pos=obj["pos"], + charges=obj["charges"], + edge_index=obj["edge_index"], + smiles=obj["smiles"], + edge_attr=obj["edge_attr"], + ) + + +def process_one_conformer_to_bytes(mol: Chem.Mol) -> bytes | None: + """Process one conformer and return serialized bytes if valid.""" + data = process_one_conformer(mol) + if data is None: + return None + return mol_to_bytes(data) + + +def open_read_env(lmdb_path: str) -> lmdb.Environment: + """Open an LMDB environment configured for multi-worker read access. + + ``lock=False`` + ``readonly=True`` allows multiple processes (DataLoader + workers) to share the mmap without the writer lock. ``readahead=False`` + is better for random access patterns typical in training. + """ + return lmdb.open( + lmdb_path, + readonly=True, + lock=False, + readahead=False, + meminit=False, + subdir=True, + max_readers=512, + ) + + +def read_lmdb_length(lmdb_path: str) -> int: + """Return the number of molecules stored in an LMDB env.""" + env = open_read_env(lmdb_path) + try: + with env.begin() as txn: + raw = txn.get(LEN_KEY) + if raw is not None: + return int(raw) + return txn.stat()["entries"] + finally: + env.close() + + +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 + + +def process_conformers_to_lmdb( + conformers: list[Chem.Mol], + lmdb_path: str, + num_workers: int | None = None, + chunksize: int = 128, + map_size: int = LMDB_MAP_SIZE, + commit_every: int = 10_000, +) -> tuple[int, int]: + """Process conformers in parallel and stream serialized bytes into LMDB. + + Writes happen as workers complete so peak memory stays bounded regardless + of dataset size (unlike accumulating a full in-memory list first). + """ + if num_workers is None: + cpu_count = os.cpu_count() or 1 + num_workers = max(1, cpu_count - 1) + + if num_workers <= 1: + iterator = (process_one_conformer_to_bytes(c) for c in conformers) + return write_bytes_to_lmdb( + iterator, + lmdb_path, + total=len(conformers), + map_size=map_size, + commit_every=commit_every, + desc="Processing conformers", + ) + + ctx = get_context("spawn") + with ctx.Pool(processes=num_workers) as pool: + outputs = pool.imap_unordered( + process_one_conformer_to_bytes, conformers, chunksize=chunksize + ) + return write_bytes_to_lmdb( + outputs, + lmdb_path, + total=len(conformers), + map_size=map_size, + commit_every=commit_every, + desc="Processing conformers", + ) + + +class GEOM(Dataset): + """GEOM dataset backed by an LMDB environment. + + The processed data lives in ``/processed/_data.lmdb/`` as a + key-value store with 8-byte big-endian integer keys mapping to the same + compact pickle-dict bytes produced by :func:`mol_to_bytes`. + + Reads are memory-mapped: the dataset does not load into RAM, and multiple + DataLoader workers share the mmap. The LMDB handle is opened lazily on + first access so it's safe under both ``fork`` and ``spawn`` worker start + methods. + """ + + def __init__( + self, + root: str, + split: str = "train", + vocab: Vocab = None, + distributions: Distributions = None, + preprocess_workers: int | None = None, + ): + self.root = root + self.split = split + self.vocab = vocab + self.distributions = distributions + self.preprocess_workers = preprocess_workers + + self.raw_dir = os.path.join(root, "raw") + # Output dir of the GEOM-Drugs Revisited pre-filter (Nikitin et al., + # 2025). Sits between ``raw/`` and ``processed/`` and holds the + # sanitized pickle files plus the empirical ``valency_dict.json``. + self.filtered_dir = os.path.join(root, "raw_filtered") + self.processed_dir = os.path.join(root, "processed") + + os.makedirs(self.processed_dir, exist_ok=True) + + self.lmdb_path = os.path.join(self.processed_dir, f"{split}_data.lmdb") + + if not os.path.exists(self.lmdb_path): + self.download() + self.prefilter() + self.process() + + self._length = read_lmdb_length(self.lmdb_path) + self._env: lmdb.Environment | None = None + self._txn = None + self._txn_pid: int | None = None + + def _ensure_env(self) -> lmdb.Environment: + if self._env is None: + self._env = open_read_env(self.lmdb_path) + return self._env + + def _ensure_txn(self): + """Return a per-process, reused read transaction. + + Opening a fresh ``env.begin()`` per ``get()`` leaks ~65 kB of native + memory per transaction in the lmdb binding (measured), which OOM-killed + read-heavy conditional tasks. One long-lived read + txn per worker — keyed by PID so forked/spawned workers each get their + own — reads from the same static snapshot with zero per-read growth. + Also fixes a latent use-after-close: ``mol_from_bytes(bytes(raw))`` runs + after the txn would have closed under the old ``with`` block. + """ + pid = os.getpid() + if self._txn is not None and self._txn_pid == pid: + return self._txn + self._txn = self._ensure_env().begin(buffers=True) + self._txn_pid = pid + return self._txn + + def __getstate__(self): + """Omit live LMDB handles when serializing for DataLoader workers.""" + # Strip live env/txn handles so DataLoader workers using spawn can + # pickle the dataset. Each worker reopens lazily on first access. + state = self.__dict__.copy() + state["_env"] = None + state["_txn"] = None + state["_txn_pid"] = None + return state + + def download(self) -> None: + """Fetch raw GEOM-Drugs pickle files from bits.csb.pitt.edu. + + Mirrors the PyG ``InMemoryDataset.download`` pattern: each of the three + split pickles is downloaded into ``raw/`` if it is not already present. + Files that already exist on disk are left untouched so re-running + training does not re-fetch ~8 GB. + """ + 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: + """Apply the GEOM-Drugs Revisited pre-filter to all available splits. + + Defers to ``morph._vendor.geom_drugs_preprocessing.process_geom_drugs``, + which sanitizes/kekulizes molecules, drops disconnected fragments, runs + a covalent-radius topology check on every conformer, and writes the + cleaned pickle files plus a ``valency_dict.json`` into ``raw_filtered/``. + + Runs once per dataset root: if every filtered pickle is already on disk + the step is skipped. + """ + 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) + + # Reclaim ~8 GB by truncating each raw pickle whose filtered copy now + # exists. A zero-byte sentinel is left in place so ``download()`` still + # short-circuits on subsequent runs. + 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) + + print("Preparing conformers for processing...") + all_conformers = [ + conformer + for _smiles, conformers_list in tqdm(raw_data, desc="Preparing data") + for conformer in conformers_list + ] + del raw_data + + print(f"Total conformers to process: {len(all_conformers)}") + n_written, n_failed = process_conformers_to_lmdb( + all_conformers, + self.lmdb_path, + num_workers=self.preprocess_workers, + ) + print(f"Successfully processed: {n_written} molecules") + print(f"Failed: {n_failed} molecules") + del all_conformers + + self._write_smiles_sidecar() + + def _write_smiles_sidecar(self) -> None: + """Build the unique-SMILES sidecar by reading the LMDB env once.""" + smiles_path = os.path.join(self.processed_dir, f"{self.split}_smiles.txt") + unique: set[str] = set() + env = open_read_env(self.lmdb_path) + try: + with env.begin() as txn, txn.cursor() as cur: + for key, value in tqdm(cur, desc="Collecting SMILES"): + if bytes(key) == LEN_KEY: + continue + unique.add(pickle.loads(bytes(value))["smiles"]) + finally: + env.close() + with open(smiles_path, "w") as f: + f.write("\n".join(sorted(unique))) + + def get_all_smiles(self) -> list[str]: + """Read SMILES in the active 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() + + def iter_bytes(self) -> Iterable[bytes]: + """Iterate raw serialized molecule bytes in insertion order.""" + env = self._ensure_env() + with env.begin() as txn: + for i in range(self._length): + raw = txn.get(_int_key(i)) + if raw is None: + continue + yield bytes(raw) + + def __len__(self): + """Return the number of records in the active split.""" + return self._length + + def __getitem__(self, index): + """Decode one raw molecular record from LMDB.""" + raw = self._ensure_txn().get(_int_key(index)) + if raw is None: + raise IndexError(index) + return mol_from_bytes(bytes(raw)) + + def get(self, index): + """Return a raw record using the dataset indexing interface.""" + return self.__getitem__(index) + + +class FlowMatchingGEOMDataset(TokenizedDecodeMixin, GEOM): + """Decode GEOM records into tokenized flow-matching 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"), + preprocess_workers: int | None = None, + ): + super().__init__( + root, + split=split, + vocab=vocab, + distributions=distributions, + preprocess_workers=preprocess_workers, + ) + + 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 tokenized atoms and bonds for one target molecule.""" + if self.vocab is None: + raise RuntimeError( + "A vocab is required for tokenized GEOM samples; " + "use .get() for raw data" + ) + return self._decode_to_lean(super().__getitem__(index), index) + + def get(self, index): + """Return raw PyG fields without requiring vocabulary or statistics.""" + # Contract: .get() returns RAW source data (PyG fields z / pos / + # edge_attr / charges / smiles) so the scaffold-cache build and + # preprocessing can iterate before/without a vocab. ``__getitem__`` is + # the canonical ChemAtomicData builder (needs vocab + distributions). + # + # NOTE: must call the *base* ``__getitem__`` explicitly. ``super().get()`` + # dispatches through ``GEOM.get`` → ``self.__getitem__``, which resolves + # back to THIS subclass's processed ``__getitem__`` (vocab-dependent, + # runs per-mol RDKit when compute_properties=True) — not the raw reader. + return super().__getitem__(index) diff --git a/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/qm9.py b/chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/qm9.py new file mode 100644 index 0000000000000000000000000000000000000000..0ffb06411d0e56c4409d84802389c3a8479f63b7 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/flow_matching/__init__.py b/chembl3d/explicit_h/runtime/src/morph/flow_matching/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d9b707d8cad88bfd9db1a9318b10da933375a2a --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/flow_matching/__init__.py @@ -0,0 +1 @@ +"""Flow-matching core: graph priors, interpolation paths, and integration.""" diff --git a/chembl3d/explicit_h/runtime/src/morph/flow_matching/assignment.py b/chembl3d/explicit_h/runtime/src/morph/flow_matching/assignment.py new file mode 100644 index 0000000000000000000000000000000000000000..64be2a6d5728675c8922c37057ccdaaf3792c679 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/flow_matching/integration.py b/chembl3d/explicit_h/runtime/src/morph/flow_matching/integration.py new file mode 100644 index 0000000000000000000000000000000000000000..11062d840a5279bf09009980345cfe52305a1c17 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/flow_matching/interpolation.py b/chembl3d/explicit_h/runtime/src/morph/flow_matching/interpolation.py new file mode 100644 index 0000000000000000000000000000000000000000..8cb3a45f05e451e2b1f81390928597a7ec672358 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/flow_matching/prior.py b/chembl3d/explicit_h/runtime/src/morph/flow_matching/prior.py new file mode 100644 index 0000000000000000000000000000000000000000..55d2a0955cd0c40e381c4c55502978a593d06e9c --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/flow_matching/sampling_step.py b/chembl3d/explicit_h/runtime/src/morph/flow_matching/sampling_step.py new file mode 100644 index 0000000000000000000000000000000000000000..4b246a1bcceb3513f24df93016b92dbda488e5a7 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/flow_matching/schedules.py b/chembl3d/explicit_h/runtime/src/morph/flow_matching/schedules.py new file mode 100644 index 0000000000000000000000000000000000000000..079f8c8b847bc383eb54d42453cae384721e889c --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/inference/__init__.py b/chembl3d/explicit_h/runtime/src/morph/inference/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..670600d41fcaca8484c6a075fc47774d017f474a --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/inference/__init__.py @@ -0,0 +1,21 @@ +"""In-process inference utilities backing the ``morph`` CLI.""" + +from morph.inference.generate import ( + build_sampling_module, + generate_denovo, + load_checkpoint, + make_predict_trainer, + record_mol, +) +from morph.inference.registry import DEFAULTS, MODELS, resolve + +__all__ = [ + "DEFAULTS", + "MODELS", + "build_sampling_module", + "generate_denovo", + "load_checkpoint", + "make_predict_trainer", + "record_mol", + "resolve", +] diff --git a/chembl3d/explicit_h/runtime/src/morph/inference/generate.py b/chembl3d/explicit_h/runtime/src/morph/inference/generate.py new file mode 100644 index 0000000000000000000000000000000000000000..554c76043befac25bbf4a083fffcecbee381dd56 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/inference/generate.py @@ -0,0 +1,295 @@ +# 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) + 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) + 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/chembl3d/explicit_h/runtime/src/morph/inference/registry.py b/chembl3d/explicit_h/runtime/src/morph/inference/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..7dcca12feea471c927baf183a11bea380b714ea3 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/inference/registry.py @@ -0,0 +1,65 @@ +"""Model registry for the ``morph`` CLI. + +A model is a **bundle**: a single directory holding the checkpoint plus the vocab ++ distributions files (``atom_tokens.txt``, ``edge_tokens.txt``, +``charge_tokens.txt``, ``distributions.pt``). Bundles are hosted externally and +downloaded on demand (see the README) — nothing is shipped in the repo. The CLI +runs entirely from a bundle: no dataset is ever touched. + +``--weights-dir`` points at a downloaded bundle; each registry entry names the +checkpoint file within it and the hydra overrides that rebuild the architecture. +""" + +from __future__ import annotations + +from pathlib import Path + +# src/morph/inference/registry.py -> repo root +_REPO = Path(__file__).resolve().parents[3] +# Generic local bundle root (gitignored). No checkpoint is registered until a +# fresh first-class model has been trained and exported. +DEFAULT_WEIGHTS_DIR = _REPO / "assets" / "model_ckpts" + +_VOCAB_FILES = { + "atom_tokens_path": "atom_tokens.txt", + "edge_tokens_path": "edge_tokens.txt", + "charge_tokens_path": "charge_tokens.txt", + "distributions_path": "distributions.pt", +} + +# Add entries only for checkpoints trained with the current repository model. +MODELS: dict[str, dict] = {} +DEFAULTS: dict[str, str] = {} + + +def vocab_overrides(weights_dir) -> list[str]: + """Hydra overrides pointing ``data.preprocessing`` at a bundle's vocab/dist.""" + d = Path(weights_dir) + return [f"data.preprocessing.{k}={d / fname}" for k, fname in _VOCAB_FILES.items()] + + +def resolve( + mode: str, name: str | None = None, checkpoint: str | None = None, weights_dir=None +) -> dict: + """Resolve a model entry against a bundle directory. + + Returns ``{name, checkpoint, overrides, weights_dir}`` where ``overrides`` + already includes the vocab/dist paths for ``weights_dir`` (default: + :data:`DEFAULT_WEIGHTS_DIR`). ``checkpoint`` overrides the bundle's default + checkpoint file; ``name`` picks a non-default model. + """ + if name is None: + name = DEFAULTS.get(mode) + if name is None: + raise KeyError(f"No default model registered for mode {mode!r}.") + if name not in MODELS: + raise KeyError(f"Unknown model {name!r}. Known: {sorted(MODELS)}") + entry = MODELS[name] + wd = Path(weights_dir) if weights_dir is not None else DEFAULT_WEIGHTS_DIR + ckpt = checkpoint if checkpoint is not None else str(wd / entry["checkpoint_name"]) + return { + "name": name, + "checkpoint": ckpt, + "overrides": [*entry["overrides"], *vocab_overrides(wd)], + "weights_dir": str(wd), + } diff --git a/chembl3d/explicit_h/runtime/src/morph/model/README.md b/chembl3d/explicit_h/runtime/src/morph/model/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d8f28600adbce10e359cf262e88682644cb942e8 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/__init__.py b/chembl3d/explicit_h/runtime/src/morph/model/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1497c59eef37001653fa1f7767cac1a31c4e5ac0 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/model/__init__.py @@ -0,0 +1 @@ +"""Model package: backbones, reusable layers, losses, and Lightning modules.""" diff --git a/chembl3d/explicit_h/runtime/src/morph/model/backbones/__init__.py b/chembl3d/explicit_h/runtime/src/morph/model/backbones/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d4ba7a4c605c984ddfedbbe7028fdd62086396e --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/model/backbones/__init__.py @@ -0,0 +1 @@ +"""The molecular DiT backbone and its conditional extension.""" diff --git a/chembl3d/explicit_h/runtime/src/morph/model/backbones/conditional_dit.py b/chembl3d/explicit_h/runtime/src/morph/model/backbones/conditional_dit.py new file mode 100644 index 0000000000000000000000000000000000000000..4287090a6a5fb5f8c100023de6a9fd41784cb28b --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/backbones/dit.py b/chembl3d/explicit_h/runtime/src/morph/model/backbones/dit.py new file mode 100644 index 0000000000000000000000000000000000000000..745fc58d6ce78ab0260d7d6162671592faa04b14 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/build.py b/chembl3d/explicit_h/runtime/src/morph/model/build.py new file mode 100644 index 0000000000000000000000000000000000000000..fa287a60f9118cae9ab03cfeddf44d5f6dfd1229 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/finetune/__init__.py b/chembl3d/explicit_h/runtime/src/morph/model/finetune/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..47b5a9eb5d013717c4851897a7d253d4a157279c --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/finetune/base/__init__.py b/chembl3d/explicit_h/runtime/src/morph/model/finetune/base/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..005c1613e1a4df3198bd5f565e73a0004e9e5c8c --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/finetune/base/conditional_backbone.py b/chembl3d/explicit_h/runtime/src/morph/model/finetune/base/conditional_backbone.py new file mode 100644 index 0000000000000000000000000000000000000000..f9c2e279574ac3c93195f335f041f855ed2a1f5e --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/finetune/base/context_encoder.py b/chembl3d/explicit_h/runtime/src/morph/model/finetune/base/context_encoder.py new file mode 100644 index 0000000000000000000000000000000000000000..08ef01fce6650b5f2752efc776714adacf393707 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/finetune/base/finetune_base.py b/chembl3d/explicit_h/runtime/src/morph/model/finetune/base/finetune_base.py new file mode 100644 index 0000000000000000000000000000000000000000..644084c72cc4600a182d373d3247be3f48281e66 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/finetune/scaffold/__init__.py b/chembl3d/explicit_h/runtime/src/morph/model/finetune/scaffold/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b0d8cde88fb6ab386fb9b6a83f6e359ca19ed3ce --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold.py b/chembl3d/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold.py new file mode 100644 index 0000000000000000000000000000000000000000..01867fd78848d1b8de0ea3cefac731bbf8cfc4c8 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold_context.py b/chembl3d/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold_context.py new file mode 100644 index 0000000000000000000000000000000000000000..8ffea81ddf0ed41fb3ccc5adc735b2ddeb1be146 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/layers/__init__.py b/chembl3d/explicit_h/runtime/src/morph/model/layers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0a05254bc5f23cf8659009d164755c1c23312b00 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/layers/attention.py b/chembl3d/explicit_h/runtime/src/morph/model/layers/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..7a54b3842f9965e913b6fbc807a9b213a6299fc0 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/layers/embedding.py b/chembl3d/explicit_h/runtime/src/morph/model/layers/embedding.py new file mode 100644 index 0000000000000000000000000000000000000000..10bc72dabda728e6e36aa916b70a7b886442973d --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/layers/gmm.py b/chembl3d/explicit_h/runtime/src/morph/model/layers/gmm.py new file mode 100644 index 0000000000000000000000000000000000000000..d64bcd8ca9f257052d2d5bde9543efe2459c0305 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/layers/heads.py b/chembl3d/explicit_h/runtime/src/morph/model/layers/heads.py new file mode 100644 index 0000000000000000000000000000000000000000..45ff2ae7907244eaa16c644305ce79c9d743a799 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/layers/kernels.py b/chembl3d/explicit_h/runtime/src/morph/model/layers/kernels.py new file mode 100644 index 0000000000000000000000000000000000000000..592fbbe70160531c527c5eea0dd6dce1a90b397c --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/layers/lora.py b/chembl3d/explicit_h/runtime/src/morph/model/layers/lora.py new file mode 100644 index 0000000000000000000000000000000000000000..784a0da934778ebc5a23ba49bfd690e8dffc619a --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/lightning/__init__.py b/chembl3d/explicit_h/runtime/src/morph/model/lightning/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..40c82d6620249d9e0eed505b47c19d01ca8a1fd0 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/lightning/_optimizer.py b/chembl3d/explicit_h/runtime/src/morph/model/lightning/_optimizer.py new file mode 100644 index 0000000000000000000000000000000000000000..f4182a65333d9762218383c04a238c19cb645f3b --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/lightning/_sampling.py b/chembl3d/explicit_h/runtime/src/morph/model/lightning/_sampling.py new file mode 100644 index 0000000000000000000000000000000000000000..79870381a02a5df045b2f2845382048d2662200d --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/model/lightning/_sampling.py @@ -0,0 +1,378 @@ +"""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 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/chembl3d/explicit_h/runtime/src/morph/model/lightning/_setup.py b/chembl3d/explicit_h/runtime/src/morph/model/lightning/_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..93f6248f6cc35915a8b97146198784d2386a4cb2 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/lightning/_training.py b/chembl3d/explicit_h/runtime/src/morph/model/lightning/_training.py new file mode 100644 index 0000000000000000000000000000000000000000..8a30ac7eda62a76ef8b14bba627663374b27479c --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/model/lightning/_training.py @@ -0,0 +1,530 @@ +"""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 _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. + + Subclasses override to hard-zero del / sub-a / sub-e (scaffold-scaffold) + probabilities and optionally coordinates at kept positions, while leaving + insertions intact so decorations can still grow. + """ + return x1_pred, do_sub_a_probs, do_sub_e_probs, do_del_probs, num_ins_pred + + 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/chembl3d/explicit_h/runtime/src/morph/model/lightning/_validation.py b/chembl3d/explicit_h/runtime/src/morph/model/lightning/_validation.py new file mode 100644 index 0000000000000000000000000000000000000000..760a4cb12e4c5e9175c3d5105947df70d8c7eeb2 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/lightning/base.py b/chembl3d/explicit_h/runtime/src/morph/model/lightning/base.py new file mode 100644 index 0000000000000000000000000000000000000000..8e47fd5a045105016798dbd037468c6188f4dab8 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/losses/__init__.py b/chembl3d/explicit_h/runtime/src/morph/model/losses/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..92286d0d607b74160e4907c06028c11e27d9b7a8 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/model/losses/functional.py b/chembl3d/explicit_h/runtime/src/morph/model/losses/functional.py new file mode 100644 index 0000000000000000000000000000000000000000..c631817fe0d68a79eea1be16dcafc60fe7fdd097 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/__init__.py b/chembl3d/explicit_h/runtime/src/morph/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..29bbd812cde4782119dc27b0c2a7073a93ba50c9 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/utils/__init__.py @@ -0,0 +1 @@ +"""Shared utilities: tensor/graph helpers, RDKit conversion, metrics, viz.""" diff --git a/chembl3d/explicit_h/runtime/src/morph/utils/checkpoints.py b/chembl3d/explicit_h/runtime/src/morph/utils/checkpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..4b6b6fd189f1ef5a8194e09c3104231f52b7f662 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/edge_alignment.py b/chembl3d/explicit_h/runtime/src/morph/utils/edge_alignment.py new file mode 100644 index 0000000000000000000000000000000000000000..b15835716bd6e19e8cb2a17af2741ce9c88199b1 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/graph_math.py b/chembl3d/explicit_h/runtime/src/morph/utils/graph_math.py new file mode 100644 index 0000000000000000000000000000000000000000..a3cbce3098c507a30512fd13db3ab3b7784e406c --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/loss_accumulation.py b/chembl3d/explicit_h/runtime/src/morph/utils/loss_accumulation.py new file mode 100644 index 0000000000000000000000000000000000000000..d4c8523585949eb02ae953c3597541216c9a0389 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/lr_schedulers.py b/chembl3d/explicit_h/runtime/src/morph/utils/lr_schedulers.py new file mode 100644 index 0000000000000000000000000000000000000000..11f5b6ac9551480c8d4b539ba75614f9add1d887 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/metrics/__init__.py b/chembl3d/explicit_h/runtime/src/morph/utils/metrics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6f7b65dd9bceb97f8944f4c1eef7f08d4ed5ddfd --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/metrics/chemistry.py b/chembl3d/explicit_h/runtime/src/morph/utils/metrics/chemistry.py new file mode 100644 index 0000000000000000000000000000000000000000..403741f444b3bb0f0923dbcade2c1b0f00c91361 --- /dev/null +++ b/chembl3d/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.GetValence(Chem.ValenceType.EXPLICIT), + 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/chembl3d/explicit_h/runtime/src/morph/utils/metrics/distribution.py b/chembl3d/explicit_h/runtime/src/morph/utils/metrics/distribution.py new file mode 100644 index 0000000000000000000000000000000000000000..e7928186f4eac061c0d58f59a7da4db6102af192 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/metrics/plotting.py b/chembl3d/explicit_h/runtime/src/morph/utils/metrics/plotting.py new file mode 100644 index 0000000000000000000000000000000000000000..42f09ef1f616be57c9267af13e0a0b28cb607fb2 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/metrics/posecheck_strain.py b/chembl3d/explicit_h/runtime/src/morph/utils/metrics/posecheck_strain.py new file mode 100644 index 0000000000000000000000000000000000000000..984523e52d9cb7f69a7755144ea24abcae115666 --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/metrics/tensor.py b/chembl3d/explicit_h/runtime/src/morph/utils/metrics/tensor.py new file mode 100644 index 0000000000000000000000000000000000000000..72a99071dc8d06d18ac464017ff6c827bc972dde --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/src/morph/utils/rdkit_utils.py b/chembl3d/explicit_h/runtime/src/morph/utils/rdkit_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..1f89a39d200a093f9619e73ed2b1965590070036 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/utils/rdkit_utils.py @@ -0,0 +1,289 @@ +"""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, +): + 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) + 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: + err = Chem.SanitizeMol(mol, catchErrors=True) + if err: + return None + + try: + for atom in mol.GetAtoms(): + atom.SetNoImplicit(True) # Ensure no "ghost" Hs are expected + atom.UpdatePropertyCache() + except Exception: + return None + + return mol + + +def sanitize_mol_correctly(mol: Chem.Mol): + for a in mol.GetAtoms(): + a.SetNoImplicit(True) + if a.HasProp("_MolFileHCount"): + a.ClearProp("_MolFileHCount") + + flags = Chem.SanitizeFlags.SANITIZE_ALL & ~Chem.SanitizeFlags.SANITIZE_ADJUSTHS + + # Full sanitization minus 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. + sanitized_mol = sanitize_mol_correctly(Chem.Mol(mol)) + 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/chembl3d/explicit_h/runtime/src/morph/utils/run_config.py b/chembl3d/explicit_h/runtime/src/morph/utils/run_config.py new file mode 100644 index 0000000000000000000000000000000000000000..0c9abd5228f7c1c4d9b096ba4cbe7afc4b0a1273 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/utils/run_config.py @@ -0,0 +1,191 @@ +"""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, + 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/chembl3d/explicit_h/runtime/src/morph/utils/training_monitor.py b/chembl3d/explicit_h/runtime/src/morph/utils/training_monitor.py new file mode 100644 index 0000000000000000000000000000000000000000..3077c372bd94863cf8e2c40eb191cc14bc3a4cb7 --- /dev/null +++ b/chembl3d/explicit_h/runtime/src/morph/utils/training_monitor.py @@ -0,0 +1,54 @@ +"""Small per-rank throughput and memory records for long training runs.""" + +import json +import time +from pathlib import Path + +import torch +from pytorch_lightning import Callback + + +class TrainingMonitor(Callback): + """Report each rank without introducing distributed collectives.""" + + def __init__(self, every_n_batches: int = 100): + self.every_n_batches = every_n_batches + + def on_train_epoch_start(self, trainer, pl_module): + """Exclude validation and checkpoint time from training windows.""" + self.started = time.perf_counter() + self.molecules = 0 + self.batches = 0 + if pl_module.device.type == "cuda": + torch.cuda.reset_peak_memory_stats(pl_module.device) + + def on_train_batch_end(self, trainer, pl_module, outputs, batch, batch_idx): + """Append a throughput/memory sample at a bounded logging interval.""" + self.molecules += int(batch[0].num_graphs) + self.batches += 1 + if self.batches != 1 and self.batches % self.every_n_batches: + return + if pl_module.device.type == "cuda": + torch.cuda.synchronize(pl_module.device) + elapsed = time.perf_counter() - self.started + metrics = { + "epoch": trainer.current_epoch, + "step": trainer.global_step, + "batch": batch_idx, + "rank": trainer.global_rank, + "molecules_per_second": self.molecules / elapsed, + } + if pl_module.device.type == "cuda": + metrics.update( + allocated_GiB=torch.cuda.memory_allocated(pl_module.device) / 2**30, + peak_allocated_GiB=torch.cuda.max_memory_allocated(pl_module.device) + / 2**30, + reserved_GiB=torch.cuda.memory_reserved(pl_module.device) / 2**30, + ) + path = Path(f"runtime-rank{trainer.global_rank}.jsonl") + with path.open("a") as stream: + stream.write(json.dumps(metrics) + "\n") + if trainer.is_global_zero: + print("TRAIN_RUNTIME", json.dumps(metrics), flush=True) + self.started = time.perf_counter() + self.molecules = 0 diff --git a/chembl3d/explicit_h/runtime/src/morph/utils/vocab_utils.py b/chembl3d/explicit_h/runtime/src/morph/utils/vocab_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..c696dfa14301ebd68f955d86366dc43ac4e0ea6e --- /dev/null +++ b/chembl3d/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/chembl3d/explicit_h/runtime/uv.lock b/chembl3d/explicit_h/runtime/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..dad933ac1f47b45588957e3488dfadcb1ea9f1f7 --- /dev/null +++ b/chembl3d/explicit_h/runtime/uv.lock @@ -0,0 +1,2976 @@ +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 = "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.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[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 = "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 = "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 = "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/chembl3d/explicit_h/training_config.yaml b/chembl3d/explicit_h/training_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..9eb47520124e7f7cb317e9b76fc7683ea305dff2 --- /dev/null +++ b/chembl3d/explicit_h/training_config.yaml @@ -0,0 +1,387 @@ +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: gpu + 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: 5 + num_val_samples: 4096 + limit_val_batches: null + limit_predict_batches: 4 + max_time: + seconds: 261035 + do_train: true + do_validate: false + do_predict: false + checkpoint_path: /cluster/scratch/frankem/morph/runs/chembl3d/max100/2026-09-26/chembl3d-max100-2x4090-15251667/every_n_epochs/epoch=4-step=11515.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_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link + verbose: false + save_on_train_epoch_end: true + runtime_monitor: + _target_: morph.utils.training_monitor.TrainingMonitor + every_n_batches: 100 +logging: + enabled: true + project: morph-${data.dataset_name} + name: ${oc.env:MORPH_RUN_ID,base} + 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: + dataset_name: chembl3d + root_path: /cluster/project/krause/frankem/morph-chem/data + preprocess_workers: 10 + 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: ${data.root_path}/chembl3d_max100 + 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 + num_workers: ${data.preprocess_workers} + vocab: null + 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} + 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_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: null + source_dir: ${data.source_dir} + val: + - _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + source_dir: ${data.source_dir} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: null + test: + - _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + source_dir: ${data.source_dir} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: null + interpolator: ${data.interpolator} + rotate: ${model.rotate} + n_augmentations: ${model.n_augmentations} + num_workers: + train: 20 + val: 2 + test: 2 + 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 + source_dir: ${oc.env:CHEMBL3D_SOURCE_DIR,null} + max_total_atoms: 100 +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/chembl3d/explicit_h/validation.json b/chembl3d/explicit_h/validation.json new file mode 100644 index 0000000000000000000000000000000000000000..2c5f5a08618c479b07e6138d8818d2f433fd71b1 --- /dev/null +++ b/chembl3d/explicit_h/validation.json @@ -0,0 +1,45 @@ +{ + "variant": "explicit_h", + "dataset_access": false, + "checkpoints": [ + { + "filename": "epoch_200.ckpt", + "completed_epochs": 200, + "global_step": 460600, + "strict_load": true, + "finite_current_and_ema_forward": true + }, + { + "filename": "epoch_300.ckpt", + "completed_epochs": 300, + "global_step": 690900, + "strict_load": true, + "finite_current_and_ema_forward": true + }, + { + "filename": "epoch_400.ckpt", + "completed_epochs": 400, + "global_step": 921200, + "strict_load": true, + "finite_current_and_ema_forward": true + } + ], + "sampling_smoke_test": { + "variant": "explicit_h", + "completed_epochs": 400, + "seed": 42, + "integration_steps": 100, + "weights": "EMA", + "coordinate_units": "angstrom", + "num_attempted": 1, + "num_valid": 1, + "samples": [ + { + "index": 0, + "valid": true, + "smiles": "[H]c1nc2c([H])c([H])c(C([H])([H])N([H])c3c([H])c([H])c(OC([H])([H])[H])c([H])c3[H])c([H])c2o1", + "represented_atoms": 33 + } + ] + } +} diff --git a/chembl3d/implicit_h/atom_tokens.txt b/chembl3d/implicit_h/atom_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..b3adfa0d277d978820f1e33e52dc432c4aaa25b3 --- /dev/null +++ b/chembl3d/implicit_h/atom_tokens.txt @@ -0,0 +1,12 @@ +B +Br +C +Cl +F +I +N +O +P +S +Se +Si diff --git a/chembl3d/implicit_h/charge_tokens.txt b/chembl3d/implicit_h/charge_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..6f858bd7282478c53d831363fa8d1815d57ce7dc --- /dev/null +++ b/chembl3d/implicit_h/charge_tokens.txt @@ -0,0 +1,3 @@ +-1 +0 +1 diff --git a/chembl3d/implicit_h/config.yaml b/chembl3d/implicit_h/config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fb87d543c6716d21518be4fd3aa6564f9705d0e7 --- /dev/null +++ b/chembl3d/implicit_h/config.yaml @@ -0,0 +1,330 @@ +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: final.ckpt +callbacks: {} +logging: + enabled: false + project: morph-chembl3d-implicit-h + name: chembl3d-implicit_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: + dataset_name: chembl3d + root_path: . + preprocess_workers: 0 + 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: . + 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 + num_workers: 0 + vocab: null + 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} + 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 + source_dir: null + max_total_atoms: 100 + hydrogen_mode: implicit +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/chembl3d/implicit_h/distributions.json b/chembl3d/implicit_h/distributions.json new file mode 100644 index 0000000000000000000000000000000000000000..0bf788d2fe0e6eddf7ce614057fc421115efbcc9 --- /dev/null +++ b/chembl3d/implicit_h/distributions.json @@ -0,0 +1,22089 @@ +{ + "atom_type_distribution": [ + 5.1333699957467616e-05, + 0.0017540467670187354, + 0.7372210621833801, + 0.008083973079919815, + 0.015582806430757046, + 0.0001475937315262854, + 0.12120023369789124, + 0.10084698349237442, + 0.000293741439236328, + 0.014774532057344913, + 1.3238559404271655e-05, + 3.0516001061187126e-05 + ], + "edge_type_distribution": [ + 0.9220269918441772, + 0.05606779828667641, + 0.02172653004527092, + 0.0001786188076948747 + ], + "charge_type_distribution": [ + 0.0044396789744496346, + 0.9856172800064087, + 0.009943029843270779 + ], + "n_atoms_distribution": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0352670162537834e-06, + 2.2387937860912643e-05, + 0.0001173670680145733, + 0.0004660761624109, + 0.0009240112849511206, + 0.0014077264349907637, + 0.002300530206412077, + 0.003422640962526202, + 0.004583421628922224, + 0.006801862735301256, + 0.010425316169857979, + 0.014072515070438385, + 0.018984971567988396, + 0.025456443428993225, + 0.03210091218352318, + 0.039765726774930954, + 0.0434943363070488, + 0.05037828907370567, + 0.055727649480104446, + 0.06074932962656021, + 0.06191486120223999, + 0.05978800728917122, + 0.05487080290913582, + 0.05375818908214569, + 0.05154788866639137, + 0.04883284121751785, + 0.04496515542268753, + 0.040930576622486115, + 0.036598172038793564, + 0.03202492743730545, + 0.02716674469411373, + 0.022557543590664864, + 0.018422558903694153, + 0.01501484401524067, + 0.012251629494130611, + 0.010043364949524403, + 0.00799317192286253, + 0.00653592124581337, + 0.005187896080315113, + 0.004267276730388403, + 0.0032510000746697187, + 0.0026431335136294365, + 0.0019721738062798977, + 0.001559014548547566, + 0.00111668324097991, + 0.0010020298650488257, + 0.0006791008054278791, + 0.0006126153748482466, + 0.00038466547266580164, + 0.0003446385671850294, + 0.0002164167381124571, + 0.00014857450150884688, + 9.023017628351226e-05, + 5.5630633141845465e-05, + 2.645847234816756e-05, + 1.424686979589751e-05, + 3.392111921129981e-06, + 4.070534032507567e-06 + ], + "coordinate_std": 2.5063276290893555, + "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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 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, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 2.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 5.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 1.0, + 5.0, + 1.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, + 20.0, + 1.0, + 3.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.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, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 1.0, + 0.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.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, + 2053.0, + 20.0, + 0.0, + 0.0, + 0.0, + 0.0, + 7.0, + 549.0, + 1564.0, + 229.0, + 1355.0, + 674.0, + 837.0, + 188.0, + 81.0, + 354.0, + 348.0, + 287.0, + 334.0, + 368.0, + 454.0, + 1124.0, + 164.0, + 1438.0, + 1103.0, + 306.0, + 274.0, + 1018.0, + 962.0, + 404.0, + 505.0, + 307.0, + 336.0, + 308.0, + 233.0, + 534.0, + 525.0, + 364.0, + 285.0, + 342.0, + 325.0, + 399.0, + 299.0, + 555.0, + 338.0, + 292.0, + 272.0, + 404.0, + 396.0, + 421.0, + 362.0, + 417.0, + 394.0, + 358.0, + 341.0, + 323.0, + 296.0, + 307.0, + 340.0, + 324.0, + 301.0, + 368.0, + 332.0, + 282.0, + 286.0, + 257.0, + 234.0, + 245.0, + 236.0, + 231.0, + 272.0, + 287.0, + 245.0, + 226.0, + 280.0, + 232.0, + 278.0, + 203.0, + 134.0, + 153.0, + 148.0, + 154.0, + 207.0, + 194.0, + 188.0, + 180.0, + 179.0, + 138.0, + 145.0, + 162.0, + 161.0, + 127.0, + 102.0, + 97.0, + 97.0, + 101.0, + 88.0, + 88.0, + 95.0, + 94.0, + 114.0, + 70.0, + 65.0, + 62.0, + 71.0, + 61.0, + 65.0, + 58.0, + 80.0, + 60.0, + 51.0, + 31.0, + 55.0, + 62.0, + 62.0, + 47.0, + 54.0, + 38.0, + 42.0, + 57.0, + 35.0, + 37.0, + 20.0, + 40.0, + 29.0, + 19.0, + 8.0, + 22.0, + 26.0, + 23.0, + 26.0, + 24.0, + 22.0, + 17.0, + 420.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 10.0, + 2.0, + 0.0, + 4.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 2.0, + 2.0, + 1.0, + 1.0, + 2.0, + 1.0, + 2.0, + 2.0, + 1.0, + 3.0, + 18.0, + 2.0, + 22.0, + 10.0, + 5.0, + 1.0, + 0.0, + 1.0, + 3.0, + 3.0, + 9.0, + 3.0, + 2.0, + 1.0, + 1.0, + 2.0, + 5.0, + 4.0, + 2.0, + 1.0, + 2.0, + 0.0, + 2.0, + 0.0, + 1.0, + 1.0, + 3.0, + 13.0, + 10.0, + 3.0, + 9.0, + 3.0, + 2.0, + 6.0, + 5.0, + 2.0, + 0.0, + 0.0, + 3.0, + 2.0, + 3.0, + 0.0, + 1.0, + 4.0, + 1.0, + 1.0, + 2.0, + 0.0, + 2.0, + 1.0, + 3.0, + 2.0, + 2.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 3.0, + 5.0, + 1.0, + 2.0, + 2.0, + 2.0, + 2.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 1.0, + 0.0, + 2.0, + 0.0, + 2.0, + 1.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 2.0, + 3.0, + 1.0, + 3.0, + 1.0, + 2.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 23.0 + ], + [ + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 4.0, + 35.0, + 0.0, + 3.0, + 29.0, + 2.0, + 1.0, + 0.0, + 0.0, + 2.0, + 2.0, + 1.0, + 3.0, + 1.0, + 0.0, + 3.0, + 1.0, + 2.0, + 7.0, + 1.0, + 0.0, + 11.0, + 17.0, + 5.0, + 3.0, + 2.0, + 5.0, + 9.0, + 18.0, + 4.0, + 4.0, + 23.0, + 6.0, + 4.0, + 1.0, + 0.0, + 3.0, + 6.0, + 4.0, + 2.0, + 8.0, + 2.0, + 5.0, + 1.0, + 7.0, + 7.0, + 7.0, + 3.0, + 9.0, + 6.0, + 6.0, + 7.0, + 9.0, + 5.0, + 5.0, + 6.0, + 3.0, + 5.0, + 5.0, + 11.0, + 18.0, + 4.0, + 7.0, + 9.0, + 7.0, + 7.0, + 7.0, + 11.0, + 11.0, + 10.0, + 3.0, + 4.0, + 9.0, + 4.0, + 7.0, + 8.0, + 6.0, + 7.0, + 12.0, + 4.0, + 5.0, + 13.0, + 11.0, + 3.0, + 3.0, + 4.0, + 8.0, + 6.0, + 7.0, + 5.0, + 17.0, + 6.0, + 8.0, + 12.0, + 5.0, + 3.0, + 4.0, + 5.0, + 3.0, + 6.0, + 2.0, + 5.0, + 5.0, + 4.0, + 4.0, + 4.0, + 6.0, + 8.0, + 6.0, + 4.0, + 5.0, + 1.0, + 4.0, + 5.0, + 4.0, + 4.0, + 1.0, + 1.0, + 2.0, + 4.0, + 2.0, + 32.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 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, + 15.0, + 0.0, + 6.0, + 6.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 174.0, + 160.0, + 7.0, + 6.0, + 2.0, + 21.0, + 5.0, + 4.0, + 11.0, + 5.0, + 4.0, + 39.0, + 30.0, + 20.0, + 86.0, + 31.0, + 39.0, + 24.0, + 20.0, + 42.0, + 32.0, + 30.0, + 23.0, + 51.0, + 51.0, + 96.0, + 98.0, + 285.0, + 79.0, + 35.0, + 62.0, + 46.0, + 81.0, + 91.0, + 46.0, + 57.0, + 47.0, + 88.0, + 69.0, + 51.0, + 51.0, + 84.0, + 75.0, + 60.0, + 92.0, + 57.0, + 46.0, + 37.0, + 52.0, + 50.0, + 36.0, + 53.0, + 46.0, + 36.0, + 44.0, + 44.0, + 43.0, + 28.0, + 22.0, + 32.0, + 30.0, + 27.0, + 27.0, + 39.0, + 21.0, + 23.0, + 34.0, + 20.0, + 26.0, + 20.0, + 24.0, + 16.0, + 28.0, + 21.0, + 10.0, + 12.0, + 14.0, + 19.0, + 26.0, + 21.0, + 10.0, + 13.0, + 13.0, + 6.0, + 16.0, + 20.0, + 19.0, + 12.0, + 15.0, + 7.0, + 12.0, + 10.0, + 8.0, + 3.0, + 4.0, + 10.0, + 10.0, + 4.0, + 5.0, + 3.0, + 4.0, + 9.0, + 5.0, + 6.0, + 7.0, + 4.0, + 4.0, + 3.0, + 2.0, + 2.0, + 7.0, + 1.0, + 6.0, + 0.0, + 1.0, + 4.0, + 2.0, + 2.0, + 1.0, + 5.0, + 2.0, + 0.0, + 2.0, + 0.0, + 2.0, + 42.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4041.0, + 29.0, + 2.0, + 0.0, + 3.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 19.0, + 37.0, + 32.0, + 25.0, + 41.0, + 18.0, + 23.0, + 86.0, + 36.0, + 36.0, + 34.0, + 65.0, + 55.0, + 44.0, + 52.0, + 56.0, + 42.0, + 62.0, + 71.0, + 45.0, + 26.0, + 36.0, + 16.0, + 64.0, + 35.0, + 49.0, + 111.0, + 197.0, + 54.0, + 91.0, + 63.0, + 52.0, + 135.0, + 60.0, + 46.0, + 61.0, + 45.0, + 69.0, + 53.0, + 39.0, + 65.0, + 100.0, + 102.0, + 97.0, + 75.0, + 52.0, + 46.0, + 40.0, + 53.0, + 49.0, + 48.0, + 42.0, + 30.0, + 68.0, + 43.0, + 42.0, + 32.0, + 28.0, + 25.0, + 28.0, + 41.0, + 37.0, + 32.0, + 23.0, + 29.0, + 21.0, + 11.0, + 42.0, + 29.0, + 49.0, + 35.0, + 34.0, + 21.0, + 16.0, + 24.0, + 17.0, + 14.0, + 17.0, + 16.0, + 12.0, + 14.0, + 15.0, + 14.0, + 15.0, + 14.0, + 22.0, + 18.0, + 20.0, + 13.0, + 9.0, + 11.0, + 9.0, + 11.0, + 15.0, + 10.0, + 12.0, + 10.0, + 13.0, + 9.0, + 8.0, + 3.0, + 11.0, + 14.0, + 16.0, + 8.0, + 13.0, + 3.0, + 10.0, + 10.0, + 5.0, + 5.0, + 2.0, + 8.0, + 13.0, + 3.0, + 3.0, + 6.0, + 3.0, + 5.0, + 4.0, + 3.0, + 4.0, + 6.0, + 5.0, + 3.0, + 92.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 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, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 1.0, + 15.0, + 7.0, + 3.0, + 14.0, + 6.0, + 2.0, + 1.0, + 5.0, + 16.0, + 0.0, + 0.0, + 0.0, + 13.0, + 1.0, + 0.0, + 1.0, + 1.0, + 7.0, + 8.0, + 4.0, + 0.0, + 4.0, + 3.0, + 0.0, + 3.0, + 9.0, + 11.0, + 8.0, + 6.0, + 4.0, + 6.0, + 3.0, + 11.0, + 8.0, + 4.0, + 7.0, + 3.0, + 2.0, + 10.0, + 3.0, + 9.0, + 7.0, + 2.0, + 9.0, + 3.0, + 0.0, + 4.0, + 3.0, + 2.0, + 4.0, + 6.0, + 5.0, + 3.0, + 5.0, + 7.0, + 1.0, + 6.0, + 4.0, + 1.0, + 5.0, + 2.0, + 8.0, + 3.0, + 2.0, + 1.0, + 4.0, + 3.0, + 5.0, + 0.0, + 3.0, + 2.0, + 2.0, + 4.0, + 0.0, + 4.0, + 1.0, + 1.0, + 2.0, + 0.0, + 4.0, + 1.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 5.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 2.0, + 0.0, + 4.0, + 0.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 233.0, + 232.0, + 635.0, + 134.0, + 98.0, + 362.0, + 63.0, + 7.0, + 8.0, + 25.0, + 45.0, + 41.0, + 50.0, + 42.0, + 46.0, + 93.0, + 36.0, + 32.0, + 19.0, + 37.0, + 60.0, + 41.0, + 30.0, + 40.0, + 106.0, + 2184.0, + 942.0, + 91.0, + 53.0, + 74.0, + 45.0, + 31.0, + 34.0, + 37.0, + 355.0, + 68.0, + 28.0, + 45.0, + 32.0, + 36.0, + 41.0, + 33.0, + 34.0, + 39.0, + 30.0, + 41.0, + 39.0, + 30.0, + 40.0, + 45.0, + 68.0, + 28.0, + 31.0, + 29.0, + 33.0, + 84.0, + 52.0, + 31.0, + 45.0, + 33.0, + 43.0, + 39.0, + 37.0, + 26.0, + 46.0, + 46.0, + 25.0, + 50.0, + 33.0, + 37.0, + 38.0, + 40.0, + 47.0, + 42.0, + 47.0, + 38.0, + 18.0, + 31.0, + 31.0, + 29.0, + 18.0, + 26.0, + 24.0, + 20.0, + 22.0, + 34.0, + 26.0, + 35.0, + 33.0, + 26.0, + 32.0, + 31.0, + 19.0, + 12.0, + 17.0, + 21.0, + 29.0, + 28.0, + 53.0, + 12.0, + 19.0, + 27.0, + 12.0, + 14.0, + 18.0, + 12.0, + 19.0, + 20.0, + 13.0, + 15.0, + 10.0, + 7.0, + 14.0, + 15.0, + 8.0, + 8.0, + 14.0, + 9.0, + 213.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 30473.0, + 39773.0, + 80.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 14.0, + 1101.0, + 123978.0, + 8996.0, + 200.0, + 4376.0, + 4533.0, + 2363.0, + 2819.0, + 3397.0, + 2795.0, + 2360.0, + 2341.0, + 4405.0, + 6838.0, + 117565.0, + 8649.0, + 7963.0, + 8292.0, + 10549.0, + 61314.0, + 8673.0, + 6183.0, + 6943.0, + 7130.0, + 9234.0, + 13293.0, + 26852.0, + 14948.0, + 9471.0, + 11384.0, + 9701.0, + 8936.0, + 9815.0, + 11337.0, + 23018.0, + 11639.0, + 12727.0, + 13266.0, + 13554.0, + 11316.0, + 14059.0, + 19467.0, + 18262.0, + 19078.0, + 12746.0, + 11129.0, + 10554.0, + 11476.0, + 11092.0, + 11734.0, + 13494.0, + 11341.0, + 9854.0, + 10246.0, + 12490.0, + 14970.0, + 14215.0, + 12311.0, + 9308.0, + 9870.0, + 10499.0, + 10979.0, + 11300.0, + 10545.0, + 10207.0, + 9982.0, + 9398.0, + 9851.0, + 8569.0, + 7822.0, + 7530.0, + 7184.0, + 7418.0, + 7514.0, + 7263.0, + 7395.0, + 7517.0, + 8189.0, + 7503.0, + 6477.0, + 6526.0, + 5699.0, + 5744.0, + 6005.0, + 5636.0, + 5133.0, + 4726.0, + 4699.0, + 4546.0, + 4606.0, + 4522.0, + 4124.0, + 3972.0, + 3644.0, + 3303.0, + 3424.0, + 3307.0, + 3289.0, + 3172.0, + 2976.0, + 2898.0, + 2908.0, + 2687.0, + 2441.0, + 2450.0, + 2412.0, + 2244.0, + 2261.0, + 2001.0, + 1994.0, + 1797.0, + 1740.0, + 1682.0, + 1639.0, + 1650.0, + 1430.0, + 1417.0, + 1366.0, + 1400.0, + 1381.0, + 1379.0, + 1184.0, + 21874.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 38.0, + 0.0, + 229.0, + 163.0, + 23.0, + 14.0, + 7.0, + 4.0, + 12.0, + 52.0, + 57.0, + 45.0, + 52.0, + 61.0, + 47.0, + 53.0, + 51.0, + 81.0, + 65.0, + 74.0, + 64.0, + 65.0, + 61.0, + 76.0, + 239.0, + 774.0, + 96.0, + 68.0, + 102.0, + 63.0, + 73.0, + 59.0, + 77.0, + 268.0, + 63.0, + 66.0, + 78.0, + 62.0, + 61.0, + 79.0, + 89.0, + 62.0, + 57.0, + 73.0, + 61.0, + 67.0, + 60.0, + 69.0, + 86.0, + 92.0, + 88.0, + 76.0, + 58.0, + 86.0, + 91.0, + 90.0, + 78.0, + 64.0, + 71.0, + 90.0, + 74.0, + 70.0, + 92.0, + 106.0, + 86.0, + 87.0, + 74.0, + 69.0, + 86.0, + 66.0, + 65.0, + 78.0, + 71.0, + 78.0, + 65.0, + 68.0, + 80.0, + 85.0, + 68.0, + 60.0, + 61.0, + 81.0, + 55.0, + 53.0, + 49.0, + 55.0, + 67.0, + 48.0, + 55.0, + 56.0, + 61.0, + 75.0, + 71.0, + 54.0, + 59.0, + 49.0, + 52.0, + 62.0, + 49.0, + 48.0, + 49.0, + 54.0, + 45.0, + 24.0, + 24.0, + 27.0, + 30.0, + 35.0, + 31.0, + 23.0, + 25.0, + 14.0, + 23.0, + 13.0, + 20.0, + 17.0, + 16.0, + 23.0, + 18.0, + 396.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 21.0, + 6.0, + 3.0, + 81.0, + 867.0, + 153.0, + 46.0, + 17.0, + 34.0, + 63.0, + 33.0, + 23.0, + 58.0, + 59.0, + 65.0, + 40.0, + 37.0, + 71.0, + 108.0, + 211.0, + 110.0, + 84.0, + 100.0, + 98.0, + 104.0, + 1535.0, + 298.0, + 132.0, + 163.0, + 137.0, + 131.0, + 159.0, + 130.0, + 480.0, + 127.0, + 131.0, + 135.0, + 150.0, + 155.0, + 123.0, + 137.0, + 179.0, + 174.0, + 139.0, + 169.0, + 118.0, + 151.0, + 125.0, + 120.0, + 143.0, + 124.0, + 134.0, + 95.0, + 106.0, + 167.0, + 140.0, + 119.0, + 127.0, + 166.0, + 135.0, + 141.0, + 140.0, + 120.0, + 125.0, + 167.0, + 117.0, + 143.0, + 132.0, + 109.0, + 104.0, + 111.0, + 121.0, + 88.0, + 96.0, + 106.0, + 125.0, + 141.0, + 113.0, + 112.0, + 88.0, + 120.0, + 92.0, + 83.0, + 101.0, + 114.0, + 114.0, + 122.0, + 123.0, + 99.0, + 101.0, + 92.0, + 105.0, + 92.0, + 87.0, + 129.0, + 134.0, + 68.0, + 73.0, + 77.0, + 85.0, + 83.0, + 62.0, + 91.0, + 63.0, + 42.0, + 69.0, + 57.0, + 64.0, + 37.0, + 59.0, + 65.0, + 52.0, + 39.0, + 28.0, + 41.0, + 32.0, + 40.0, + 39.0, + 44.0, + 49.0, + 68.0, + 39.0, + 33.0, + 664.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 7.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 3.0, + 2.0, + 1.0, + 0.0, + 1.0, + 1.0, + 1.0, + 2.0, + 0.0, + 1.0, + 2.0, + 3.0, + 0.0, + 2.0, + 3.0, + 1.0, + 2.0, + 19.0, + 4.0, + 2.0, + 4.0, + 6.0, + 1.0, + 1.0, + 2.0, + 1.0, + 5.0, + 6.0, + 3.0, + 5.0, + 1.0, + 2.0, + 1.0, + 1.0, + 3.0, + 7.0, + 2.0, + 3.0, + 4.0, + 4.0, + 1.0, + 3.0, + 2.0, + 1.0, + 1.0, + 7.0, + 1.0, + 1.0, + 2.0, + 4.0, + 2.0, + 3.0, + 0.0, + 3.0, + 4.0, + 0.0, + 1.0, + 1.0, + 4.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 2.0, + 2.0, + 1.0, + 2.0, + 2.0, + 1.0, + 1.0, + 1.0, + 3.0, + 2.0, + 4.0, + 4.0, + 3.0, + 2.0, + 2.0, + 2.0, + 3.0, + 0.0, + 1.0, + 0.0, + 2.0, + 2.0, + 4.0, + 2.0, + 1.0, + 1.0, + 2.0, + 1.0, + 3.0, + 1.0, + 1.0, + 3.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 11.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 5.0, + 1115.0, + 1064.0, + 36.0, + 1643.0, + 2225.0, + 1500.0, + 1202.0, + 1066.0, + 716.0, + 610.0, + 573.0, + 435.0, + 1122.0, + 5269.0, + 5675.0, + 975.0, + 1031.0, + 1157.0, + 1185.0, + 1014.0, + 882.0, + 1070.0, + 1266.0, + 1554.0, + 2180.0, + 4931.0, + 3976.0, + 3533.0, + 2519.0, + 1946.0, + 1810.0, + 2550.0, + 5355.0, + 11569.0, + 2434.0, + 2833.0, + 2958.0, + 4293.0, + 3184.0, + 2220.0, + 2574.0, + 6914.0, + 6387.0, + 3053.0, + 1856.0, + 1916.0, + 2030.0, + 2028.0, + 2271.0, + 2078.0, + 1887.0, + 1998.0, + 1711.0, + 1985.0, + 3087.0, + 3582.0, + 2277.0, + 1702.0, + 1733.0, + 1770.0, + 1837.0, + 1838.0, + 1903.0, + 1774.0, + 1713.0, + 1795.0, + 1950.0, + 1533.0, + 1317.0, + 1150.0, + 1135.0, + 1095.0, + 1067.0, + 983.0, + 1217.0, + 1221.0, + 1242.0, + 1208.0, + 1050.0, + 882.0, + 864.0, + 752.0, + 764.0, + 894.0, + 732.0, + 730.0, + 653.0, + 615.0, + 580.0, + 638.0, + 564.0, + 528.0, + 453.0, + 418.0, + 440.0, + 440.0, + 437.0, + 461.0, + 404.0, + 386.0, + 387.0, + 377.0, + 341.0, + 404.0, + 311.0, + 318.0, + 279.0, + 270.0, + 298.0, + 252.0, + 257.0, + 239.0, + 214.0, + 207.0, + 185.0, + 190.0, + 230.0, + 197.0, + 193.0, + 179.0, + 163.0, + 150.0, + 2893.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4.0, + 1281.0, + 19.0, + 2510.0, + 5107.0, + 1845.0, + 1384.0, + 703.0, + 498.0, + 567.0, + 592.0, + 610.0, + 699.0, + 668.0, + 929.0, + 818.0, + 616.0, + 733.0, + 715.0, + 827.0, + 973.0, + 1217.0, + 1437.0, + 988.0, + 1751.0, + 4076.0, + 3632.0, + 2728.0, + 3805.0, + 2192.0, + 1754.0, + 1672.0, + 1768.0, + 2700.0, + 7880.0, + 1916.0, + 2503.0, + 2561.0, + 3332.0, + 2060.0, + 2590.0, + 2874.0, + 6111.0, + 5662.0, + 4334.0, + 1883.0, + 1683.0, + 1754.0, + 1827.0, + 1808.0, + 2402.0, + 1817.0, + 1849.0, + 1983.0, + 2583.0, + 2335.0, + 2226.0, + 2119.0, + 1805.0, + 1819.0, + 1865.0, + 1610.0, + 1523.0, + 1711.0, + 1509.0, + 1668.0, + 1409.0, + 1293.0, + 1260.0, + 1122.0, + 1133.0, + 1027.0, + 1116.0, + 1068.0, + 1001.0, + 1063.0, + 1041.0, + 1129.0, + 1025.0, + 1048.0, + 880.0, + 803.0, + 844.0, + 803.0, + 951.0, + 874.0, + 833.0, + 722.0, + 769.0, + 678.0, + 707.0, + 690.0, + 701.0, + 567.0, + 575.0, + 529.0, + 617.0, + 647.0, + 756.0, + 481.0, + 480.0, + 468.0, + 495.0, + 471.0, + 439.0, + 403.0, + 443.0, + 429.0, + 361.0, + 345.0, + 331.0, + 277.0, + 308.0, + 304.0, + 275.0, + 291.0, + 270.0, + 235.0, + 228.0, + 226.0, + 247.0, + 223.0, + 217.0, + 4307.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 4.0, + 6.0, + 1.0, + 1.0, + 1.0, + 0.0, + 3.0, + 3.0, + 5.0, + 8.0, + 10.0, + 1.0, + 7.0, + 7.0, + 9.0, + 4.0, + 9.0, + 7.0, + 29.0, + 38.0, + 27.0, + 24.0, + 25.0, + 11.0, + 6.0, + 9.0, + 11.0, + 4.0, + 7.0, + 6.0, + 10.0, + 7.0, + 5.0, + 10.0, + 4.0, + 7.0, + 3.0, + 13.0, + 31.0, + 45.0, + 16.0, + 16.0, + 4.0, + 9.0, + 5.0, + 7.0, + 5.0, + 7.0, + 2.0, + 6.0, + 4.0, + 7.0, + 5.0, + 5.0, + 4.0, + 7.0, + 4.0, + 2.0, + 4.0, + 1.0, + 2.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 3.0, + 5.0, + 0.0, + 1.0, + 0.0, + 3.0, + 2.0, + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 4.0, + 2.0, + 0.0, + 0.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, + 1.0, + 15.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 1382.0, + 37.0, + 296.0, + 337.0, + 124.0, + 108.0, + 152.0, + 90.0, + 134.0, + 86.0, + 70.0, + 125.0, + 579.0, + 244.0, + 169.0, + 105.0, + 101.0, + 91.0, + 63.0, + 101.0, + 132.0, + 198.0, + 224.0, + 301.0, + 1233.0, + 510.0, + 158.0, + 212.0, + 588.0, + 184.0, + 226.0, + 265.0, + 1257.0, + 1550.0, + 226.0, + 239.0, + 208.0, + 298.0, + 568.0, + 572.0, + 375.0, + 348.0, + 318.0, + 253.0, + 216.0, + 200.0, + 330.0, + 300.0, + 314.0, + 230.0, + 236.0, + 211.0, + 218.0, + 348.0, + 529.0, + 736.0, + 287.0, + 210.0, + 270.0, + 287.0, + 280.0, + 298.0, + 226.0, + 169.0, + 229.0, + 202.0, + 256.0, + 174.0, + 163.0, + 142.0, + 142.0, + 174.0, + 112.0, + 127.0, + 106.0, + 182.0, + 143.0, + 135.0, + 146.0, + 112.0, + 97.0, + 89.0, + 93.0, + 121.0, + 90.0, + 62.0, + 81.0, + 71.0, + 80.0, + 76.0, + 63.0, + 49.0, + 48.0, + 53.0, + 51.0, + 40.0, + 61.0, + 73.0, + 38.0, + 40.0, + 53.0, + 47.0, + 43.0, + 38.0, + 44.0, + 49.0, + 40.0, + 35.0, + 31.0, + 25.0, + 33.0, + 31.0, + 20.0, + 20.0, + 26.0, + 11.0, + 14.0, + 32.0, + 346.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 1.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, + 2.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 4.0, + 0.0, + 0.0, + 1.0, + 2.0, + 4.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.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, + 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, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 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, + 2.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.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, + 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, + 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, + 9505.0, + 10170.0, + 13796115.0, + 4949873.0, + 7244400.0, + 649.0, + 13.0, + 139.0, + 11.0, + 45017.0, + 510365.0, + 1584130.0, + 6984878.0, + 17126344.0, + 8154223.0, + 1125762.0, + 6790951.0, + 2640506.0, + 1723187.0, + 1968240.0, + 1751057.0, + 1529440.0, + 1423653.0, + 1619744.0, + 2824856.0, + 4692184.0, + 7782451.0, + 3991307.0, + 1649425.0, + 1773348.0, + 3110964.0, + 5336688.0, + 4875813.0, + 4399578.0, + 4075591.0, + 3899549.0, + 4897529.0, + 5335163.0, + 5069863.0, + 5102965.0, + 4225160.0, + 3342564.0, + 3198748.0, + 3213300.0, + 3248633.0, + 3620454.0, + 3633439.0, + 3825816.0, + 3972058.0, + 3962894.0, + 3498128.0, + 3295758.0, + 3708934.0, + 4374756.0, + 3854257.0, + 3320280.0, + 3079116.0, + 2994615.0, + 2948195.0, + 3086208.0, + 3061383.0, + 3035884.0, + 2794335.0, + 2522194.0, + 2406732.0, + 2358862.0, + 2303043.0, + 2357573.0, + 2243626.0, + 2120359.0, + 2052202.0, + 2033549.0, + 2095709.0, + 2133192.0, + 2106090.0, + 2082660.0, + 1961618.0, + 1820227.0, + 1676181.0, + 1594034.0, + 1554651.0, + 1514272.0, + 1409101.0, + 1366653.0, + 1333226.0, + 1281305.0, + 1224655.0, + 1207770.0, + 1192779.0, + 1158164.0, + 1086600.0, + 1021202.0, + 972251.0, + 961525.0, + 972868.0, + 970009.0, + 936961.0, + 882794.0, + 842827.0, + 797370.0, + 760389.0, + 723834.0, + 687949.0, + 650010.0, + 613216.0, + 582542.0, + 558178.0, + 543646.0, + 527282.0, + 514433.0, + 500020.0, + 484778.0, + 459002.0, + 435248.0, + 415898.0, + 406574.0, + 394852.0, + 381313.0, + 357785.0, + 339645.0, + 321276.0, + 306631.0, + 293693.0, + 283527.0, + 269645.0, + 255002.0, + 240897.0, + 228052.0, + 217268.0, + 210162.0, + 200787.0, + 192640.0, + 184991.0, + 177172.0, + 169175.0, + 162725.0, + 158280.0, + 152685.0, + 2897318.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 847.0, + 319533.0, + 3642.0, + 2.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 17.0, + 198371.0, + 415288.0, + 4765.0, + 3810.0, + 44499.0, + 26037.0, + 12583.0, + 18659.0, + 18794.0, + 16958.0, + 17805.0, + 18453.0, + 31331.0, + 454343.0, + 157943.0, + 37691.0, + 48369.0, + 53782.0, + 157536.0, + 211625.0, + 39608.0, + 38525.0, + 43955.0, + 48457.0, + 48421.0, + 95438.0, + 80022.0, + 56793.0, + 55130.0, + 60422.0, + 48444.0, + 48376.0, + 53171.0, + 89718.0, + 95139.0, + 58872.0, + 70247.0, + 74409.0, + 63441.0, + 64328.0, + 88070.0, + 106207.0, + 101828.0, + 74733.0, + 62962.0, + 59481.0, + 57606.0, + 58336.0, + 64375.0, + 65008.0, + 60644.0, + 56600.0, + 54944.0, + 58731.0, + 70930.0, + 79110.0, + 64294.0, + 50358.0, + 48200.0, + 50534.0, + 55131.0, + 56991.0, + 53565.0, + 55630.0, + 53830.0, + 49649.0, + 46569.0, + 45615.0, + 39156.0, + 37893.0, + 36167.0, + 36118.0, + 36693.0, + 35258.0, + 35102.0, + 37632.0, + 37955.0, + 36585.0, + 35410.0, + 31733.0, + 30330.0, + 29152.0, + 29634.0, + 29699.0, + 26598.0, + 25350.0, + 24272.0, + 24094.0, + 23399.0, + 23247.0, + 21654.0, + 20320.0, + 19536.0, + 17937.0, + 16962.0, + 17315.0, + 17796.0, + 17440.0, + 16396.0, + 15626.0, + 14916.0, + 14820.0, + 14751.0, + 14002.0, + 13453.0, + 12258.0, + 12043.0, + 11011.0, + 10807.0, + 10087.0, + 9754.0, + 9402.0, + 9069.0, + 8829.0, + 8213.0, + 7810.0, + 7831.0, + 7384.0, + 7422.0, + 6807.0, + 6699.0, + 6200.0, + 5951.0, + 117835.0 + ], + [ + 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, + 617726.0, + 6980.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 259.0, + 848817.0, + 15215.0, + 497.0, + 13870.0, + 94795.0, + 116953.0, + 127845.0, + 70458.0, + 44357.0, + 34755.0, + 51545.0, + 136474.0, + 181585.0, + 497978.0, + 59112.0, + 44521.0, + 58059.0, + 249095.0, + 266055.0, + 167825.0, + 136535.0, + 101983.0, + 122436.0, + 148378.0, + 153608.0, + 190960.0, + 276186.0, + 124792.0, + 106927.0, + 114664.0, + 104769.0, + 107210.0, + 131284.0, + 151547.0, + 113846.0, + 132536.0, + 123926.0, + 126176.0, + 134350.0, + 156519.0, + 199087.0, + 194391.0, + 145217.0, + 129750.0, + 129576.0, + 129078.0, + 133105.0, + 142100.0, + 141109.0, + 137904.0, + 137925.0, + 127153.0, + 123903.0, + 134014.0, + 138884.0, + 122759.0, + 108658.0, + 108197.0, + 111810.0, + 121586.0, + 129201.0, + 126670.0, + 124212.0, + 119819.0, + 112401.0, + 106762.0, + 103101.0, + 97045.0, + 97797.0, + 92779.0, + 90651.0, + 88384.0, + 88043.0, + 87500.0, + 87930.0, + 86470.0, + 83500.0, + 77964.0, + 76314.0, + 73213.0, + 73614.0, + 74672.0, + 76160.0, + 71623.0, + 68519.0, + 66036.0, + 63593.0, + 61736.0, + 59679.0, + 57658.0, + 55023.0, + 53016.0, + 50247.0, + 47929.0, + 47096.0, + 46404.0, + 46326.0, + 44750.0, + 43200.0, + 41659.0, + 40111.0, + 39392.0, + 37857.0, + 37290.0, + 35743.0, + 34233.0, + 32576.0, + 31036.0, + 29799.0, + 28883.0, + 27817.0, + 26908.0, + 25518.0, + 24161.0, + 23386.0, + 21915.0, + 21840.0, + 21716.0, + 20914.0, + 19585.0, + 19041.0, + 18488.0, + 17374.0, + 17258.0, + 16035.0, + 15315.0, + 311380.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 10.0, + 4548.0, + 1356.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 475.0, + 10098.0, + 523.0, + 218.0, + 540.0, + 319.0, + 289.0, + 511.0, + 447.0, + 317.0, + 317.0, + 317.0, + 416.0, + 580.0, + 9696.0, + 980.0, + 810.0, + 611.0, + 717.0, + 5148.0, + 922.0, + 782.0, + 666.0, + 713.0, + 876.0, + 1824.0, + 2394.0, + 1155.0, + 805.0, + 948.0, + 874.0, + 826.0, + 851.0, + 893.0, + 1543.0, + 995.0, + 1102.0, + 1223.0, + 989.0, + 935.0, + 1217.0, + 1573.0, + 1487.0, + 1566.0, + 893.0, + 826.0, + 801.0, + 841.0, + 831.0, + 945.0, + 929.0, + 725.0, + 661.0, + 726.0, + 897.0, + 1250.0, + 1238.0, + 940.0, + 760.0, + 691.0, + 807.0, + 855.0, + 791.0, + 751.0, + 738.0, + 800.0, + 821.0, + 799.0, + 631.0, + 607.0, + 518.0, + 550.0, + 541.0, + 498.0, + 504.0, + 573.0, + 602.0, + 534.0, + 536.0, + 530.0, + 468.0, + 449.0, + 435.0, + 498.0, + 467.0, + 376.0, + 326.0, + 360.0, + 346.0, + 385.0, + 288.0, + 300.0, + 275.0, + 259.0, + 275.0, + 224.0, + 250.0, + 243.0, + 238.0, + 216.0, + 181.0, + 197.0, + 202.0, + 199.0, + 175.0, + 166.0, + 170.0, + 165.0, + 137.0, + 118.0, + 135.0, + 121.0, + 141.0, + 115.0, + 105.0, + 100.0, + 101.0, + 76.0, + 94.0, + 78.0, + 1431.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 81020.0, + 375651.0, + 5173442.0, + 3759846.0, + 318826.0, + 8.0, + 1.0, + 0.0, + 6.0, + 45637.0, + 576081.0, + 1158471.0, + 2182208.0, + 6716007.0, + 990572.0, + 312048.0, + 927904.0, + 1041670.0, + 921762.0, + 728715.0, + 591313.0, + 523000.0, + 534968.0, + 985881.0, + 1964187.0, + 3533963.0, + 2242427.0, + 726510.0, + 564379.0, + 998863.0, + 1876762.0, + 2044510.0, + 1584585.0, + 1430612.0, + 1346186.0, + 1548508.0, + 1734848.0, + 1794059.0, + 1595950.0, + 1848403.0, + 1422894.0, + 1128961.0, + 977945.0, + 976627.0, + 1078220.0, + 1227680.0, + 1240059.0, + 1233611.0, + 1357154.0, + 1113784.0, + 981677.0, + 1103320.0, + 1368338.0, + 1412245.0, + 1176235.0, + 1000096.0, + 936569.0, + 893944.0, + 931569.0, + 959706.0, + 900129.0, + 889039.0, + 798115.0, + 767705.0, + 735292.0, + 705140.0, + 730926.0, + 723564.0, + 641432.0, + 591522.0, + 587611.0, + 609184.0, + 627442.0, + 643428.0, + 633769.0, + 602627.0, + 552891.0, + 502498.0, + 464773.0, + 439669.0, + 423667.0, + 407899.0, + 386733.0, + 368518.0, + 355954.0, + 342193.0, + 340780.0, + 352346.0, + 344759.0, + 320401.0, + 289957.0, + 274808.0, + 268615.0, + 269028.0, + 274248.0, + 269613.0, + 251245.0, + 234782.0, + 220804.0, + 208073.0, + 202531.0, + 191446.0, + 179141.0, + 167991.0, + 159751.0, + 153739.0, + 147456.0, + 144205.0, + 141382.0, + 138404.0, + 131238.0, + 124651.0, + 118560.0, + 113785.0, + 112167.0, + 110414.0, + 107397.0, + 100394.0, + 94531.0, + 88013.0, + 82840.0, + 79261.0, + 74472.0, + 71921.0, + 67977.0, + 64042.0, + 60917.0, + 58768.0, + 56768.0, + 55589.0, + 53059.0, + 50480.0, + 47439.0, + 45737.0, + 43770.0, + 42084.0, + 40930.0, + 39336.0, + 749687.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 17164.0, + 1733508.0, + 1529173.0, + 1411141.0, + 23.0, + 6.0, + 2.0, + 0.0, + 1.0, + 562.0, + 86592.0, + 306809.0, + 3196437.0, + 2150651.0, + 216373.0, + 567938.0, + 978039.0, + 1508568.0, + 1217871.0, + 661128.0, + 607114.0, + 569936.0, + 587700.0, + 832887.0, + 1668615.0, + 2975525.0, + 876193.0, + 878662.0, + 678559.0, + 1088748.0, + 2158616.0, + 1530616.0, + 1306081.0, + 1040965.0, + 1107340.0, + 1183005.0, + 1414442.0, + 1338619.0, + 1700959.0, + 1330381.0, + 1439208.0, + 1109035.0, + 865043.0, + 882910.0, + 924153.0, + 1043008.0, + 837368.0, + 950705.0, + 1004763.0, + 911941.0, + 894074.0, + 978561.0, + 1138533.0, + 1167460.0, + 1051856.0, + 928074.0, + 825559.0, + 804557.0, + 826596.0, + 862271.0, + 846579.0, + 829700.0, + 802490.0, + 752552.0, + 698174.0, + 697344.0, + 701286.0, + 680233.0, + 614437.0, + 572730.0, + 580124.0, + 613538.0, + 640831.0, + 642933.0, + 633745.0, + 615561.0, + 576483.0, + 533786.0, + 503401.0, + 465763.0, + 457952.0, + 443341.0, + 424666.0, + 400146.0, + 382184.0, + 378309.0, + 378957.0, + 368609.0, + 354713.0, + 336542.0, + 311438.0, + 300433.0, + 293285.0, + 296375.0, + 302859.0, + 288872.0, + 275443.0, + 259661.0, + 252482.0, + 241758.0, + 231636.0, + 222625.0, + 209934.0, + 199430.0, + 185303.0, + 173557.0, + 165937.0, + 163259.0, + 159432.0, + 154507.0, + 146484.0, + 140771.0, + 137942.0, + 134184.0, + 130808.0, + 129611.0, + 125336.0, + 118611.0, + 111475.0, + 105402.0, + 100052.0, + 96205.0, + 93133.0, + 87492.0, + 83443.0, + 79869.0, + 75124.0, + 72135.0, + 67249.0, + 65644.0, + 63868.0, + 59896.0, + 56598.0, + 54567.0, + 52742.0, + 52373.0, + 50235.0, + 48792.0, + 955550.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 3698.0, + 6783.0, + 445.0, + 3.0, + 0.0, + 0.0, + 2.0, + 29.0, + 856.0, + 11199.0, + 9533.0, + 3767.0, + 715.0, + 731.0, + 1242.0, + 1713.0, + 2399.0, + 3005.0, + 3362.0, + 2617.0, + 2652.0, + 6260.0, + 4144.0, + 5805.0, + 3455.0, + 2308.0, + 2022.0, + 2718.0, + 5510.0, + 5254.0, + 4838.0, + 3954.0, + 4499.0, + 3683.0, + 4206.0, + 5145.0, + 3538.0, + 2621.0, + 2387.0, + 2480.0, + 2386.0, + 2215.0, + 2215.0, + 2492.0, + 2541.0, + 2820.0, + 2395.0, + 2086.0, + 2078.0, + 2358.0, + 2619.0, + 2045.0, + 1861.0, + 1651.0, + 1697.0, + 1715.0, + 1663.0, + 1451.0, + 1454.0, + 1248.0, + 1143.0, + 1168.0, + 1058.0, + 1055.0, + 1072.0, + 1029.0, + 948.0, + 887.0, + 868.0, + 823.0, + 941.0, + 947.0, + 807.0, + 721.0, + 716.0, + 681.0, + 694.0, + 672.0, + 651.0, + 599.0, + 566.0, + 587.0, + 515.0, + 525.0, + 476.0, + 483.0, + 478.0, + 412.0, + 433.0, + 447.0, + 401.0, + 402.0, + 389.0, + 359.0, + 445.0, + 369.0, + 334.0, + 309.0, + 316.0, + 284.0, + 284.0, + 239.0, + 250.0, + 234.0, + 213.0, + 218.0, + 180.0, + 197.0, + 181.0, + 164.0, + 186.0, + 176.0, + 160.0, + 193.0, + 196.0, + 204.0, + 175.0, + 178.0, + 164.0, + 145.0, + 145.0, + 154.0, + 126.0, + 97.0, + 116.0, + 111.0, + 95.0, + 123.0, + 102.0, + 96.0, + 116.0, + 91.0, + 2013.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 299.0, + 40915.0, + 783811.0, + 117819.0, + 28.0, + 0.0, + 0.0, + 0.0, + 3834.0, + 7683.0, + 270185.0, + 304141.0, + 599330.0, + 156654.0, + 20541.0, + 46045.0, + 86282.0, + 83332.0, + 69730.0, + 81593.0, + 82259.0, + 79908.0, + 81748.0, + 183367.0, + 380000.0, + 501517.0, + 116260.0, + 80266.0, + 108087.0, + 235504.0, + 319594.0, + 183713.0, + 156149.0, + 147247.0, + 148140.0, + 191010.0, + 224512.0, + 209808.0, + 155650.0, + 129353.0, + 120329.0, + 110424.0, + 111841.0, + 131106.0, + 131176.0, + 160372.0, + 139268.0, + 168601.0, + 146745.0, + 121054.0, + 123414.0, + 152260.0, + 177971.0, + 147708.0, + 117678.0, + 109217.0, + 105120.0, + 103468.0, + 108098.0, + 108577.0, + 100231.0, + 88608.0, + 81105.0, + 83636.0, + 82275.0, + 82362.0, + 86350.0, + 82293.0, + 69051.0, + 67786.0, + 69609.0, + 74147.0, + 71642.0, + 70749.0, + 67847.0, + 63010.0, + 56586.0, + 52579.0, + 50725.0, + 45623.0, + 42612.0, + 42721.0, + 40273.0, + 38720.0, + 36620.0, + 37025.0, + 36838.0, + 36466.0, + 34532.0, + 32326.0, + 30098.0, + 28279.0, + 28131.0, + 28797.0, + 28948.0, + 25773.0, + 23828.0, + 23064.0, + 21999.0, + 20460.0, + 19665.0, + 17671.0, + 16176.0, + 15295.0, + 14585.0, + 14292.0, + 14110.0, + 13931.0, + 13872.0, + 13413.0, + 12836.0, + 11707.0, + 11124.0, + 10776.0, + 10202.0, + 9981.0, + 9043.0, + 8645.0, + 8083.0, + 7736.0, + 7225.0, + 6719.0, + 6292.0, + 5911.0, + 5744.0, + 5548.0, + 5054.0, + 4840.0, + 4560.0, + 4510.0, + 4492.0, + 4137.0, + 3805.0, + 3687.0, + 62314.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 43.0, + 999.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 39.0, + 1118.0, + 303.0, + 0.0, + 29.0, + 99.0, + 82.0, + 83.0, + 66.0, + 102.0, + 97.0, + 85.0, + 104.0, + 80.0, + 770.0, + 260.0, + 64.0, + 36.0, + 126.0, + 430.0, + 270.0, + 135.0, + 134.0, + 130.0, + 133.0, + 175.0, + 190.0, + 125.0, + 79.0, + 73.0, + 84.0, + 50.0, + 76.0, + 94.0, + 110.0, + 91.0, + 102.0, + 111.0, + 65.0, + 72.0, + 88.0, + 112.0, + 135.0, + 101.0, + 93.0, + 49.0, + 55.0, + 48.0, + 22.0, + 54.0, + 49.0, + 59.0, + 62.0, + 46.0, + 54.0, + 44.0, + 80.0, + 70.0, + 56.0, + 53.0, + 46.0, + 44.0, + 58.0, + 36.0, + 27.0, + 34.0, + 46.0, + 20.0, + 30.0, + 20.0, + 13.0, + 25.0, + 13.0, + 22.0, + 19.0, + 15.0, + 22.0, + 38.0, + 38.0, + 17.0, + 27.0, + 16.0, + 14.0, + 19.0, + 20.0, + 12.0, + 14.0, + 8.0, + 13.0, + 17.0, + 10.0, + 13.0, + 12.0, + 5.0, + 6.0, + 5.0, + 5.0, + 1.0, + 6.0, + 7.0, + 7.0, + 3.0, + 7.0, + 9.0, + 8.0, + 6.0, + 9.0, + 1.0, + 1.0, + 3.0, + 1.0, + 3.0, + 1.0, + 5.0, + 3.0, + 4.0, + 2.0, + 2.0, + 0.0, + 1.0, + 2.0, + 3.0, + 19.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3818.0, + 100.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 5.0, + 192.0, + 1122.0, + 2480.0, + 197.0, + 104.0, + 57.0, + 76.0, + 153.0, + 118.0, + 121.0, + 178.0, + 139.0, + 155.0, + 411.0, + 260.0, + 775.0, + 356.0, + 233.0, + 267.0, + 296.0, + 640.0, + 366.0, + 300.0, + 305.0, + 270.0, + 322.0, + 369.0, + 419.0, + 337.0, + 295.0, + 333.0, + 262.0, + 270.0, + 247.0, + 259.0, + 289.0, + 341.0, + 219.0, + 222.0, + 212.0, + 241.0, + 218.0, + 259.0, + 290.0, + 219.0, + 181.0, + 183.0, + 163.0, + 159.0, + 160.0, + 172.0, + 143.0, + 149.0, + 132.0, + 132.0, + 141.0, + 159.0, + 179.0, + 176.0, + 161.0, + 132.0, + 165.0, + 173.0, + 170.0, + 131.0, + 144.0, + 134.0, + 181.0, + 145.0, + 120.0, + 114.0, + 79.0, + 91.0, + 84.0, + 82.0, + 73.0, + 84.0, + 95.0, + 88.0, + 86.0, + 64.0, + 63.0, + 63.0, + 72.0, + 56.0, + 52.0, + 58.0, + 27.0, + 33.0, + 36.0, + 27.0, + 26.0, + 19.0, + 34.0, + 27.0, + 44.0, + 25.0, + 42.0, + 24.0, + 24.0, + 28.0, + 24.0, + 23.0, + 22.0, + 11.0, + 8.0, + 12.0, + 16.0, + 19.0, + 17.0, + 20.0, + 10.0, + 10.0, + 12.0, + 18.0, + 14.0, + 7.0, + 5.0, + 15.0, + 8.0, + 12.0, + 15.0, + 15.0, + 225.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2802.0, + 1410.0, + 140.0, + 14358.0, + 1732.0, + 425.0, + 551.0, + 136.0, + 148.0, + 130.0, + 216.0, + 295.0, + 128.0, + 135.0, + 356.0, + 303.0, + 195.0, + 79.0, + 122.0, + 261.0, + 435.0, + 331.0, + 276.0, + 570.0, + 276.0, + 18407.0, + 6107.0, + 672.0, + 519.0, + 598.0, + 346.0, + 281.0, + 305.0, + 529.0, + 3331.0, + 398.0, + 427.0, + 420.0, + 434.0, + 412.0, + 361.0, + 355.0, + 319.0, + 350.0, + 342.0, + 313.0, + 288.0, + 325.0, + 321.0, + 368.0, + 348.0, + 596.0, + 409.0, + 355.0, + 390.0, + 487.0, + 492.0, + 382.0, + 421.0, + 353.0, + 286.0, + 310.0, + 292.0, + 321.0, + 286.0, + 322.0, + 311.0, + 360.0, + 296.0, + 256.0, + 273.0, + 276.0, + 283.0, + 306.0, + 329.0, + 266.0, + 210.0, + 245.0, + 253.0, + 209.0, + 159.0, + 200.0, + 221.0, + 204.0, + 178.0, + 157.0, + 211.0, + 171.0, + 166.0, + 179.0, + 181.0, + 174.0, + 151.0, + 142.0, + 153.0, + 176.0, + 160.0, + 180.0, + 184.0, + 135.0, + 127.0, + 122.0, + 163.0, + 143.0, + 115.0, + 103.0, + 102.0, + 91.0, + 96.0, + 98.0, + 86.0, + 82.0, + 73.0, + 77.0, + 83.0, + 64.0, + 67.0, + 69.0, + 69.0, + 63.0, + 77.0, + 1604.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 512.0, + 0.0, + 20.0, + 780.0, + 7048.0, + 4115.0, + 327.0, + 154.0, + 195.0, + 340.0, + 278.0, + 325.0, + 263.0, + 287.0, + 278.0, + 266.0, + 324.0, + 317.0, + 595.0, + 2456.0, + 429.0, + 562.0, + 806.0, + 708.0, + 705.0, + 7889.0, + 914.0, + 1508.0, + 1428.0, + 1012.0, + 1249.0, + 1321.0, + 987.0, + 2767.0, + 1078.0, + 1476.0, + 1309.0, + 1618.0, + 1469.0, + 938.0, + 2235.0, + 2130.0, + 916.0, + 920.0, + 860.0, + 913.0, + 1171.0, + 846.0, + 881.0, + 982.0, + 924.0, + 902.0, + 1151.0, + 1391.0, + 1107.0, + 1045.0, + 895.0, + 949.0, + 1012.0, + 990.0, + 978.0, + 982.0, + 951.0, + 902.0, + 938.0, + 902.0, + 913.0, + 1057.0, + 850.0, + 874.0, + 739.0, + 804.0, + 837.0, + 795.0, + 842.0, + 900.0, + 919.0, + 776.0, + 811.0, + 784.0, + 678.0, + 693.0, + 736.0, + 766.0, + 726.0, + 718.0, + 696.0, + 656.0, + 580.0, + 613.0, + 602.0, + 601.0, + 548.0, + 506.0, + 592.0, + 589.0, + 564.0, + 557.0, + 493.0, + 537.0, + 473.0, + 456.0, + 514.0, + 446.0, + 430.0, + 456.0, + 382.0, + 363.0, + 362.0, + 386.0, + 304.0, + 321.0, + 305.0, + 295.0, + 277.0, + 266.0, + 244.0, + 273.0, + 252.0, + 281.0, + 284.0, + 285.0, + 256.0, + 198.0, + 4950.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 4.0, + 24.0, + 21.0, + 5.0, + 3.0, + 0.0, + 1.0, + 0.0, + 1.0, + 3.0, + 6.0, + 2.0, + 4.0, + 7.0, + 5.0, + 7.0, + 9.0, + 10.0, + 7.0, + 4.0, + 9.0, + 7.0, + 8.0, + 42.0, + 18.0, + 3.0, + 5.0, + 7.0, + 8.0, + 2.0, + 4.0, + 27.0, + 23.0, + 12.0, + 7.0, + 6.0, + 5.0, + 3.0, + 8.0, + 8.0, + 5.0, + 3.0, + 4.0, + 5.0, + 5.0, + 4.0, + 2.0, + 11.0, + 7.0, + 5.0, + 7.0, + 2.0, + 4.0, + 8.0, + 1.0, + 12.0, + 4.0, + 10.0, + 10.0, + 7.0, + 4.0, + 2.0, + 6.0, + 9.0, + 5.0, + 6.0, + 4.0, + 7.0, + 15.0, + 13.0, + 7.0, + 12.0, + 12.0, + 10.0, + 9.0, + 9.0, + 18.0, + 5.0, + 5.0, + 5.0, + 6.0, + 10.0, + 9.0, + 7.0, + 7.0, + 5.0, + 8.0, + 5.0, + 3.0, + 8.0, + 8.0, + 3.0, + 1.0, + 2.0, + 3.0, + 11.0, + 11.0, + 6.0, + 5.0, + 4.0, + 7.0, + 3.0, + 4.0, + 3.0, + 2.0, + 2.0, + 2.0, + 3.0, + 8.0, + 6.0, + 3.0, + 2.0, + 3.0, + 0.0, + 0.0, + 3.0, + 64.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 51.0, + 67.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 6689.0, + 8960.0, + 532.0, + 380.0, + 17728.0, + 11660.0, + 7807.0, + 9599.0, + 9371.0, + 6314.0, + 5428.0, + 6040.0, + 5126.0, + 10052.0, + 29493.0, + 6552.0, + 7160.0, + 7073.0, + 7879.0, + 12356.0, + 7720.0, + 6342.0, + 7555.0, + 9430.0, + 10680.0, + 26466.0, + 37932.0, + 13523.0, + 12980.0, + 10149.0, + 9176.0, + 9383.0, + 15076.0, + 23576.0, + 34687.0, + 11720.0, + 13310.0, + 15226.0, + 15332.0, + 11620.0, + 13037.0, + 25208.0, + 42845.0, + 24366.0, + 11861.0, + 10646.0, + 10193.0, + 12210.0, + 11196.0, + 10755.0, + 10283.0, + 10638.0, + 10713.0, + 10383.0, + 12057.0, + 17695.0, + 17017.0, + 9796.0, + 9102.0, + 9533.0, + 8909.0, + 9580.0, + 11694.0, + 10158.0, + 10480.0, + 10132.0, + 9358.0, + 8834.0, + 6814.0, + 6510.0, + 5940.0, + 6109.0, + 5558.0, + 5680.0, + 6050.0, + 6902.0, + 6613.0, + 6609.0, + 5684.0, + 5670.0, + 4656.0, + 4405.0, + 4625.0, + 4316.0, + 4249.0, + 4077.0, + 3734.0, + 3693.0, + 3556.0, + 3504.0, + 3322.0, + 3185.0, + 3032.0, + 2727.0, + 2705.0, + 2760.0, + 2727.0, + 2731.0, + 2709.0, + 2298.0, + 2470.0, + 2315.0, + 2238.0, + 2564.0, + 2094.0, + 1977.0, + 1862.0, + 1653.0, + 1566.0, + 1541.0, + 1587.0, + 1527.0, + 1468.0, + 1332.0, + 1165.0, + 1226.0, + 1116.0, + 1093.0, + 1090.0, + 1153.0, + 916.0, + 912.0, + 921.0, + 912.0, + 16802.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 462.0, + 70.0, + 118.0, + 12038.0, + 14407.0, + 6509.0, + 5773.0, + 3787.0, + 3110.0, + 4216.0, + 4896.0, + 5184.0, + 5196.0, + 4404.0, + 4542.0, + 5149.0, + 4847.0, + 4488.0, + 5108.0, + 5232.0, + 5528.0, + 9015.0, + 5618.0, + 6217.0, + 11406.0, + 20097.0, + 13161.0, + 11755.0, + 12728.0, + 8613.0, + 8252.0, + 7822.0, + 8932.0, + 28596.0, + 7489.0, + 8214.0, + 8604.0, + 11896.0, + 11264.0, + 9768.0, + 12897.0, + 24431.0, + 21360.0, + 24947.0, + 9705.0, + 8301.0, + 7925.0, + 8189.0, + 8763.0, + 8196.0, + 8448.0, + 7828.0, + 8132.0, + 9564.0, + 9165.0, + 9394.0, + 9230.0, + 8778.0, + 8343.0, + 8851.0, + 8729.0, + 7803.0, + 8433.0, + 8319.0, + 8194.0, + 7948.0, + 7493.0, + 6861.0, + 6502.0, + 5938.0, + 5826.0, + 5831.0, + 5826.0, + 5777.0, + 6066.0, + 5910.0, + 5844.0, + 5654.0, + 5383.0, + 5302.0, + 4781.0, + 4298.0, + 4573.0, + 4738.0, + 4929.0, + 4724.0, + 4486.0, + 3941.0, + 3838.0, + 3840.0, + 3577.0, + 3342.0, + 3158.0, + 3037.0, + 3043.0, + 2989.0, + 2924.0, + 3322.0, + 2746.0, + 2630.0, + 2561.0, + 2377.0, + 2329.0, + 2356.0, + 2256.0, + 2166.0, + 2147.0, + 1922.0, + 1777.0, + 1744.0, + 1645.0, + 1645.0, + 1562.0, + 1560.0, + 1497.0, + 1383.0, + 1386.0, + 1283.0, + 1217.0, + 1218.0, + 1260.0, + 1180.0, + 1068.0, + 964.0, + 21521.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 40.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 64.0, + 20.0, + 1.0, + 22.0, + 11.0, + 11.0, + 28.0, + 11.0, + 16.0, + 23.0, + 41.0, + 49.0, + 48.0, + 50.0, + 34.0, + 50.0, + 22.0, + 27.0, + 67.0, + 72.0, + 44.0, + 66.0, + 73.0, + 31.0, + 28.0, + 42.0, + 18.0, + 31.0, + 14.0, + 22.0, + 37.0, + 34.0, + 21.0, + 27.0, + 31.0, + 14.0, + 24.0, + 20.0, + 24.0, + 97.0, + 97.0, + 32.0, + 22.0, + 19.0, + 19.0, + 23.0, + 17.0, + 32.0, + 30.0, + 17.0, + 18.0, + 19.0, + 23.0, + 32.0, + 27.0, + 37.0, + 30.0, + 26.0, + 24.0, + 14.0, + 13.0, + 12.0, + 17.0, + 18.0, + 10.0, + 5.0, + 8.0, + 19.0, + 20.0, + 17.0, + 20.0, + 9.0, + 12.0, + 8.0, + 7.0, + 15.0, + 12.0, + 12.0, + 11.0, + 8.0, + 7.0, + 6.0, + 8.0, + 4.0, + 7.0, + 5.0, + 4.0, + 7.0, + 6.0, + 5.0, + 3.0, + 7.0, + 6.0, + 5.0, + 5.0, + 5.0, + 4.0, + 3.0, + 7.0, + 2.0, + 2.0, + 3.0, + 2.0, + 2.0, + 2.0, + 3.0, + 5.0, + 5.0, + 2.0, + 0.0, + 2.0, + 2.0, + 1.0, + 2.0, + 3.0, + 1.0, + 2.0, + 2.0, + 1.0, + 3.0, + 43.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 50.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 19.0, + 3701.0, + 123.0, + 546.0, + 4454.0, + 1162.0, + 594.0, + 441.0, + 816.0, + 867.0, + 809.0, + 700.0, + 573.0, + 854.0, + 2270.0, + 892.0, + 883.0, + 894.0, + 819.0, + 804.0, + 865.0, + 966.0, + 828.0, + 1147.0, + 1505.0, + 3902.0, + 4672.0, + 2101.0, + 1043.0, + 1658.0, + 1199.0, + 1198.0, + 1178.0, + 1917.0, + 9766.0, + 1175.0, + 1150.0, + 1310.0, + 1392.0, + 2121.0, + 2732.0, + 2261.0, + 2384.0, + 1445.0, + 1467.0, + 1202.0, + 1204.0, + 1221.0, + 1511.0, + 1494.0, + 1331.0, + 1264.0, + 1265.0, + 1108.0, + 1205.0, + 1768.0, + 3072.0, + 1508.0, + 1109.0, + 1108.0, + 1266.0, + 1179.0, + 1322.0, + 1228.0, + 1150.0, + 1063.0, + 1007.0, + 1325.0, + 1007.0, + 716.0, + 663.0, + 746.0, + 680.0, + 667.0, + 682.0, + 881.0, + 800.0, + 697.0, + 698.0, + 638.0, + 565.0, + 525.0, + 537.0, + 508.0, + 452.0, + 520.0, + 492.0, + 494.0, + 474.0, + 404.0, + 418.0, + 404.0, + 376.0, + 321.0, + 316.0, + 293.0, + 327.0, + 339.0, + 408.0, + 266.0, + 241.0, + 269.0, + 247.0, + 220.0, + 235.0, + 231.0, + 189.0, + 184.0, + 170.0, + 172.0, + 185.0, + 149.0, + 153.0, + 156.0, + 118.0, + 109.0, + 113.0, + 94.0, + 112.0, + 119.0, + 94.0, + 1706.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 2.0, + 7.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 12.0, + 8.0, + 2.0, + 4.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 1.0, + 8.0, + 3.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 2.0, + 13.0, + 0.0, + 0.0, + 0.0, + 14.0, + 21.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 2.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, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 9.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 2.0, + 2.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 1.0, + 2.0, + 3.0, + 2.0, + 1.0, + 3.0, + 4.0, + 1.0, + 3.0, + 2.0, + 0.0, + 1.0, + 1.0, + 4.0, + 2.0, + 2.0, + 5.0, + 3.0, + 3.0, + 1.0, + 4.0, + 0.0, + 2.0, + 0.0, + 3.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 1.0, + 2.0, + 2.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 3.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 1.0, + 1.0, + 3.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 3.0, + 0.0, + 1.0, + 1.0, + 2.0, + 2.0, + 1.0, + 0.0, + 0.0, + 2.0, + 2.0, + 1.0, + 1.0, + 1.0, + 1.0, + 1.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, + 1.0, + 1.0, + 0.0, + 2.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 335097.0, + 36.0, + 1.0, + 20.0, + 332.0, + 14155.0, + 8899.0, + 3766.0, + 681.0, + 418.0, + 239.0, + 251.0, + 525.0, + 2091.0, + 2217.0, + 765.0, + 536.0, + 496.0, + 781.0, + 2370.0, + 8244.0, + 1339.0, + 466.0, + 706.0, + 997.0, + 24429.0, + 7803.0, + 1815.0, + 1636.0, + 2558.0, + 2257.0, + 1974.0, + 2311.0, + 7855.0, + 1777.0, + 1848.0, + 1863.0, + 2080.0, + 3855.0, + 3470.0, + 2747.0, + 3378.0, + 3812.0, + 3953.0, + 3444.0, + 1943.0, + 1822.0, + 2772.0, + 1989.0, + 2002.0, + 2441.0, + 1580.0, + 1401.0, + 1486.0, + 1608.0, + 1409.0, + 1326.0, + 1334.0, + 1459.0, + 1449.0, + 1539.0, + 1742.0, + 1708.0, + 1535.0, + 1508.0, + 1588.0, + 1360.0, + 1367.0, + 1489.0, + 1612.0, + 1684.0, + 1594.0, + 1463.0, + 1347.0, + 1347.0, + 1486.0, + 1357.0, + 1383.0, + 1271.0, + 1238.0, + 1239.0, + 1205.0, + 1196.0, + 1333.0, + 1300.0, + 1272.0, + 1333.0, + 1221.0, + 1256.0, + 1232.0, + 1359.0, + 1429.0, + 1446.0, + 1189.0, + 1132.0, + 1145.0, + 1130.0, + 1154.0, + 1033.0, + 1077.0, + 1052.0, + 1057.0, + 1112.0, + 1119.0, + 976.0, + 1039.0, + 845.0, + 818.0, + 851.0, + 813.0, + 820.0, + 849.0, + 774.0, + 790.0, + 755.0, + 668.0, + 595.0, + 560.0, + 633.0, + 549.0, + 594.0, + 528.0, + 558.0, + 500.0, + 546.0, + 547.0, + 462.0, + 415.0, + 9514.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 2.0, + 3.0, + 18.0, + 7.0, + 4.0, + 0.0, + 1.0, + 3.0, + 3.0, + 3.0, + 1.0, + 0.0, + 0.0, + 2.0, + 1.0, + 3.0, + 3.0, + 3.0, + 5.0, + 6.0, + 5.0, + 8.0, + 3.0, + 11.0, + 289.0, + 11.0, + 12.0, + 4.0, + 8.0, + 9.0, + 8.0, + 6.0, + 20.0, + 19.0, + 31.0, + 39.0, + 15.0, + 16.0, + 18.0, + 28.0, + 18.0, + 7.0, + 15.0, + 8.0, + 5.0, + 15.0, + 8.0, + 5.0, + 2.0, + 10.0, + 9.0, + 12.0, + 3.0, + 6.0, + 9.0, + 7.0, + 15.0, + 53.0, + 37.0, + 26.0, + 20.0, + 14.0, + 11.0, + 16.0, + 14.0, + 5.0, + 9.0, + 9.0, + 5.0, + 9.0, + 11.0, + 15.0, + 16.0, + 13.0, + 18.0, + 11.0, + 20.0, + 11.0, + 20.0, + 10.0, + 13.0, + 6.0, + 16.0, + 14.0, + 14.0, + 7.0, + 16.0, + 15.0, + 10.0, + 18.0, + 10.0, + 6.0, + 12.0, + 6.0, + 4.0, + 10.0, + 5.0, + 8.0, + 7.0, + 7.0, + 2.0, + 4.0, + 7.0, + 8.0, + 3.0, + 5.0, + 8.0, + 5.0, + 6.0, + 5.0, + 5.0, + 5.0, + 3.0, + 3.0, + 2.0, + 4.0, + 5.0, + 2.0, + 2.0, + 0.0, + 45.0 + ], + [ + 0.0, + 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, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3388.0, + 160.0, + 25.0, + 924.0, + 11605.0, + 31718.0, + 32965.0, + 23408.0, + 15341.0, + 12006.0, + 8895.0, + 14120.0, + 12670.0, + 31627.0, + 15879.0, + 6882.0, + 7500.0, + 9707.0, + 18585.0, + 23582.0, + 23613.0, + 20248.0, + 19859.0, + 19859.0, + 19166.0, + 40511.0, + 30970.0, + 39176.0, + 30703.0, + 30224.0, + 24304.0, + 22186.0, + 33933.0, + 43961.0, + 24232.0, + 30498.0, + 31050.0, + 27057.0, + 33433.0, + 33557.0, + 49140.0, + 67907.0, + 34611.0, + 27793.0, + 26107.0, + 25689.0, + 26842.0, + 32658.0, + 32025.0, + 27374.0, + 28185.0, + 28326.0, + 24931.0, + 25926.0, + 29433.0, + 28585.0, + 22071.0, + 22378.0, + 21678.0, + 21364.0, + 23772.0, + 27509.0, + 27583.0, + 24330.0, + 22609.0, + 21204.0, + 19382.0, + 17703.0, + 17519.0, + 17402.0, + 16084.0, + 15778.0, + 15509.0, + 15852.0, + 15794.0, + 16431.0, + 15490.0, + 14808.0, + 13831.0, + 13294.0, + 12796.0, + 12970.0, + 13276.0, + 12847.0, + 11927.0, + 11586.0, + 11305.0, + 11095.0, + 10269.0, + 10330.0, + 9613.0, + 9117.0, + 8671.0, + 8444.0, + 8563.0, + 8119.0, + 8093.0, + 8084.0, + 7600.0, + 7215.0, + 6993.0, + 6731.0, + 6915.0, + 6583.0, + 6463.0, + 6502.0, + 6007.0, + 5593.0, + 5644.0, + 5457.0, + 5290.0, + 4920.0, + 4865.0, + 4369.0, + 4201.0, + 4038.0, + 4111.0, + 3867.0, + 3845.0, + 3569.0, + 3403.0, + 3244.0, + 3304.0, + 3210.0, + 3028.0, + 2887.0, + 2667.0, + 50372.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 13071.0, + 24077.0, + 596.0, + 34.0, + 1053.0, + 13078.0, + 22847.0, + 14610.0, + 10820.0, + 7630.0, + 5044.0, + 4999.0, + 6513.0, + 6516.0, + 13501.0, + 4042.0, + 6598.0, + 4962.0, + 4872.0, + 8374.0, + 10552.0, + 10678.0, + 14798.0, + 12396.0, + 14933.0, + 19361.0, + 24995.0, + 17801.0, + 21991.0, + 19781.0, + 16447.0, + 13845.0, + 13646.0, + 22108.0, + 13907.0, + 14238.0, + 14797.0, + 16491.0, + 20855.0, + 19015.0, + 27025.0, + 37069.0, + 27731.0, + 31792.0, + 18537.0, + 15959.0, + 17224.0, + 20149.0, + 21631.0, + 21189.0, + 21744.0, + 19542.0, + 17329.0, + 18630.0, + 18066.0, + 17913.0, + 16938.0, + 14723.0, + 13903.0, + 15216.0, + 15479.0, + 15156.0, + 16375.0, + 18233.0, + 15453.0, + 15416.0, + 14550.0, + 13633.0, + 13293.0, + 13366.0, + 13685.0, + 13126.0, + 12401.0, + 12153.0, + 12171.0, + 11623.0, + 12008.0, + 11489.0, + 11339.0, + 10931.0, + 11106.0, + 10285.0, + 10279.0, + 10761.0, + 10459.0, + 9980.0, + 9578.0, + 9202.0, + 8668.0, + 8780.0, + 8354.0, + 8011.0, + 7602.0, + 7349.0, + 7492.0, + 7293.0, + 6952.0, + 7577.0, + 6699.0, + 6513.0, + 6156.0, + 6154.0, + 5900.0, + 5880.0, + 5805.0, + 5702.0, + 5718.0, + 5337.0, + 5113.0, + 4924.0, + 4716.0, + 4786.0, + 4647.0, + 4527.0, + 4208.0, + 3970.0, + 3996.0, + 3613.0, + 3443.0, + 3486.0, + 3127.0, + 3312.0, + 3169.0, + 3321.0, + 3015.0, + 2931.0, + 2675.0, + 2696.0, + 54983.0 + ], + [ + 0.0, + 0.0, + 0.0, + 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, + 12.0, + 402.0, + 299.0, + 3.0, + 9.0, + 11.0, + 13.0, + 9.0, + 12.0, + 21.0, + 21.0, + 29.0, + 21.0, + 8.0, + 9.0, + 27.0, + 36.0, + 38.0, + 37.0, + 24.0, + 21.0, + 27.0, + 43.0, + 48.0, + 68.0, + 33.0, + 37.0, + 37.0, + 32.0, + 37.0, + 38.0, + 45.0, + 39.0, + 55.0, + 31.0, + 37.0, + 38.0, + 35.0, + 71.0, + 106.0, + 101.0, + 97.0, + 100.0, + 45.0, + 47.0, + 46.0, + 45.0, + 57.0, + 31.0, + 47.0, + 49.0, + 25.0, + 31.0, + 40.0, + 20.0, + 38.0, + 30.0, + 26.0, + 28.0, + 20.0, + 23.0, + 19.0, + 29.0, + 27.0, + 30.0, + 17.0, + 16.0, + 26.0, + 10.0, + 17.0, + 16.0, + 8.0, + 15.0, + 15.0, + 13.0, + 16.0, + 11.0, + 6.0, + 6.0, + 12.0, + 7.0, + 23.0, + 12.0, + 14.0, + 20.0, + 6.0, + 11.0, + 7.0, + 7.0, + 6.0, + 8.0, + 10.0, + 14.0, + 18.0, + 25.0, + 8.0, + 13.0, + 6.0, + 13.0, + 10.0, + 13.0, + 6.0, + 9.0, + 10.0, + 4.0, + 14.0, + 11.0, + 3.0, + 4.0, + 8.0, + 3.0, + 6.0, + 7.0, + 6.0, + 8.0, + 5.0, + 8.0, + 6.0, + 8.0, + 3.0, + 4.0, + 6.0, + 6.0, + 3.0, + 136.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 280.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 718.0, + 3659.0, + 116.0, + 359.0, + 3183.0, + 2537.0, + 1737.0, + 774.0, + 526.0, + 772.0, + 1369.0, + 1518.0, + 1273.0, + 1236.0, + 1340.0, + 604.0, + 883.0, + 1153.0, + 1297.0, + 1873.0, + 2043.0, + 1971.0, + 1700.0, + 1436.0, + 1867.0, + 4931.0, + 3171.0, + 3290.0, + 2864.0, + 2249.0, + 2377.0, + 2190.0, + 2481.0, + 8442.0, + 2409.0, + 2400.0, + 2873.0, + 3323.0, + 3984.0, + 4059.0, + 4396.0, + 7502.0, + 2819.0, + 2552.0, + 2745.0, + 2523.0, + 2334.0, + 2897.0, + 2682.0, + 2708.0, + 2888.0, + 2817.0, + 2310.0, + 2283.0, + 2681.0, + 3425.0, + 2682.0, + 2043.0, + 2005.0, + 2168.0, + 2042.0, + 2298.0, + 2428.0, + 2482.0, + 2026.0, + 1910.0, + 2094.0, + 2049.0, + 1655.0, + 1814.0, + 1533.0, + 1494.0, + 1396.0, + 1465.0, + 1728.0, + 1620.0, + 1564.0, + 1432.0, + 1441.0, + 1233.0, + 1219.0, + 1163.0, + 1269.0, + 1180.0, + 1151.0, + 1234.0, + 1188.0, + 1136.0, + 969.0, + 921.0, + 962.0, + 842.0, + 864.0, + 827.0, + 996.0, + 874.0, + 902.0, + 854.0, + 700.0, + 676.0, + 621.0, + 580.0, + 619.0, + 623.0, + 621.0, + 542.0, + 594.0, + 574.0, + 464.0, + 480.0, + 440.0, + 393.0, + 394.0, + 399.0, + 332.0, + 345.0, + 336.0, + 310.0, + 317.0, + 292.0, + 269.0, + 231.0, + 208.0, + 219.0, + 3629.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 56.0, + 0.0, + 0.0, + 0.0, + 2.0, + 3.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 3.0, + 2.0, + 4.0, + 21.0, + 8.0, + 6.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 3.0, + 6.0, + 1.0, + 1.0, + 3.0, + 2.0, + 1.0, + 0.0, + 26.0, + 1.0, + 6.0, + 7.0, + 7.0, + 26.0, + 3.0, + 3.0, + 3.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.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, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.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, + 1.0, + 5.0, + 7.0, + 4.0, + 1.0, + 8.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 3.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 26.0, + 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, + 0.0, + 5.0, + 2.0, + 0.0, + 2.0, + 0.0, + 3.0, + 1.0, + 0.0, + 1.0, + 1.0, + 5.0, + 6.0, + 2.0, + 0.0, + 1.0, + 1.0, + 5.0, + 0.0, + 1.0, + 1.0, + 2.0, + 4.0, + 3.0, + 1.0, + 2.0, + 0.0, + 1.0, + 4.0, + 5.0, + 0.0, + 5.0, + 6.0, + 5.0, + 4.0, + 2.0, + 21.0, + 4.0, + 13.0, + 11.0, + 4.0, + 3.0, + 2.0, + 5.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 4.0, + 25.0, + 0.0, + 2.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.0, + 5.0, + 1.0, + 1.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 2.0, + 1.0, + 2.0, + 1.0, + 2.0, + 2.0, + 1.0, + 1.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 6.0, + 2.0, + 1.0, + 4.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 3.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 1.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 30.0, + 13.0, + 0.0, + 1.0, + 11.0, + 0.0, + 0.0, + 0.0, + 0.0, + 3.0, + 4.0, + 4.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 4.0, + 0.0, + 5.0, + 2.0, + 7.0, + 92.0, + 425.0, + 33.0, + 26.0, + 15.0, + 12.0, + 2.0, + 4.0, + 5.0, + 3.0, + 27.0, + 33.0, + 6.0, + 3.0, + 3.0, + 4.0, + 1.0, + 0.0, + 1.0, + 2.0, + 1.0, + 3.0, + 1.0, + 3.0, + 4.0, + 2.0, + 0.0, + 2.0, + 0.0, + 0.0, + 1.0, + 2.0, + 3.0, + 11.0, + 6.0, + 0.0, + 5.0, + 0.0, + 1.0, + 2.0, + 3.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 10.0, + 1.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 3.0, + 0.0, + 1.0, + 1.0, + 4.0, + 1.0, + 2.0, + 2.0, + 3.0, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 1.0, + 4.0, + 1.0, + 0.0, + 1.0, + 0.0, + 3.0, + 2.0, + 1.0, + 2.0, + 2.0, + 0.0, + 0.0, + 2.0, + 1.0, + 1.0, + 16.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 1.0, + 170.0, + 180.0, + 68.0, + 315.0, + 72.0, + 43.0, + 58.0, + 77.0, + 116.0, + 74.0, + 46.0, + 45.0, + 168.0, + 622.0, + 438.0, + 50.0, + 62.0, + 65.0, + 115.0, + 135.0, + 88.0, + 78.0, + 116.0, + 191.0, + 221.0, + 331.0, + 296.0, + 375.0, + 211.0, + 205.0, + 124.0, + 141.0, + 301.0, + 1285.0, + 219.0, + 216.0, + 285.0, + 364.0, + 304.0, + 132.0, + 183.0, + 495.0, + 362.0, + 175.0, + 154.0, + 150.0, + 132.0, + 159.0, + 126.0, + 170.0, + 135.0, + 158.0, + 102.0, + 143.0, + 203.0, + 299.0, + 169.0, + 121.0, + 106.0, + 104.0, + 124.0, + 101.0, + 109.0, + 125.0, + 134.0, + 137.0, + 152.0, + 92.0, + 74.0, + 61.0, + 70.0, + 61.0, + 43.0, + 66.0, + 77.0, + 80.0, + 72.0, + 71.0, + 98.0, + 71.0, + 61.0, + 45.0, + 43.0, + 63.0, + 59.0, + 49.0, + 36.0, + 41.0, + 37.0, + 36.0, + 44.0, + 40.0, + 29.0, + 36.0, + 16.0, + 21.0, + 26.0, + 57.0, + 54.0, + 24.0, + 42.0, + 29.0, + 20.0, + 30.0, + 18.0, + 14.0, + 17.0, + 17.0, + 14.0, + 15.0, + 11.0, + 18.0, + 21.0, + 11.0, + 13.0, + 15.0, + 7.0, + 13.0, + 15.0, + 11.0, + 187.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 18.0, + 37.0, + 917.0, + 500.0, + 186.0, + 87.0, + 75.0, + 171.0, + 97.0, + 66.0, + 57.0, + 54.0, + 33.0, + 91.0, + 95.0, + 84.0, + 69.0, + 74.0, + 85.0, + 84.0, + 95.0, + 95.0, + 89.0, + 143.0, + 487.0, + 407.0, + 292.0, + 453.0, + 217.0, + 164.0, + 182.0, + 184.0, + 326.0, + 508.0, + 166.0, + 218.0, + 221.0, + 437.0, + 218.0, + 235.0, + 255.0, + 518.0, + 422.0, + 418.0, + 184.0, + 178.0, + 146.0, + 176.0, + 176.0, + 173.0, + 172.0, + 165.0, + 178.0, + 175.0, + 189.0, + 170.0, + 180.0, + 173.0, + 169.0, + 168.0, + 128.0, + 116.0, + 178.0, + 163.0, + 151.0, + 94.0, + 117.0, + 129.0, + 125.0, + 99.0, + 90.0, + 90.0, + 86.0, + 82.0, + 106.0, + 90.0, + 106.0, + 73.0, + 87.0, + 91.0, + 68.0, + 67.0, + 92.0, + 57.0, + 83.0, + 70.0, + 81.0, + 64.0, + 70.0, + 106.0, + 54.0, + 45.0, + 57.0, + 52.0, + 63.0, + 42.0, + 59.0, + 53.0, + 52.0, + 45.0, + 37.0, + 34.0, + 40.0, + 40.0, + 22.0, + 20.0, + 34.0, + 24.0, + 18.0, + 24.0, + 24.0, + 23.0, + 25.0, + 23.0, + 18.0, + 20.0, + 21.0, + 16.0, + 22.0, + 18.0, + 304.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 1.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, + 0.0, + 3.0, + 3.0, + 6.0, + 4.0, + 4.0, + 3.0, + 3.0, + 0.0, + 4.0, + 2.0, + 1.0, + 3.0, + 0.0, + 2.0, + 2.0, + 2.0, + 1.0, + 0.0, + 1.0, + 2.0, + 0.0, + 0.0, + 1.0, + 0.0, + 2.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 3.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 5.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.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, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 49.0, + 19.0, + 19.0, + 19.0, + 5.0, + 3.0, + 6.0, + 3.0, + 8.0, + 3.0, + 7.0, + 11.0, + 8.0, + 15.0, + 5.0, + 43.0, + 2.0, + 4.0, + 4.0, + 7.0, + 4.0, + 11.0, + 34.0, + 17.0, + 25.0, + 40.0, + 64.0, + 17.0, + 9.0, + 9.0, + 9.0, + 20.0, + 10.0, + 79.0, + 43.0, + 9.0, + 18.0, + 10.0, + 28.0, + 45.0, + 44.0, + 32.0, + 27.0, + 9.0, + 18.0, + 16.0, + 13.0, + 9.0, + 21.0, + 13.0, + 8.0, + 12.0, + 10.0, + 21.0, + 47.0, + 58.0, + 62.0, + 20.0, + 10.0, + 11.0, + 18.0, + 13.0, + 23.0, + 13.0, + 11.0, + 7.0, + 12.0, + 22.0, + 12.0, + 0.0, + 8.0, + 9.0, + 10.0, + 7.0, + 12.0, + 11.0, + 15.0, + 6.0, + 9.0, + 13.0, + 21.0, + 7.0, + 3.0, + 3.0, + 5.0, + 4.0, + 5.0, + 20.0, + 29.0, + 3.0, + 6.0, + 8.0, + 2.0, + 3.0, + 1.0, + 4.0, + 7.0, + 5.0, + 8.0, + 6.0, + 8.0, + 7.0, + 6.0, + 5.0, + 5.0, + 4.0, + 1.0, + 3.0, + 2.0, + 3.0, + 2.0, + 2.0, + 2.0, + 0.0, + 2.0, + 0.0, + 0.0, + 35.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.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, + 0.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.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, + 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, + 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, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 20.0, + 1097.0, + 45923.0, + 412373.0, + 12094.0, + 23.0, + 13.0, + 0.0, + 0.0, + 0.0, + 7381.0, + 176337.0, + 405105.0, + 700717.0, + 86391.0, + 7038.0, + 62880.0, + 133016.0, + 217452.0, + 91501.0, + 60581.0, + 43252.0, + 35218.0, + 44732.0, + 104945.0, + 243897.0, + 244832.0, + 69855.0, + 26171.0, + 46352.0, + 142899.0, + 171412.0, + 153373.0, + 120329.0, + 124232.0, + 146181.0, + 143194.0, + 125098.0, + 128777.0, + 109116.0, + 92966.0, + 78125.0, + 76631.0, + 72570.0, + 64674.0, + 87857.0, + 98708.0, + 79982.0, + 84383.0, + 78524.0, + 71658.0, + 71006.0, + 83424.0, + 117191.0, + 120003.0, + 87104.0, + 67064.0, + 67154.0, + 63799.0, + 69160.0, + 70429.0, + 62913.0, + 54723.0, + 52264.0, + 51585.0, + 50828.0, + 53354.0, + 62184.0, + 49161.0, + 41399.0, + 40747.0, + 40613.0, + 41601.0, + 44694.0, + 51451.0, + 49288.0, + 45304.0, + 40164.0, + 38211.0, + 35316.0, + 29913.0, + 28547.0, + 28428.0, + 26461.0, + 25398.0, + 24057.0, + 23382.0, + 24161.0, + 25243.0, + 23541.0, + 21992.0, + 18824.0, + 17982.0, + 18570.0, + 19931.0, + 21997.0, + 19454.0, + 16390.0, + 16046.0, + 14591.0, + 13853.0, + 13358.0, + 13253.0, + 12636.0, + 11773.0, + 10532.0, + 9813.0, + 9733.0, + 9636.0, + 9539.0, + 8953.0, + 8288.0, + 7748.0, + 7610.0, + 7425.0, + 7774.0, + 7748.0, + 7549.0, + 6809.0, + 6205.0, + 5716.0, + 5755.0, + 5202.0, + 5196.0, + 4718.0, + 4440.0, + 4151.0, + 4065.0, + 3951.0, + 3800.0, + 3604.0, + 3508.0, + 3360.0, + 3109.0, + 2892.0, + 3048.0, + 2846.0, + 2702.0, + 2739.0, + 56104.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 284.0, + 120819.0, + 79092.0, + 18740.0, + 2.0, + 0.0, + 4.0, + 0.0, + 0.0, + 103.0, + 46561.0, + 1313469.0, + 179643.0, + 269606.0, + 107514.0, + 166924.0, + 237142.0, + 236567.0, + 130314.0, + 81635.0, + 64108.0, + 70450.0, + 89132.0, + 145454.0, + 235783.0, + 148288.0, + 63260.0, + 52667.0, + 85236.0, + 210303.0, + 211103.0, + 196522.0, + 156835.0, + 169602.0, + 199157.0, + 183448.0, + 215935.0, + 198221.0, + 165493.0, + 168699.0, + 157931.0, + 129654.0, + 119616.0, + 130483.0, + 186474.0, + 112868.0, + 117128.0, + 123676.0, + 129550.0, + 143374.0, + 134795.0, + 179165.0, + 268862.0, + 207121.0, + 172576.0, + 116242.0, + 110144.0, + 111885.0, + 122414.0, + 123794.0, + 120010.0, + 111334.0, + 107837.0, + 98102.0, + 96193.0, + 107010.0, + 104330.0, + 81897.0, + 71854.0, + 73550.0, + 76425.0, + 78209.0, + 86711.0, + 91186.0, + 88042.0, + 86659.0, + 77818.0, + 74440.0, + 64974.0, + 61262.0, + 60104.0, + 54449.0, + 51719.0, + 47607.0, + 47422.0, + 47243.0, + 46091.0, + 44802.0, + 41361.0, + 39294.0, + 36346.0, + 35057.0, + 35617.0, + 38133.0, + 39120.0, + 36093.0, + 32845.0, + 30982.0, + 30248.0, + 28446.0, + 26463.0, + 25675.0, + 25405.0, + 23374.0, + 21066.0, + 20892.0, + 20190.0, + 19469.0, + 18630.0, + 17088.0, + 16159.0, + 16290.0, + 15796.0, + 16089.0, + 15765.0, + 14911.0, + 14657.0, + 13485.0, + 12405.0, + 11710.0, + 11147.0, + 10803.0, + 10178.0, + 9942.0, + 9817.0, + 9198.0, + 8801.0, + 8348.0, + 8095.0, + 7830.0, + 7521.0, + 6828.0, + 6671.0, + 6561.0, + 6300.0, + 6090.0, + 5838.0, + 5654.0, + 107112.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 111.0, + 2405.0, + 89.0, + 5.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 105.0, + 1531.0, + 741.0, + 197.0, + 66.0, + 358.0, + 292.0, + 162.0, + 266.0, + 380.0, + 268.0, + 239.0, + 331.0, + 283.0, + 387.0, + 353.0, + 286.0, + 339.0, + 286.0, + 285.0, + 410.0, + 411.0, + 458.0, + 515.0, + 650.0, + 535.0, + 473.0, + 495.0, + 373.0, + 382.0, + 399.0, + 359.0, + 339.0, + 348.0, + 464.0, + 339.0, + 296.0, + 395.0, + 376.0, + 382.0, + 406.0, + 382.0, + 424.0, + 394.0, + 304.0, + 328.0, + 372.0, + 247.0, + 278.0, + 198.0, + 216.0, + 190.0, + 163.0, + 137.0, + 126.0, + 146.0, + 143.0, + 130.0, + 159.0, + 140.0, + 197.0, + 172.0, + 162.0, + 182.0, + 159.0, + 84.0, + 91.0, + 100.0, + 86.0, + 84.0, + 71.0, + 59.0, + 62.0, + 65.0, + 64.0, + 56.0, + 39.0, + 49.0, + 62.0, + 59.0, + 49.0, + 42.0, + 36.0, + 37.0, + 48.0, + 33.0, + 36.0, + 30.0, + 46.0, + 30.0, + 47.0, + 39.0, + 38.0, + 32.0, + 30.0, + 29.0, + 28.0, + 39.0, + 27.0, + 25.0, + 42.0, + 24.0, + 33.0, + 21.0, + 13.0, + 19.0, + 20.0, + 23.0, + 16.0, + 20.0, + 13.0, + 7.0, + 16.0, + 13.0, + 18.0, + 13.0, + 15.0, + 22.0, + 18.0, + 13.0, + 12.0, + 7.0, + 3.0, + 7.0, + 102.0 + ], + [ + 0.0, + 0.0, + 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, + 7537.0, + 171861.0, + 1662.0, + 4.0, + 2.0, + 0.0, + 0.0, + 0.0, + 17.0, + 2710.0, + 129778.0, + 179321.0, + 118432.0, + 12644.0, + 11881.0, + 24829.0, + 20929.0, + 17279.0, + 15451.0, + 16604.0, + 19993.0, + 15111.0, + 20307.0, + 65157.0, + 63338.0, + 28204.0, + 9534.0, + 11807.0, + 24398.0, + 38837.0, + 26213.0, + 21628.0, + 21647.0, + 25366.0, + 33141.0, + 31595.0, + 32679.0, + 23690.0, + 20911.0, + 19915.0, + 15342.0, + 14842.0, + 18746.0, + 25336.0, + 36402.0, + 17600.0, + 20204.0, + 18005.0, + 16650.0, + 15259.0, + 16805.0, + 21816.0, + 27490.0, + 19365.0, + 14482.0, + 13733.0, + 13269.0, + 13289.0, + 13722.0, + 13582.0, + 12329.0, + 11782.0, + 11145.0, + 9919.0, + 11159.0, + 12519.0, + 13071.0, + 9390.0, + 8309.0, + 8650.0, + 8406.0, + 9000.0, + 9106.0, + 8490.0, + 8174.0, + 7779.0, + 7066.0, + 6660.0, + 5892.0, + 4991.0, + 5047.0, + 5252.0, + 4757.0, + 4957.0, + 4483.0, + 4901.0, + 4921.0, + 4645.0, + 4272.0, + 3895.0, + 3335.0, + 2952.0, + 3512.0, + 3540.0, + 2997.0, + 2931.0, + 2569.0, + 2431.0, + 2345.0, + 2275.0, + 2228.0, + 2059.0, + 1874.0, + 1635.0, + 1569.0, + 1503.0, + 1664.0, + 1753.0, + 1618.0, + 1352.0, + 1350.0, + 1191.0, + 1135.0, + 1223.0, + 1140.0, + 1030.0, + 1026.0, + 935.0, + 897.0, + 817.0, + 801.0, + 762.0, + 625.0, + 688.0, + 527.0, + 567.0, + 656.0, + 576.0, + 523.0, + 535.0, + 444.0, + 421.0, + 376.0, + 339.0, + 7259.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 44.0, + 72.0, + 1.0, + 15.0, + 8.0, + 24.0, + 46.0, + 10.0, + 20.0, + 17.0, + 16.0, + 4.0, + 33.0, + 19.0, + 44.0, + 15.0, + 27.0, + 16.0, + 18.0, + 38.0, + 72.0, + 40.0, + 10.0, + 28.0, + 32.0, + 5.0, + 24.0, + 14.0, + 5.0, + 19.0, + 18.0, + 3.0, + 1.0, + 19.0, + 31.0, + 45.0, + 5.0, + 14.0, + 8.0, + 2.0, + 21.0, + 17.0, + 26.0, + 33.0, + 14.0, + 15.0, + 9.0, + 1.0, + 5.0, + 6.0, + 2.0, + 6.0, + 2.0, + 5.0, + 1.0, + 5.0, + 6.0, + 0.0, + 1.0, + 2.0, + 1.0, + 1.0, + 5.0, + 4.0, + 0.0, + 0.0, + 1.0, + 4.0, + 1.0, + 2.0, + 0.0, + 0.0, + 1.0, + 2.0, + 1.0, + 1.0, + 4.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 2.0, + 2.0, + 0.0, + 1.0, + 2.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 21.0, + 2.0, + 1.0, + 0.0, + 1.0, + 5.0, + 0.0, + 6.0, + 9.0, + 1.0, + 12.0, + 33.0, + 5.0, + 0.0, + 28.0, + 11.0, + 30.0, + 8.0, + 5.0, + 5.0, + 20.0, + 27.0, + 44.0, + 39.0, + 44.0, + 68.0, + 34.0, + 38.0, + 54.0, + 72.0, + 51.0, + 56.0, + 44.0, + 64.0, + 36.0, + 59.0, + 71.0, + 61.0, + 56.0, + 72.0, + 99.0, + 72.0, + 60.0, + 55.0, + 57.0, + 40.0, + 42.0, + 35.0, + 29.0, + 43.0, + 31.0, + 72.0, + 52.0, + 53.0, + 33.0, + 22.0, + 33.0, + 27.0, + 19.0, + 21.0, + 21.0, + 16.0, + 26.0, + 27.0, + 26.0, + 21.0, + 20.0, + 13.0, + 10.0, + 29.0, + 13.0, + 27.0, + 6.0, + 13.0, + 10.0, + 11.0, + 20.0, + 13.0, + 5.0, + 6.0, + 3.0, + 7.0, + 6.0, + 5.0, + 5.0, + 9.0, + 7.0, + 8.0, + 9.0, + 4.0, + 8.0, + 11.0, + 5.0, + 6.0, + 7.0, + 3.0, + 3.0, + 3.0, + 2.0, + 2.0, + 1.0, + 3.0, + 1.0, + 2.0, + 2.0, + 2.0, + 0.0, + 3.0, + 1.0, + 2.0, + 12.0, + 2.0, + 0.0, + 0.0, + 0.0, + 4.0, + 2.0, + 1.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 2.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 2.0, + 19.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 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, + 1673.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 77606.0, + 323872.0, + 32635.0, + 233592.0, + 82367.0, + 115977.0, + 84872.0, + 75682.0, + 40817.0, + 28279.0, + 28205.0, + 39925.0, + 58114.0, + 63685.0, + 91345.0, + 45399.0, + 29394.0, + 30439.0, + 33392.0, + 60052.0, + 61174.0, + 63066.0, + 58092.0, + 64764.0, + 133663.0, + 117513.0, + 140650.0, + 93826.0, + 82566.0, + 89174.0, + 79582.0, + 59809.0, + 60673.0, + 62974.0, + 68091.0, + 48004.0, + 50343.0, + 55760.0, + 69725.0, + 67242.0, + 70781.0, + 102165.0, + 132727.0, + 107112.0, + 75101.0, + 57424.0, + 59889.0, + 68752.0, + 72341.0, + 69677.0, + 66753.0, + 60973.0, + 56655.0, + 58567.0, + 61070.0, + 61328.0, + 51120.0, + 43785.0, + 40346.0, + 39921.0, + 41839.0, + 41800.0, + 49091.0, + 56429.0, + 45495.0, + 47436.0, + 45176.0, + 39472.0, + 34635.0, + 32496.0, + 32153.0, + 31971.0, + 30699.0, + 27543.0, + 27212.0, + 27185.0, + 26898.0, + 25509.0, + 23054.0, + 21988.0, + 20974.0, + 20840.0, + 21352.0, + 22167.0, + 22824.0, + 23390.0, + 21434.0, + 19409.0, + 19501.0, + 17778.0, + 16911.0, + 16282.0, + 15048.0, + 14324.0, + 12777.0, + 12761.0, + 11987.0, + 12302.0, + 12996.0, + 11311.0, + 10701.0, + 10476.0, + 10772.0, + 11175.0, + 10464.0, + 9862.0, + 9674.0, + 9268.0, + 8431.0, + 8092.0, + 7714.0, + 7345.0, + 7124.0, + 6816.0, + 6359.0, + 6075.0, + 5659.0, + 5196.0, + 5021.0, + 5578.0, + 4659.0, + 4552.0, + 4515.0, + 4179.0, + 4114.0, + 4091.0, + 4026.0, + 3886.0, + 80225.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 8897.0, + 20982.0, + 2381.0, + 436.0, + 26.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 200.0, + 874.0, + 289.0, + 129.0, + 231.0, + 343.0, + 547.0, + 544.0, + 640.0, + 730.0, + 1025.0, + 999.0, + 950.0, + 799.0, + 706.0, + 604.0, + 520.0, + 736.0, + 938.0, + 1075.0, + 501.0, + 406.0, + 406.0, + 490.0, + 460.0, + 442.0, + 383.0, + 451.0, + 388.0, + 491.0, + 331.0, + 471.0, + 474.0, + 476.0, + 440.0, + 440.0, + 471.0, + 410.0, + 365.0, + 399.0, + 391.0, + 372.0, + 372.0, + 366.0, + 349.0, + 301.0, + 328.0, + 375.0, + 321.0, + 239.0, + 287.0, + 281.0, + 224.0, + 221.0, + 182.0, + 167.0, + 164.0, + 160.0, + 151.0, + 158.0, + 142.0, + 156.0, + 182.0, + 147.0, + 166.0, + 154.0, + 156.0, + 168.0, + 221.0, + 134.0, + 87.0, + 114.0, + 84.0, + 83.0, + 74.0, + 60.0, + 51.0, + 48.0, + 49.0, + 53.0, + 68.0, + 49.0, + 65.0, + 32.0, + 53.0, + 54.0, + 72.0, + 51.0, + 49.0, + 41.0, + 39.0, + 27.0, + 24.0, + 20.0, + 20.0, + 23.0, + 23.0, + 21.0, + 22.0, + 15.0, + 27.0, + 23.0, + 25.0, + 19.0, + 20.0, + 35.0, + 34.0, + 22.0, + 15.0, + 20.0, + 20.0, + 20.0, + 22.0, + 14.0, + 12.0, + 15.0, + 17.0, + 12.0, + 12.0, + 12.0, + 15.0, + 9.0, + 6.0, + 14.0, + 237.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 424049.0, + 4454.0, + 3376.0, + 1.0, + 2.0, + 2.0, + 1.0, + 5.0, + 19.0, + 29.0, + 152.0, + 2965.0, + 22147.0, + 11147.0, + 12539.0, + 35225.0, + 23441.0, + 16433.0, + 17054.0, + 14236.0, + 11446.0, + 11800.0, + 13041.0, + 16458.0, + 36326.0, + 23471.0, + 11786.0, + 13116.0, + 12559.0, + 17059.0, + 28576.0, + 37730.0, + 21623.0, + 15573.0, + 25818.0, + 42270.0, + 23143.0, + 22167.0, + 22938.0, + 20904.0, + 15616.0, + 15257.0, + 15168.0, + 15983.0, + 30633.0, + 15509.0, + 15382.0, + 16203.0, + 19834.0, + 18442.0, + 18711.0, + 20439.0, + 26210.0, + 30553.0, + 23153.0, + 15898.0, + 14485.0, + 14229.0, + 14586.0, + 15656.0, + 14154.0, + 14041.0, + 12392.0, + 11778.0, + 11570.0, + 13043.0, + 13374.0, + 11687.0, + 10322.0, + 9345.0, + 9783.0, + 10422.0, + 10992.0, + 11425.0, + 10898.0, + 10092.0, + 9296.0, + 8687.0, + 7967.0, + 7355.0, + 6719.0, + 6274.0, + 6607.0, + 5791.0, + 5582.0, + 5526.0, + 5178.0, + 5195.0, + 5496.0, + 4716.0, + 4477.0, + 4117.0, + 4115.0, + 4075.0, + 4092.0, + 4178.0, + 3947.0, + 3473.0, + 3372.0, + 3069.0, + 3027.0, + 2843.0, + 2815.0, + 2445.0, + 2248.0, + 2247.0, + 2182.0, + 2239.0, + 2159.0, + 2066.0, + 1813.0, + 1787.0, + 1707.0, + 1669.0, + 1733.0, + 1635.0, + 1610.0, + 1463.0, + 1416.0, + 1378.0, + 1373.0, + 1170.0, + 1146.0, + 1147.0, + 993.0, + 932.0, + 910.0, + 828.0, + 741.0, + 848.0, + 723.0, + 720.0, + 625.0, + 620.0, + 660.0, + 11151.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 6.0, + 3.0, + 34.0, + 11.0, + 31.0, + 53.0, + 15.0, + 18.0, + 4.0, + 9.0, + 12.0, + 4.0, + 6.0, + 42.0, + 36.0, + 18.0, + 7.0, + 10.0, + 5.0, + 9.0, + 21.0, + 16.0, + 6.0, + 7.0, + 64.0, + 30.0, + 16.0, + 28.0, + 14.0, + 15.0, + 15.0, + 6.0, + 15.0, + 44.0, + 6.0, + 8.0, + 0.0, + 33.0, + 26.0, + 15.0, + 10.0, + 16.0, + 18.0, + 71.0, + 23.0, + 11.0, + 13.0, + 17.0, + 19.0, + 14.0, + 19.0, + 8.0, + 5.0, + 20.0, + 13.0, + 22.0, + 20.0, + 13.0, + 20.0, + 10.0, + 3.0, + 6.0, + 0.0, + 5.0, + 10.0, + 1.0, + 2.0, + 7.0, + 3.0, + 8.0, + 6.0, + 8.0, + 6.0, + 6.0, + 8.0, + 0.0, + 6.0, + 0.0, + 5.0, + 9.0, + 3.0, + 8.0, + 8.0, + 2.0, + 1.0, + 3.0, + 1.0, + 1.0, + 1.0, + 3.0, + 4.0, + 2.0, + 1.0, + 3.0, + 3.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 2.0, + 2.0, + 0.0, + 3.0, + 1.0, + 1.0, + 0.0, + 13.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 892.0, + 13.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 1.0, + 12.0, + 9.0, + 12.0, + 22.0, + 24.0, + 21.0, + 34.0, + 12.0, + 7.0, + 21.0, + 51.0, + 35.0, + 44.0, + 65.0, + 49.0, + 94.0, + 79.0, + 98.0, + 97.0, + 59.0, + 74.0, + 75.0, + 71.0, + 101.0, + 96.0, + 140.0, + 44.0, + 68.0, + 35.0, + 45.0, + 52.0, + 49.0, + 76.0, + 136.0, + 72.0, + 55.0, + 44.0, + 30.0, + 57.0, + 71.0, + 61.0, + 99.0, + 67.0, + 48.0, + 35.0, + 42.0, + 46.0, + 22.0, + 28.0, + 31.0, + 39.0, + 31.0, + 32.0, + 34.0, + 32.0, + 25.0, + 24.0, + 25.0, + 21.0, + 28.0, + 15.0, + 19.0, + 16.0, + 22.0, + 20.0, + 8.0, + 14.0, + 20.0, + 19.0, + 18.0, + 19.0, + 16.0, + 19.0, + 15.0, + 16.0, + 11.0, + 8.0, + 5.0, + 8.0, + 9.0, + 7.0, + 9.0, + 7.0, + 6.0, + 9.0, + 19.0, + 10.0, + 10.0, + 11.0, + 14.0, + 16.0, + 17.0, + 6.0, + 10.0, + 10.0, + 2.0, + 2.0, + 7.0, + 3.0, + 6.0, + 2.0, + 5.0, + 2.0, + 2.0, + 8.0, + 2.0, + 2.0, + 1.0, + 5.0, + 2.0, + 5.0, + 3.0, + 4.0, + 1.0, + 1.0, + 4.0, + 2.0, + 7.0, + 2.0, + 2.0, + 3.0, + 1.0, + 29.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 29.0, + 165.0, + 116.0, + 160.0, + 57.0, + 87.0, + 140.0, + 11.0, + 0.0, + 3.0, + 0.0, + 0.0, + 6.0, + 11.0, + 5.0, + 1.0, + 4.0, + 10.0, + 7.0, + 5.0, + 3.0, + 2.0, + 11.0, + 2.0, + 2.0, + 4.0, + 2.0, + 11.0, + 6.0, + 10.0, + 4.0, + 8.0, + 7.0, + 9.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 3.0, + 3.0, + 2.0, + 1.0, + 7.0, + 7.0, + 2.0, + 1.0, + 1.0, + 4.0, + 4.0, + 1.0, + 2.0, + 2.0, + 1.0, + 4.0, + 1.0, + 0.0, + 0.0, + 2.0, + 2.0, + 2.0, + 1.0, + 0.0, + 1.0, + 0.0, + 2.0, + 1.0, + 1.0, + 0.0, + 3.0, + 0.0, + 2.0, + 1.0, + 1.0, + 1.0, + 2.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 2.0, + 0.0, + 0.0, + 0.0, + 0.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, + 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, + 528.0, + 139.0, + 45.0, + 3.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 42.0, + 68.0, + 32.0, + 12.0, + 17.0, + 21.0, + 17.0, + 38.0, + 34.0, + 42.0, + 31.0, + 35.0, + 31.0, + 52.0, + 38.0, + 50.0, + 57.0, + 57.0, + 41.0, + 45.0, + 59.0, + 42.0, + 42.0, + 55.0, + 27.0, + 27.0, + 29.0, + 27.0, + 21.0, + 31.0, + 24.0, + 8.0, + 21.0, + 34.0, + 17.0, + 13.0, + 21.0, + 17.0, + 25.0, + 30.0, + 63.0, + 75.0, + 87.0, + 67.0, + 39.0, + 48.0, + 47.0, + 23.0, + 10.0, + 16.0, + 14.0, + 7.0, + 11.0, + 9.0, + 17.0, + 44.0, + 9.0, + 5.0, + 11.0, + 3.0, + 12.0, + 11.0, + 20.0, + 11.0, + 5.0, + 4.0, + 7.0, + 6.0, + 6.0, + 3.0, + 5.0, + 3.0, + 10.0, + 9.0, + 13.0, + 7.0, + 9.0, + 9.0, + 6.0, + 7.0, + 5.0, + 0.0, + 2.0, + 0.0, + 3.0, + 1.0, + 2.0, + 1.0, + 0.0, + 1.0, + 3.0, + 0.0, + 3.0, + 1.0, + 2.0, + 3.0, + 1.0, + 2.0, + 1.0, + 1.0, + 3.0, + 0.0, + 1.0, + 3.0, + 2.0, + 1.0, + 1.0, + 2.0, + 5.0, + 2.0, + 0.0, + 4.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 1.0, + 0.0, + 19.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 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, + 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, + 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, + 1.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 12.0, + 1672.0, + 168.0, + 2.0, + 1.0, + 0.0, + 1.0, + 20.0, + 114.0, + 520.0, + 9822.0, + 10533.0, + 606.0, + 723.0, + 915.0, + 403.0, + 614.0, + 1071.0, + 1120.0, + 700.0, + 880.0, + 960.0, + 1891.0, + 3342.0, + 1879.0, + 1048.0, + 699.0, + 808.0, + 1578.0, + 1483.0, + 1544.0, + 1588.0, + 1472.0, + 2846.0, + 4619.0, + 1902.0, + 2279.0, + 1432.0, + 1368.0, + 1156.0, + 1080.0, + 1296.0, + 2107.0, + 2330.0, + 1209.0, + 1332.0, + 1575.0, + 1359.0, + 1050.0, + 1369.0, + 1431.0, + 1399.0, + 1233.0, + 1058.0, + 884.0, + 740.0, + 830.0, + 998.0, + 1020.0, + 883.0, + 730.0, + 697.0, + 659.0, + 631.0, + 857.0, + 1103.0, + 683.0, + 502.0, + 519.0, + 607.0, + 582.0, + 682.0, + 824.0, + 466.0, + 394.0, + 321.0, + 441.0, + 367.0, + 292.0, + 270.0, + 247.0, + 254.0, + 317.0, + 327.0, + 326.0, + 247.0, + 242.0, + 226.0, + 210.0, + 230.0, + 185.0, + 197.0, + 198.0, + 188.0, + 160.0, + 146.0, + 166.0, + 149.0, + 129.0, + 131.0, + 115.0, + 114.0, + 77.0, + 103.0, + 95.0, + 97.0, + 90.0, + 152.0, + 92.0, + 98.0, + 85.0, + 70.0, + 81.0, + 86.0, + 73.0, + 79.0, + 65.0, + 32.0, + 69.0, + 64.0, + 36.0, + 33.0, + 38.0, + 30.0, + 32.0, + 36.0, + 22.0, + 43.0, + 38.0, + 37.0, + 546.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 6.0, + 1.0, + 7.0, + 0.0, + 0.0, + 0.0, + 1.0, + 1.0, + 0.0, + 8.0, + 3.0, + 1.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 0.0, + 0.0, + 1.0, + 7.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 26.0, + 0.0, + 2.0, + 3.0, + 6.0, + 2.0, + 2.0, + 0.0, + 0.0, + 1.0, + 6.0, + 7.0, + 7.0, + 1.0, + 0.0, + 0.0, + 2.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 2.0, + 0.0, + 0.0, + 3.0, + 4.0, + 3.0, + 2.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 5.0, + 3.0, + 0.0, + 0.0, + 0.0, + 1.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, + 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, + 0.0, + 1.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 6.0, + 13.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 4.0, + 4.0, + 3.0, + 10.0, + 38.0, + 43.0, + 8.0, + 3.0, + 5.0, + 8.0, + 8.0, + 9.0, + 5.0, + 15.0, + 5.0, + 4.0, + 2.0, + 3.0, + 7.0, + 5.0, + 2.0, + 3.0, + 6.0, + 3.0, + 4.0, + 5.0, + 17.0, + 11.0, + 4.0, + 1.0, + 3.0, + 2.0, + 0.0, + 1.0, + 3.0, + 4.0, + 2.0, + 0.0, + 1.0, + 1.0, + 1.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 4.0, + 3.0, + 1.0, + 0.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 3.0, + 2.0, + 0.0, + 0.0, + 2.0, + 2.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 2.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 5.0, + 0.0, + 0.0, + 1.0, + 2.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, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 4.0, + 1.0, + 0.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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ] + ], + [ + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 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, + 2.0, + 6.0, + 26.0, + 11.0, + 0.0, + 2.0, + 3.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 2.0, + 0.0, + 1.0, + 0.0, + 1.0, + 0.0, + 1.0, + 2.0, + 8.0, + 1.0, + 2.0, + 3.0, + 0.0, + 2.0, + 3.0, + 1.0, + 2.0, + 2.0, + 2.0, + 5.0, + 3.0, + 1.0, + 3.0, + 2.0, + 1.0, + 1.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 1.0, + 1.0, + 4.0, + 3.0, + 2.0, + 14.0, + 15.0, + 9.0, + 11.0, + 4.0, + 1.0, + 3.0, + 0.0, + 0.0, + 0.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, + 2.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, + 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, + 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 + ] + ] + ], + "radius_of_gyration_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, + 1.0, + 22.0, + 79.0, + 205.0, + 444.0, + 739.0, + 1077.0, + 1534.0, + 2206.0, + 3062.0, + 3951.0, + 5789.0, + 8726.0, + 13463.0, + 19912.0, + 27670.0, + 36776.0, + 46412.0, + 56264.0, + 63783.0, + 70598.0, + 74637.0, + 77961.0, + 79670.0, + 79595.0, + 76829.0, + 72607.0, + 68535.0, + 64334.0, + 59786.0, + 54639.0, + 50151.0, + 44591.0, + 40056.0, + 35421.0, + 30957.0, + 27549.0, + 23993.0, + 21039.0, + 18330.0, + 15958.0, + 13645.0, + 12029.0, + 10210.0, + 8767.0, + 7607.0, + 6534.0, + 5292.0, + 4701.0, + 3978.0, + 3207.0, + 2666.0, + 2313.0, + 2041.0, + 1699.0, + 1500.0, + 1302.0, + 1059.0, + 912.0, + 730.0, + 617.0, + 574.0, + 474.0, + 431.0, + 352.0, + 2017.0 + ] +} diff --git a/chembl3d/implicit_h/distributions.pt b/chembl3d/implicit_h/distributions.pt new file mode 100644 index 0000000000000000000000000000000000000000..eef3622526fbc9f1d7f77dfd9e44e47b1a57457a --- /dev/null +++ b/chembl3d/implicit_h/distributions.pt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba4101ee7f3284dd2087b15573f1730aaa70ee808d874ced396fcd64d55a66d5 +size 89925 diff --git a/chembl3d/implicit_h/edge_tokens.txt b/chembl3d/implicit_h/edge_tokens.txt new file mode 100644 index 0000000000000000000000000000000000000000..59a3b678b20991c4db5881d2f6b9f0f2fc214357 --- /dev/null +++ b/chembl3d/implicit_h/edge_tokens.txt @@ -0,0 +1,4 @@ + +1 +2 +3 diff --git a/chembl3d/implicit_h/epoch_100.ckpt b/chembl3d/implicit_h/epoch_100.ckpt new file mode 100644 index 0000000000000000000000000000000000000000..5db398b10424cfb155592a3978833a04a08ccca3 --- /dev/null +++ b/chembl3d/implicit_h/epoch_100.ckpt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df34096b7b25342afb1c429cc6c491a0bc1b121f6735f411cd70acb2ac9b747a +size 509282633 diff --git a/chembl3d/implicit_h/epoch_200.ckpt b/chembl3d/implicit_h/epoch_200.ckpt new file mode 100644 index 0000000000000000000000000000000000000000..35b5513a9f0e233b82918909db3c225823edec6b --- /dev/null +++ b/chembl3d/implicit_h/epoch_200.ckpt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5e13c261bff867ecb376df8fe369e85bafb4935ecaa343c5da83f6820458112 +size 509282633 diff --git a/chembl3d/implicit_h/epoch_300.ckpt b/chembl3d/implicit_h/epoch_300.ckpt new file mode 100644 index 0000000000000000000000000000000000000000..9e03887ae4c754c461b39d1f7eb8642a3c58f187 --- /dev/null +++ b/chembl3d/implicit_h/epoch_300.ckpt @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ec003ebc2e6412b3fcfdf05b9de145f411d602974d5f83b08c55e2e74742184e +size 509282633 diff --git a/chembl3d/implicit_h/metadata.json b/chembl3d/implicit_h/metadata.json new file mode 100644 index 0000000000000000000000000000000000000000..681693dd66ef402e8221051aa9ea08ae23d2d9e9 --- /dev/null +++ b/chembl3d/implicit_h/metadata.json @@ -0,0 +1,70 @@ +{ + "dataset": "ChEMBL3D", + "variant": "implicit_h", + "includes_explicit_hydrogens": false, + "source_max_total_atoms_including_h": 100, + "training_run": "chembl3d-implicit-2x4090-15416351", + "source_snapshot": "chembl3d_implicit_20260928_v2", + "checkpoint_format": "Original Lightning checkpoint, current + EMA + optimizer state", + "epoch_numbering": "Completed epochs (one-based); original checkpoint epoch is zero-based", + "split_molecules": { + "train": 1474008, + "val": 184295, + "test": 184242 + }, + "checkpoints": [ + { + "filename": "epoch_100.ckpt", + "original_filename": "epoch=99-step=230300.ckpt", + "completed_epochs": 100, + "checkpoint_epoch_index": 99, + "bytes": 509282633, + "sha256": "df34096b7b25342afb1c429cc6c491a0bc1b121f6735f411cd70acb2ac9b747a", + "global_step": 230300 + }, + { + "filename": "epoch_200.ckpt", + "original_filename": "epoch=199-step=460600.ckpt", + "completed_epochs": 200, + "checkpoint_epoch_index": 199, + "bytes": 509282633, + "sha256": "b5e13c261bff867ecb376df8fe369e85bafb4935ecaa343c5da83f6820458112", + "global_step": 460600 + }, + { + "filename": "epoch_300.ckpt", + "original_filename": "epoch=299-step=690900.ckpt", + "completed_epochs": 300, + "checkpoint_epoch_index": 299, + "bytes": 509282633, + "sha256": "ec003ebc2e6412b3fcfdf05b9de145f411d602974d5f83b08c55e2e74742184e", + "global_step": 690900 + } + ], + "coordinate_std_angstrom": 2.5063276290893555, + "atom_tokens": [ + "B", + "Br", + "C", + "Cl", + "F", + "I", + "N", + "O", + "P", + "S", + "Se", + "Si" + ], + "edge_tokens": [ + "", + "1", + "2", + "3" + ], + "charge_tokens": [ + "-1", + "0", + "1" + ] +} diff --git a/chembl3d/implicit_h/runtime/README.md b/chembl3d/implicit_h/runtime/README.md new file mode 100644 index 0000000000000000000000000000000000000000..defa6c31ee5c5654e17c598606b99c5a912b90d5 --- /dev/null +++ b/chembl3d/implicit_h/runtime/README.md @@ -0,0 +1,3 @@ +# Morph runtime for ChEMBL3D implicit_h + +Frozen training source. See the model repository README and sample.py. diff --git a/chembl3d/implicit_h/runtime/configs/callbacks/callbacks.yaml b/chembl3d/implicit_h/runtime/configs/callbacks/callbacks.yaml new file mode 100644 index 0000000000000000000000000000000000000000..1fe407944f322f2e45a3f2789d65ac6dbcfbc112 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/data/chembl3d/chembl3d.yaml b/chembl3d/implicit_h/runtime/configs/data/chembl3d/chembl3d.yaml new file mode 100644 index 0000000000000000000000000000000000000000..216c77bf4e98d233c6a870596c13efebf3dd69cd --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/data/chembl3d/chembl3d.yaml @@ -0,0 +1,36 @@ +# LoQI Release 2 -> Morph LMDB, using the shared drug-like training setup. +defaults: + - /data/geom/geom@_here_ + - _self_ + +dataset_name: chembl3d +# Optional directory with the downloaded train_h.pt / val_h.pt / test_h.pt. +# When unset, missing files are downloaded to /raw/. +source_dir: ${oc.env:CHEMBL3D_SOURCE_DIR,null} +vocab: null + +preprocessing: + num_workers: ${data.preprocess_workers} + root: ${data.root_path}/chembl3d + +datamodule: + distributions: null + datasets: + train: + _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + source_dir: ${data.source_dir} + distributions: null + val: + - _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + source_dir: ${data.source_dir} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: null + test: + - _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + source_dir: ${data.source_dir} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: null diff --git a/chembl3d/implicit_h/runtime/configs/data/chembl3d/implicit_h.yaml b/chembl3d/implicit_h/runtime/configs/data/chembl3d/implicit_h.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2b0dab79ce86c4491e5dffa455c39698e7d0cc30 --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/data/chembl3d/implicit_h.yaml @@ -0,0 +1,25 @@ +# Same max100-total-atom molecule subset, represented using only heavy atoms. +defaults: + - /data/chembl3d/max100@_here_ + - _self_ + +hydrogen_mode: implicit +preprocessing: + root: ${data.root_path}/chembl3d_max100_implicit_h + +datamodule: + datasets: + train: + hydrogen_mode: ${data.hydrogen_mode} + val: + - _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + vocab: ${data.vocab} + distributions: null + hydrogen_mode: ${data.hydrogen_mode} + test: + - _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + vocab: ${data.vocab} + distributions: null + hydrogen_mode: ${data.hydrogen_mode} diff --git a/chembl3d/implicit_h/runtime/configs/data/chembl3d/max100.yaml b/chembl3d/implicit_h/runtime/configs/data/chembl3d/max100.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4fe5b881f2621e58c9e287ab6dcf8673114942ff --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/data/chembl3d/max100.yaml @@ -0,0 +1,8 @@ +# Dataset cache built by morph.dataset.unconditional.chembl3d_filter. +defaults: + - /data/chembl3d/chembl3d@_here_ + - _self_ + +max_total_atoms: 100 # Inclusive, with explicit hydrogens retained. +preprocessing: + root: ${data.root_path}/chembl3d_max100 diff --git a/chembl3d/implicit_h/runtime/configs/data/geom/geom.yaml b/chembl3d/implicit_h/runtime/configs/data/geom/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8e04bb92820a2c9b62523035b4cf80c4a3cb9af1 --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/data/geom/geom.yaml @@ -0,0 +1,113 @@ +## GEOM-Drugs data and preprocessing configuration. + +dataset_name: geom +root_path: ${oc.env:PROJECT_ROOT}/data +preprocess_workers: 10 + +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 + +# GEOM-Drugs contains charged species; allow them when scoring validity. +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}/geom + 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: 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} + # 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: None + representation: ${representation} + + datasets: + train: + _target_: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: None + + val: + - _target_: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: None + + test: + - _target_: morph.dataset.unconditional.geom.FlowMatchingGEOMDataset + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + 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: 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/chembl3d/implicit_h/runtime/configs/data/geom/scaffold_decoration.yaml b/chembl3d/implicit_h/runtime/configs/data/geom/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..784e3b44c463f4885489f28b09a956b01a5e9580 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/data/qm9/qm9.yaml b/chembl3d/implicit_h/runtime/configs/data/qm9/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..992049b566f18acb2744568adcf61e47ecfc2342 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/data/qm9/scaffold_decoration.yaml b/chembl3d/implicit_h/runtime/configs/data/qm9/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8c636cf25fc5a740906f6210a7430457794b92fe --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/default.yaml b/chembl3d/implicit_h/runtime/configs/default.yaml new file mode 100644 index 0000000000000000000000000000000000000000..62ac4294601048087b24a7825a283abf44a2d68e --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml b/chembl3d/implicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml new file mode 100644 index 0000000000000000000000000000000000000000..da60c3219bac2bd1cc23d5cf1eaca85084c2cb89 --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml @@ -0,0 +1,5 @@ +# @package _global_ +defaults: + - core + - /data: chembl3d/chembl3d + - /embeddings: geom diff --git a/chembl3d/implicit_h/runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml b/chembl3d/implicit_h/runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0def74794517afa39acf9e67716cd5dad3b65900 --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml @@ -0,0 +1,10 @@ +# @package _global_ +defaults: + - /defaults/chembl3d/train_2x4090 + - override /data: chembl3d/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/chembl3d/implicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml b/chembl3d/implicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c2297c46612a159be05b2a0e558fae9401845593 --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml @@ -0,0 +1,43 @@ +# @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 + runtime_monitor: + _target_: morph.utils.training_monitor.TrainingMonitor + every_n_batches: 100 + every_n_epochs_checkpoint: + every_n_epochs: 5 + save_top_k: -1 + save_last: link diff --git a/chembl3d/implicit_h/runtime/configs/defaults/core.yaml b/chembl3d/implicit_h/runtime/configs/defaults/core.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a8a7114d33bddc4ec3a5011213e925285a426e40 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/defaults/geom/geom.yaml b/chembl3d/implicit_h/runtime/configs/defaults/geom/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c3231855aa9a8e3551ca11a261e05949ebb2820b --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/defaults/geom/geom.yaml @@ -0,0 +1,6 @@ +# @package _global_ +# GEOM preset: shared core + GEOM data and embeddings. +defaults: + - core + - /data: geom/geom + - /embeddings: geom diff --git a/chembl3d/implicit_h/runtime/configs/defaults/geom/scaffold_decoration.yaml b/chembl3d/implicit_h/runtime/configs/defaults/geom/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..c2fd2d1ba1d5386433bc15d97cdbd0b77b04296b --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/defaults/qm9/qm9.yaml b/chembl3d/implicit_h/runtime/configs/defaults/qm9/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a43bdfbcd6bb9fb6964e0be073e11d28f2f399bc --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/defaults/qm9/scaffold_decoration.yaml b/chembl3d/implicit_h/runtime/configs/defaults/qm9/scaffold_decoration.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cf721f81b4422726df6382286e23c38787f75cce --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/embeddings/geom.yaml b/chembl3d/implicit_h/runtime/configs/embeddings/geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..51d9fda0862b44cff795661c2f1e91217c7743ab --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/embeddings/qm9.yaml b/chembl3d/implicit_h/runtime/configs/embeddings/qm9.yaml new file mode 100644 index 0000000000000000000000000000000000000000..4dcc81608316e2105165576aaf8d915e2317f97d --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/heads/head.yaml b/chembl3d/implicit_h/runtime/configs/heads/head.yaml new file mode 100644 index 0000000000000000000000000000000000000000..bef544d639b98055af99cb253ea8dc0a82486473 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/insertion_head/gmm.yaml b/chembl3d/implicit_h/runtime/configs/insertion_head/gmm.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5dedca7c0ff62a5d3fcfaf43de5aa79a161d813f --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/insertion_head/unimodal.yaml b/chembl3d/implicit_h/runtime/configs/insertion_head/unimodal.yaml new file mode 100644 index 0000000000000000000000000000000000000000..85148799b512c78531aa577a18d36bbdc70f9cba --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/integrator/integrator.yaml b/chembl3d/implicit_h/runtime/configs/integrator/integrator.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d0c167b0138fc54d8413743649543e4b2428c05a --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/logging/wandb_logger.yaml b/chembl3d/implicit_h/runtime/configs/logging/wandb_logger.yaml new file mode 100644 index 0000000000000000000000000000000000000000..162b88b38e78138334d36469b72bccb633924517 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/loss_weights/calibrated_geom.yaml b/chembl3d/implicit_h/runtime/configs/loss_weights/calibrated_geom.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ddc9b38bc200adc1832e091277ad3aeb88e89a5d --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/loss_weights/calibrated_geom_frozen.yaml b/chembl3d/implicit_h/runtime/configs/loss_weights/calibrated_geom_frozen.yaml new file mode 100644 index 0000000000000000000000000000000000000000..d0e9e3b6f7e1814dce089db7a5b9497f3e7fd860 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/loss_weights/tuned_weights.yaml b/chembl3d/implicit_h/runtime/configs/loss_weights/tuned_weights.yaml new file mode 100644 index 0000000000000000000000000000000000000000..681de9e73d6cd6836226366549221edb368be363 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/loss_weights/uniform.yaml b/chembl3d/implicit_h/runtime/configs/loss_weights/uniform.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fb279b4f7a159f8098ad5aa9b06059570949c270 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/model/backbones/dit.yaml b/chembl3d/implicit_h/runtime/configs/model/backbones/dit.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5393a940e3628f9d0b299eb543b8e619ad03adaf --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/model/finetune/lora_finetune.yaml b/chembl3d/implicit_h/runtime/configs/model/finetune/lora_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ae37397e74c5720f8f1c93835bc67e71f6157b29 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/model/finetune/scaffold_finetune.yaml b/chembl3d/implicit_h/runtime/configs/model/finetune/scaffold_finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..02cb1b117c555e97a2f2e5296403900fd09871b3 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/optimizer/muon_aux_adam.yaml b/chembl3d/implicit_h/runtime/configs/optimizer/muon_aux_adam.yaml new file mode 100644 index 0000000000000000000000000000000000000000..94cf30139d2d0799df91519152dc7a18c6380355 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/schedule/beta.yaml b/chembl3d/implicit_h/runtime/configs/schedule/beta.yaml new file mode 100644 index 0000000000000000000000000000000000000000..282746a14cd54cfa9b5560340f90cde6d8762f2f --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/schedule/cubic.yaml b/chembl3d/implicit_h/runtime/configs/schedule/cubic.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fd5cf2d9e5dcb3213e5f25acddbf86209620219a --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/schedule/cubic.yaml @@ -0,0 +1 @@ +_target_: morph.flow_matching.schedules.CubicSchedule diff --git a/chembl3d/implicit_h/runtime/configs/schedule/fast_power.yaml b/chembl3d/implicit_h/runtime/configs/schedule/fast_power.yaml new file mode 100644 index 0000000000000000000000000000000000000000..21757bb0f0037500dc106d10f3a01b1fd4655e22 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/configs/schedule/linear.yaml b/chembl3d/implicit_h/runtime/configs/schedule/linear.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f63c2e226275c32dc406db96f21c3aa488054a80 --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/schedule/linear.yaml @@ -0,0 +1 @@ +_target_: morph.flow_matching.schedules.LinearSchedule diff --git a/chembl3d/implicit_h/runtime/configs/schedule/smoothstep.yaml b/chembl3d/implicit_h/runtime/configs/schedule/smoothstep.yaml new file mode 100644 index 0000000000000000000000000000000000000000..ed29d5013930dafd40a1090a40d08b638976e3ba --- /dev/null +++ b/chembl3d/implicit_h/runtime/configs/schedule/smoothstep.yaml @@ -0,0 +1,2 @@ +_target_: morph.flow_matching.schedules.SmoothstepSchedule +shift: 0.8 diff --git a/chembl3d/implicit_h/runtime/configs/trainer/trainer.yaml b/chembl3d/implicit_h/runtime/configs/trainer/trainer.yaml new file mode 100644 index 0000000000000000000000000000000000000000..a894707dd858be394921733728c16e523ed8810b --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/pyproject.toml b/chembl3d/implicit_h/runtime/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..4ce2767599273c900bf54add69d218f31baedea0 --- /dev/null +++ b/chembl3d/implicit_h/runtime/pyproject.toml @@ -0,0 +1,136 @@ +[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", + "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/chembl3d/implicit_h/runtime/requirements-tested.txt b/chembl3d/implicit_h/runtime/requirements-tested.txt new file mode 100644 index 0000000000000000000000000000000000000000..a850aee476957f3d21efdd6575f1cef7f0c8064f --- /dev/null +++ b/chembl3d/implicit_h/runtime/requirements-tested.txt @@ -0,0 +1,19 @@ +torch==2.9.0 +numpy==2.4.2 +rdkit==2025.9.6 +lightning==2.6.1 +pytorch-lightning==2.6.1 +torch-geometric==2.7.0 +torchmetrics==1.8.2 +hydra-core==1.3.2 +omegaconf==2.3.0 +scipy==1.17.1 +pandas==3.0.1 +wandb==0.25.0 +posebusters==0.6.5 +matplotlib==3.10.9 +lmdb==2.2.0 +click==8.3.1 +py3dmol==2.5.4 +ipykernel==7.2.0 +ase==3.28.0 diff --git a/chembl3d/implicit_h/runtime/src/morph/__init__.py b/chembl3d/implicit_h/runtime/src/morph/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..320a78e02ba300f6433f6b0131e073b0517f6353 --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/__init__.py @@ -0,0 +1 @@ +"""morph — flow-matching generative modeling for molecules and materials.""" diff --git a/chembl3d/implicit_h/runtime/src/morph/__main__.py b/chembl3d/implicit_h/runtime/src/morph/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..bfc34c1fb7a9b7c2a1f0ec010fc40eb07495c7b1 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/_vendor/__init__.py b/chembl3d/implicit_h/runtime/src/morph/_vendor/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..24bba35366c470be8093d2f9f133cb384a991740 --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/_vendor/__init__.py @@ -0,0 +1 @@ +"""Third-party reference code kept separate from Morph's public API.""" diff --git a/chembl3d/implicit_h/runtime/src/morph/_vendor/geom_drugs_preprocessing.py b/chembl3d/implicit_h/runtime/src/morph/_vendor/geom_drugs_preprocessing.py new file mode 100644 index 0000000000000000000000000000000000000000..b6037a4822dc690f1913505d661da1e11a488d96 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/_vendor/muon.py b/chembl3d/implicit_h/runtime/src/morph/_vendor/muon.py new file mode 100644 index 0000000000000000000000000000000000000000..92e596caf3d6194a5829602568048ea0680f6893 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/cli.py b/chembl3d/implicit_h/runtime/src/morph/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..0d558802812799bf5468090730771d6cfadb86cc --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/cli.py @@ -0,0 +1,133 @@ +# 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 + +The model registry is intentionally empty until a checkpoint has been trained +with the current architecture. Conditional scaffold subcommands (dataset-free, +molecule/scaffold+mask driven) are coming next. +""" + +from __future__ import annotations + +import click + + +@click.group() +@click.version_option(package_name="morph") +def cli() -> None: + """Morph — molecular generation CLI.""" + + +@cli.command() +@click.option( + "-b", + "--batch", + "n_mols", + default=16, + show_default=True, + help="Number of molecules to generate.", +) +@click.option( + "-o", + "--output", + type=click.Path(), + show_default=True, + default="eval_outputs/geom_denovo/denovo.sdf", + help="Output SDF of final-frame molecules (3D coords + SMILES/logP).", +) +@click.option( + "--model", + "model_name", + default=None, + help="Named current-architecture model from the registry.", +) +@click.option( + "--weights-dir", + default=None, + help="Downloaded bundle dir (checkpoint + vocab + dist).", +) +@click.option( + "--checkpoint", + default=None, + help="Override the checkpoint path (else the bundle's default file).", +) +@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, +) -> None: + """Generate molecules de-novo with an unconditional checkpoint. + + Current and EMA models compile by default. Set MORPH_COMPILE=0 to disable. + """ + from pathlib import Path + + import torch + + from morph.inference.generate import generate_denovo, record_mol, write_sdf + from morph.inference.registry import resolve + + m = resolve("generate", model_name, checkpoint, weights_dir) + click.echo( + f"[morph generate] model={m['name']} ckpt={m['checkpoint']} n={n_mols}" + ) + + trajectories, is_valid, vocab, _coord_std = generate_denovo( + m["checkpoint"], + m["overrides"], + 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/chembl3d/implicit_h/runtime/src/morph/config.py b/chembl3d/implicit_h/runtime/src/morph/config.py new file mode 100644 index 0000000000000000000000000000000000000000..da5bb482ee9d174a998bdb034503cd5cb02b8741 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/__init__.py b/chembl3d/implicit_h/runtime/src/morph/dataset/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..10e5dc5a50476b29d2ae1d166f14155adb49730d --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/__init__.py b/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d905b6cdbea624870c29d8776265d212acf02755 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/__init__.py b/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..7cf1ec8108fa1e6beef793cfdc5adf792c1c941d --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/cutters.py b/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/cutters.py new file mode 100644 index 0000000000000000000000000000000000000000..de0a5d2d1b24fc710e7d6164e2c176afdfe5f1a1 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/datamodule.py b/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/datamodule.py new file mode 100644 index 0000000000000000000000000000000000000000..2cd77ef47e018297b248a50fccf4850ab70ec741 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/lmdb.py b/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/lmdb.py new file mode 100644 index 0000000000000000000000000000000000000000..669931f4d3ca0400cb18c5596bc1435ef8b4d3bb --- /dev/null +++ b/chembl3d/implicit_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.unconditional.geom 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/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/wrappers.py b/chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/wrappers.py new file mode 100644 index 0000000000000000000000000000000000000000..8cc5ad15e367400a5cffbd36b61e05e856546c57 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/core/__init__.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a62d4083497c96476cf11f482ad769736b7a8e6d --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/core/atomic_data.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/atomic_data.py new file mode 100644 index 0000000000000000000000000000000000000000..776461c5440a93e54ace90df1713bcdb505886db --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/core/batch.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/batch.py new file mode 100644 index 0000000000000000000000000000000000000000..74b1069b098f3fc9f52ed0f3efdfe1aaf64cf1ed --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/core/containers.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/containers.py new file mode 100644 index 0000000000000000000000000000000000000000..a2f874b20aebb359a838b7541c619f2a32c7e18b --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/dataset/core/containers.py @@ -0,0 +1,516 @@ +"""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 = 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): + """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() + 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() + 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/chembl3d/implicit_h/runtime/src/morph/dataset/core/flow_matching_wrapper.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/flow_matching_wrapper.py new file mode 100644 index 0000000000000000000000000000000000000000..7fa8efe43e66c34041cdb98ba9e1201ea42047a2 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/core/insertion_data.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/insertion_data.py new file mode 100644 index 0000000000000000000000000000000000000000..d8415a4e5bd055330292aab3a0f59dec913cc372 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/core/insertion_targets.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/insertion_targets.py new file mode 100644 index 0000000000000000000000000000000000000000..e0d32eef34d675e0ae42db78aacee9e8ecf9dd40 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/core/lightning_datamodule.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/lightning_datamodule.py new file mode 100644 index 0000000000000000000000000000000000000000..339b1def0e98ea033858e2bf4ada4e338ce27ed7 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/core/preprocessing.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/preprocessing.py new file mode 100644 index 0000000000000000000000000000000000000000..49704e0c9b7e1aee7c0db28839be688141a722df --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/dataset/core/preprocessing.py @@ -0,0 +1,542 @@ +"""Preprocessing class for calculating token distributions from datasets.""" + +import importlib +import os +import tempfile +from multiprocessing import get_context + +import torch +from torch_geometric.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) + worker = object.__new__(Preprocessing) + worker.train_dataset = dataset + worker.vocab = vocab + _PREPROCESSING_WORKER = worker + + +def _preprocessing_chunk(task): + method, indices = task + return getattr(_PREPROCESSING_WORKER, method)(indices) + + +class Preprocessing: + """ + Preprocessing class that computes token distributions from a training dataset. + + This class should be instantiated before the datamodule to compute distributions + from the training dataset only. The computed tokens and distributions are then + passed to the datamodule and model. + + Tokens are discovered from the data and saved to a file. On subsequent runs, + tokens are loaded from the file if it exists. + """ + + # Special tokens that must always be present + 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, + ): + """ + Initialize preprocessing. + + Args: + root: Root directory path for the dataset + train_dataset: Training dataset instance or Hydra config for lazy + instantiation. It is only instantiated when preprocessing files are + missing. + atom_tokens_path: Path to save/load atom tokens. + If None, uses root/atom_tokens.txt + edge_tokens_path: Path to save/load edge tokens. + If None, uses root/edge_tokens.txt + charge_tokens_path: Path to save/load charge tokens. + If None, uses root/charge_tokens.txt + distributions_path: Path to save/load distributions. + If None, uses root/distributions.pt + num_workers: Processes for vocabulary and training statistics. + """ + if num_workers < 0: + raise ValueError("num_workers must be non-negative") + self.num_workers = num_workers + self.root = root + self._train_dataset_cfg = train_dataset + self.train_dataset = None + + # Set default tokens path if not provided + if atom_tokens_path is None: + atom_tokens_path = os.path.join(self.root, "atom_tokens.txt") + + # Set default edge tokens path if not provided + if edge_tokens_path is None: + edge_tokens_path = os.path.join(self.root, "edge_tokens.txt") + + # Set default charge tokens path if not provided + if charge_tokens_path is None: + charge_tokens_path = os.path.join(self.root, "charge_tokens.txt") + + # Set default distributions path if not provided + if distributions_path is None: + distributions_path = os.path.join(self.root, "distributions.pt") + + self.atom_tokens_path = atom_tokens_path + self.edge_tokens_path = edge_tokens_path + self.charge_tokens_path = charge_tokens_path + self.distributions_path = 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() + + # Load or compute tokens (both computed together if either is missing) + self.vocab = None + self.vocab = self._load_or_compute_tokens() + self.distributions = self._load_or_compute_distributions( + force=not self._artifacts_were_complete + ) + + # remove train dataset to free memory + 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 tokens from files if they exist, + otherwise compute both from data in a single loop and save. + + Returns: + Vocab object + """ + # Try to load both from files + 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: + # Compute both from data in a single loop + 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) + + token_dict = { + "atom_tokens": atom_tokens, + "edge_tokens": edge_tokens, + "charge_tokens": charge_tokens, + } + + vocab = Vocab(**token_dict) + return vocab + + def _compute_tokens_from_data( + self, + ) -> tuple[list[str], list[str], list[str]]: + """ + Compute both tokens and edge tokens by extracting unique types + from training data in a single loop. + + Returns: + Tuple of (tokens, edge_tokens, charge_tokens) + """ + 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: + yield getattr(self, method)(range(length)) + return + workers = min(workers, length) + chunk_size = min(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 _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 _save_tokens(self, path: str, tokens: list[str]): + """Save tokens to file. + + Args: + path: Path to the tokens file + tokens: List of tokens to save + """ + 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]: + """Load tokens from file. + + Args: + path: Path to the tokens file + + Returns: + List of tokens loaded from file + """ + 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 distributions from file if exists, otherwise compute from data and save. + + Returns: + Distributions object + """ + # Try to load from file + if os.path.exists(self.distributions_path) and not force: + distributions = self._load_distributions() + else: + # Compute distributions from data + distributions = self._compute_distributions() + + # Save distributions to file + self._save_distributions(distributions) + + distributions = Distributions(**distributions) + return distributions + + 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 ( + hasattr(data, "edge_attr") + and data.edge_attr is not None + and data.edge_attr.numel() > 0 + ): + 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, + } + + 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: + 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/chembl3d/implicit_h/runtime/src/morph/dataset/core/properties.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/properties.py new file mode 100644 index 0000000000000000000000000000000000000000..c606bbda163ca46a357d71817de2515485dcb480 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/core/representation.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/representation.py new file mode 100644 index 0000000000000000000000000000000000000000..ccfde2cf7c47e9e77338016fa8f8a4269ff8b4a4 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/core/tokenized_decode.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/tokenized_decode.py new file mode 100644 index 0000000000000000000000000000000000000000..7b29af15aa4402cb83ce5da7b1224327458f24bb --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/dataset/core/tokenized_decode.py @@ -0,0 +1,83 @@ +"""Decode stored QM9/GEOM 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.utils.vocab_utils import z_to_atom_types + + +class TokenizedDecodeMixin: + """Stored PyG ``Data`` -> token-index ``ChemAtomicData`` decode, shared by + :class:`FlowMatchingGEOMDataset` and :class:`FlowMatchingQM9Dataset`. + """ + + def _build_token_maps(self, vocab) -> None: + """Build tensor lookup tables for atomic numbers and formal charges.""" + 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 + + 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=self._z_to_atom_idx[data.z.long()], + e=data.edge_attr.long(), + c=self._charge_to_idx[data.charges.long() - self._charge_min], + edge_index=data.edge_index, + **self._build_conditions(data, index), + ) diff --git a/chembl3d/implicit_h/runtime/src/morph/dataset/core/vocab.py b/chembl3d/implicit_h/runtime/src/morph/dataset/core/vocab.py new file mode 100644 index 0000000000000000000000000000000000000000..af28186c1c928a0a56b35e4858f1ddaa525eeb8b --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/__init__.py b/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6bcfd2cf5eb8e220756c471312279d00685a1f32 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py b/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py new file mode 100644 index 0000000000000000000000000000000000000000..918771061065352a7d11493a93a928c6a29355a3 --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py @@ -0,0 +1,400 @@ +"""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 argparse +import hashlib +import json +import os +import tempfile +from multiprocessing import get_context +from pathlib import Path +from typing import TYPE_CHECKING +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.representation import Capabilities +from morph.dataset.core.tokenized_decode import TokenizedDecodeMixin +from morph.dataset.unconditional.geom import ( + GEOM, + mol_to_bytes, + read_lmdb_length, + write_bytes_to_lmdb, +) +from morph.utils.rdkit_utils import ( + BOND_IDX_MAP, + IDX_BOND_MAP, + mol_is_valid, + sanitize_mol_correctly, + smiles_from_mol, +) + +if TYPE_CHECKING: + from morph.dataset.core.vocab import Distributions, Vocab + + +# 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, +) +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) -> 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. + """ + 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]") + if (labels < 1).any() or (labels > 8).any(): + raise ValueError("Unknown LoQI edge label (expected physical 1--4/stereo 5--8)") + if (edges < 0).any() or (edges >= n_atoms).any(): + raise ValueError("Edge index outside the molecule") + + physical = labels <= 4 + 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() + if not mol_is_valid(mol, allow_charged=True): + return None + 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 + smiles = smiles_from_mol(mol, canonical=True) + if not smiles: + return None + + # RDKit cleanup can normalize resonance charges along with bond orders. + # Persist both from the same sanitized molecule (e.g. nitro and azide). + charges = torch.tensor( + [atom.GetFormalCharge() for atom in mol.GetAtoms()], dtype=torch.long + ) + pairs, bond_types = [], [] + for bond in mol.GetBonds(): + i, j = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + pairs.extend(((i, j), (j, i))) + bond_types.extend([BOND_IDX_MAP[bond.GetBondType()]] * 2) + edge_index = torch.tensor(pairs, dtype=torch.long).reshape(-1, 2).t() + edge_attr = torch.tensor(bond_types, dtype=torch.long) + order = (edge_index[0] * n_atoms + edge_index[1]).argsort() + return Data( + z=z, + pos=pos, + charges=charges, + edge_index=edge_index[:, order].contiguous(), + edge_attr=edge_attr[order], + smiles=smiles, + ) + + +def _convert_to_bytes(record: Data) -> bytes | None: + data = convert_record(record) + return None if data is None else mol_to_bytes(data) + + +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(GEOM): + """LoQI ChEMBL3D splits with the GEOM LMDB storage and lazy reader contract. + + ``source_dir`` may contain ``train_h.pt`` directly, or an extracted + ``processed/`` or ``chembl3d_stereo/processed/`` directory. Without it, + look under the dataset root and download missing splits into ``raw/``. + Converted splits are published atomically in ``processed/``. GEOM's + downloader, prefilter and molecule-size policy are not used. + """ + + def __init__( + self, + root: str, + split: str = "train", + vocab: Vocab | None = None, + distributions: Distributions | None = None, + preprocess_workers: int | None = None, + source_dir: str | None = None, + hydrogen_mode: str = "explicit", + ): + if split not in RELEASE_FILES: + raise ValueError( + f"Invalid split {split!r}; expected {tuple(RELEASE_FILES)}" + ) + if preprocess_workers is not None and preprocess_workers < 0: + raise ValueError("preprocess_workers must be non-negative") + if hydrogen_mode not in ("explicit", "implicit"): + raise ValueError("hydrogen_mode must be explicit or implicit") + self.hydrogen_mode = hydrogen_mode + manifest_path = Path(root) / "processed" / "preprocessing_summary.json" + manifest = ( + json.loads(manifest_path.read_text()) if manifest_path.is_file() else {} + ) + cached_mode = ( + "implicit" if manifest.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}" + ) + if ( + hydrogen_mode == "implicit" + and not (Path(root) / "processed" / f"{split}_data.lmdb").is_dir() + ): + raise FileNotFoundError( + "Build the implicit-H cache with chembl3d_implicit first" + ) + self.root = os.fspath(root) + self.split = split + self.vocab = vocab + self.distributions = distributions + self.preprocess_workers = preprocess_workers + self.source_dir = source_dir + 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 = None + self._txn = None + self._txn_pid = None + os.makedirs(self.processed_dir, exist_ok=True) + if not os.path.exists(self.lmdb_path): + self.download() + self.process() + self._length = read_lmdb_length(self.lmdb_path) + sidecar = Path(self.processed_dir) / f"{split}_smiles.txt" + if not sidecar.is_file(): + self._write_smiles_sidecar() + + 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) + workers = self.preprocess_workers or 0 + records = _iter_records(data, slices, length) + with tempfile.TemporaryDirectory( + prefix=f".{self.split}_", dir=self.processed_dir + ) as staging: + output = str(Path(staging) / "data.lmdb") + if workers > 1: + with get_context("spawn").Pool(workers) as pool: + results = pool.imap(_convert_to_bytes, records, chunksize=128) + written, failed = write_bytes_to_lmdb(results, output, total=length) + else: + written, failed = write_bytes_to_lmdb( + map(_convert_to_bytes, records), output, total=length + ) + if written == 0: + raise ValueError(f"No valid molecules in {source}") + os.rename(output, self.lmdb_path) + print(f"{self.split}: wrote {written} molecules; skipped {failed}") + + +class FlowMatchingChEMBL3DDataset(TokenizedDecodeMixin, ChEMBL3D): + """Decode ChEMBL3D LMDB records into Morph's flow-matching targets.""" + + CAPABILITIES = Capabilities(provides_charges=True, provides_topology=True) + + def __init__( + self, + root, + vocab: Vocab | None, + distributions: Distributions | None, + transform=None, + pre_transform=None, + split="train", + compute_properties: bool = False, + property_names: tuple[str, ...] = ("qed", "logp"), + preprocess_workers: int | None = None, + source_dir: str | None = None, + hydrogen_mode: str = "explicit", + ): + super().__init__( + root, + split=split, + vocab=vocab, + distributions=distributions, + preprocess_workers=preprocess_workers, + source_dir=source_dir, + hydrogen_mode=hydrogen_mode, + ) + if vocab is not None: + self._build_token_maps(vocab) + self._load_property_arrays(compute_properties, property_names, split) + + def __getitem__(self, index): + """Return tokenized atom, bond and charge labels and normalized 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): + """Return raw PyG fields for shared vocabulary/statistics preprocessing.""" + return super().__getitem__(index) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--root", default="data/chembl3d", help="Morph dataset output root" + ) + parser.add_argument("--source-dir", help="Existing LoQI release directory") + parser.add_argument( + "--splits", nargs="+", choices=tuple(RELEASE_FILES), default=list(RELEASE_FILES) + ) + parser.add_argument("--workers", type=int, default=0) + args = parser.parse_args() + if args.workers < 0: + parser.error("--workers must be non-negative") + for split in args.splits: + ChEMBL3D( + args.root, + split=split, + source_dir=args.source_dir, + preprocess_workers=args.workers, + ) + + +if __name__ == "__main__": + main() diff --git a/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d_filter.py b/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d_filter.py new file mode 100644 index 0000000000000000000000000000000000000000..7cee1c365c5a31975412707456d05b52f4da2734 --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d_filter.py @@ -0,0 +1,199 @@ +"""Create an atom-count-limited copy of an already processed ChEMBL3D dataset.""" + +from __future__ import annotations + +import argparse +import json +import os +import pickle +import tempfile +from collections import Counter +from multiprocessing import get_context +from pathlib import Path + +import numpy as np +import torch + +from morph.dataset.core.preprocessing import Preprocessing +from morph.dataset.unconditional.geom import ( + _int_key, + open_read_env, + read_lmdb_length, + write_bytes_to_lmdb, +) + +_FILTER_ENV = None +_FILTER_MAX_ATOMS = None + + +def _init_filter_worker(path, max_atoms): + global _FILTER_ENV, _FILTER_MAX_ATOMS # noqa: PLW0603 + torch.set_num_threads(1) + _FILTER_ENV = open_read_env(path) + _FILTER_MAX_ATOMS = max_atoms + + +def _select_chunk(task): + """Inspect all atoms, including H, without altering a retained record.""" + start, stop = task + indices, smiles, counts = [], [], Counter() + with _FILTER_ENV.begin() as txn: + for index in range(start, stop): + raw = txn.get(_int_key(index)) + if raw is None: + raise RuntimeError(f"Missing source record {index}") + record = pickle.loads(raw) + n = int(record["z"].numel()) + counts[n] += 1 + if n <= _FILTER_MAX_ATOMS: + indices.append(index) + smiles.append(record["smiles"]) + return indices, smiles, counts + + +def filter_split(source: Path, output: Path, split: str, max_atoms: int, workers: int): + """Filter in parallel, then copy eligible bytes in their original order.""" + source_lmdb = source / f"{split}_data.lmdb" + length = read_lmdb_length(str(source_lmdb)) + tasks = [(start, min(start + 4096, length)) for start in range(0, length, 4096)] + selected, smiles, counts = [], set(), Counter() + # This reader belongs only to the parent; spawned workers open their own. + env = open_read_env(str(source_lmdb)) + + def retained_records(results): + with env.begin() as txn: + for indices, chunk_smiles, histogram in results: + selected.extend(indices) + smiles.update(chunk_smiles) + counts.update(histogram) + for index in indices: + yield bytes(txn.get(_int_key(index))) + + try: + if workers > 1: + with get_context("spawn").Pool( + workers, + initializer=_init_filter_worker, + initargs=(str(source_lmdb), max_atoms), + ) as pool: + written, _ = write_bytes_to_lmdb( + retained_records(pool.imap(_select_chunk, tasks)), + str(output / f"{split}_data.lmdb"), + desc=f"Copying {split} <= {max_atoms} atoms", + ) + else: + # Reuse the parent env in serial mode (LMDB disallows a second open). + global _FILTER_ENV, _FILTER_MAX_ATOMS # noqa: PLW0603 + _FILTER_ENV, _FILTER_MAX_ATOMS = env, max_atoms + written, _ = write_bytes_to_lmdb( + retained_records(map(_select_chunk, tasks)), + str(output / f"{split}_data.lmdb"), + desc=f"Copying {split} <= {max_atoms} atoms", + ) + finally: + env.close() + if workers <= 1: + _FILTER_ENV = None + if written == 0: + raise ValueError(f"Cutoff removed every molecule in {split}") + assert written == len(selected) + assert sum(counts.values()) == length + (output / f"{split}_smiles.txt").write_text("\n".join(sorted(smiles))) + np.save( + output / f"{split}_source_indices.npy", np.asarray(selected, dtype=np.int64) + ) + return { + "source_molecules": length, + "retained": written, + "excluded": length - written, + "retained_fraction": written / length, + "source_atom_counts": dict(sorted(counts.items())), + "max_retained_atoms": max(n for n in counts if n <= max_atoms), + } + + +def filter_dataset(source_root: Path, root: Path, max_atoms: int, workers: int = 0): + """Publish all splits, sidecars and recomputed statistics atomically.""" + source_root, root = source_root.resolve(), root.resolve() + if max_atoms < 1 or workers < 0: + raise ValueError("max_atoms must be positive and workers non-negative") + if source_root == root: + raise ValueError("Filtered output must use a separate dataset root") + source, output = source_root / "processed", root / "processed" + fingerprint = {} + for split in ("train", "val", "test"): + path = source / f"{split}_data.lmdb" / "data.mdb" + stat = path.stat() + fingerprint[split] = {"bytes": stat.st_size, "mtime_ns": stat.st_mtime_ns} + identity = { + "source_root": str(source_root), + "source_files": fingerprint, + "max_total_atoms": max_atoms, + "includes_hydrogens": True, + } + if output.exists(): + summary_path = output / "preprocessing_summary.json" + summary = json.loads(summary_path.read_text()) if summary_path.is_file() else {} + if summary.get("status") == "ready" and all( + summary.get(k) == v for k, v in identity.items() + ): + print("Reusing completed filtered dataset", root, flush=True) + return summary + raise FileExistsError( + f"Output cache is incomplete or has a different cutoff/source: {output}" + ) + root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".filter_", dir=root) as temporary: + staging = Path(temporary) + processed = staging / "processed" + processed.mkdir() + splits = {} + for split in ("train", "val", "test"): + splits[split] = filter_split(source, processed, split, max_atoms, workers) + print(split, json.dumps(splits[split]), flush=True) + preprocessing = Preprocessing( + root=str(staging), + train_dataset={ + "_target_": ( + "morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset" + ), + "root": str(staging), + "vocab": None, + "distributions": None, + }, + atom_tokens_path=str(processed / "atom_tokens.txt"), + edge_tokens_path=str(processed / "edge_tokens.txt"), + charge_tokens_path=str(processed / "charge_tokens.txt"), + distributions_path=str(processed / "distributions.pt"), + num_workers=workers, + ) + support = preprocessing.distributions.n_atoms_distribution.nonzero().flatten() + assert int(support.max()) <= max_atoms + summary = { + **identity, + "status": "ready", + "job_id": os.environ.get("SLURM_JOB_ID"), + "molecules": {s: v["retained"] for s, v in splits.items()}, + "splits": splits, + "coordinate_std_A": float(preprocessing.distributions.coordinate_std), + } + (processed / "preprocessing_summary.json").write_text( + json.dumps(summary, indent=2) + "\n" + ) + os.rename(processed, output) + print(json.dumps(summary, indent=2), flush=True) + return summary + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--max-atoms", type=int, default=100) + parser.add_argument("--workers", type=int, default=0) + args = parser.parse_args() + filter_dataset(args.source_root, args.root, args.max_atoms, args.workers) + + +if __name__ == "__main__": + main() diff --git a/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d_implicit.py b/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d_implicit.py new file mode 100644 index 0000000000000000000000000000000000000000..b196aa03a6b8548c110c63d9ae8924f8734888ca --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d_implicit.py @@ -0,0 +1,319 @@ +"""Build a separate implicit-H ChEMBL cache from the filtered explicit-H splits.""" + +from __future__ import annotations + +import argparse +import json +import os +import pickle +import shutil +import tempfile +from collections import Counter +from multiprocessing import get_context +from pathlib import Path + +import torch +from rdkit import Chem +from rdkit.Chem import AllChem +from torch_geometric.data import Data + +from morph.dataset.core.preprocessing import Preprocessing +from morph.dataset.unconditional.geom import ( + _int_key, + mol_to_bytes, + open_read_env, + read_lmdb_length, + write_bytes_to_lmdb, +) +from morph.utils.rdkit_utils import ( + BOND_IDX_MAP, + IDX_BOND_MAP, + mol_is_valid, + tensors_to_rdkit_mol, +) +from morph.utils.vocab_utils import z_to_atom_types + +_SOURCE_ENV = None + + +def _remove_hydrogens(record: dict) -> Data: + """Keep heavy atoms/geometry/charge/bonds and verify inferred H per atom. + + Fail rather than silently dropping or changing a molecule whose hydrogen + count cannot be recovered from its heavy-atom bond orders and charges. + """ + z = record["z"] + keep = z != 1 + if not keep.any(): + raise ValueError("Cannot represent a molecule with no heavy atoms") + mapping = torch.full_like(z, -1) + mapping[keep] = torch.arange(int(keep.sum())) + edges = record["edge_index"] + edge_keep = keep[edges[0]] & keep[edges[1]] + out = Data( + z=z[keep].clone(), + pos=record["pos"][keep].clone(), + charges=record["charges"][keep].clone(), + edge_index=mapping[edges[:, edge_keep]].contiguous(), + edge_attr=record["edge_attr"][edge_keep].clone(), + smiles=record["smiles"], + ) + upper = out.edge_index[0] < out.edge_index[1] + mol = tensors_to_rdkit_mol( + z_to_atom_types(out.z.tolist()), + out.pos.numpy(), + out.charges.tolist(), + [IDX_BOND_MAP[i] for i in out.edge_attr[upper].tolist()], + out.edge_index[:, upper].T.tolist(), + implicit_hydrogens=True, + ) + if mol is None or not mol_is_valid(mol, allow_charged=True): + raise ValueError(f"Implicit-H reconstruction failed validity: {out.smiles}") + attached_h = torch.bincount( + edges[0, keep[edges[0]] & ~keep[edges[1]]], minlength=len(z) + )[keep].tolist() + inferred_h = [atom.GetTotalNumHs() for atom in mol.GetAtoms()] + if inferred_h != attached_h: + raise ValueError(f"Implicit H counts change source chemistry: {out.smiles}") + return out + + +def remove_hydrogens(record: dict) -> Data: + """Remove H, recovering stale cached charges from the source SMILES if needed. + + The original cache saved bond orders after RDKit cleanup but charge labels + before cleanup. Only repair records which fail direct reconstruction. The + source SMILES supplies the chemistry; atom order and coordinates stay fixed. + Every repair must still reproduce the original attached-H count per atom. + """ + try: + return _remove_hydrogens(record) + except ValueError as original_error: + reference = Chem.MolFromSmiles(record["smiles"]) + keep = record["z"] != 1 + edges = record["edge_index"] + upper = keep[edges[0]] & keep[edges[1]] & (edges[0] < edges[1]) + mapping = torch.full_like(record["z"], -1) + mapping[keep] = torch.arange(int(keep.sum())) + heavy = tensors_to_rdkit_mol( + z_to_atom_types(record["z"][keep].tolist()), + record["pos"][keep].numpy(), + record["charges"][keep].tolist(), + [IDX_BOND_MAP[i] for i in record["edge_attr"][upper].tolist()], + mapping[edges[:, upper]].T.tolist(), + sanitize=False, + ) + if ( + reference is None + or heavy is None + or reference.GetNumAtoms() != heavy.GetNumAtoms() + or reference.GetNumBonds() != heavy.GetNumBonds() + ): + raise ValueError( + f"Source SMILES does not match stored topology: {record['smiles']}" + ) from original_error + # Clear stale charges before template matching. Neutral template atoms + # do not constrain charge in RDKit's initial substructure match. + for atom in heavy.GetAtoms(): + atom.SetFormalCharge(0) + repaired = AllChem.AssignBondOrdersFromTemplate(reference, heavy) + # Export the model's Kekule representation, never aromatic edge tokens. + Chem.Kekulize(repaired, clearAromaticFlags=True) + normalized = { + **record, + "charges": record["charges"].clone(), + "edge_attr": record["edge_attr"].clone(), + } + normalized["charges"][keep] = torch.tensor( + [a.GetFormalCharge() for a in repaired.GetAtoms()] + ) + for j in (keep[edges[0]] & keep[edges[1]]).nonzero().flatten().tolist(): + a, b = mapping[edges[:, j]].tolist() + normalized["edge_attr"][j] = BOND_IDX_MAP[ + repaired.GetBondBetweenAtoms(a, b).GetBondType() + ] + out = _remove_hydrogens(normalized) + out.normalized_charge_atoms = int( + (normalized["charges"] != record["charges"]).sum() + ) + out.normalized_bonds = ( + int((normalized["edge_attr"] != record["edge_attr"]).sum()) // 2 + ) + out.source_normalized = True + return out + + +def _init_worker(path): + global _SOURCE_ENV # noqa: PLW0603 + torch.set_num_threads(1) + _SOURCE_ENV = open_read_env(path) + + +def _convert_chunk(task): + start, stop = task + blobs, counts, repairs, errors = [], Counter(), Counter(), [] + with _SOURCE_ENV.begin() as txn: + for index in range(start, stop): + raw = txn.get(_int_key(index)) + if raw is None: + raise RuntimeError(f"Missing source record {index}") + try: + data = remove_hydrogens(pickle.loads(raw)) + except (ValueError, RuntimeError) as exc: + errors.append({"index": index, "error": str(exc)}) + blobs.append(None) + continue + if getattr(data, "source_normalized", False): + repairs["molecules"] += 1 + repairs["charge_atoms"] += data.normalized_charge_atoms + repairs["bonds"] += data.normalized_bonds + blobs.append(mol_to_bytes(data)) + counts[len(data.z)] += 1 + return blobs, counts, repairs, errors + + +def convert_split(source, output, split, workers): + path = str(source / f"{split}_data.lmdb") + length = read_lmdb_length(path) + tasks = [(i, min(i + 256, length)) for i in range(0, length, 256)] + counts, repairs, errors = Counter(), Counter(), [] + + def records(results): + for blobs, histogram, corrections, failures in results: + counts.update(histogram) + repairs.update(corrections) + errors.extend(failures) + yield from blobs + + def write(results): + return write_bytes_to_lmdb( + records(results), + str(output / f"{split}_data.lmdb"), + total=length, + desc=f"Removing H and auditing {split}", + ) + + if workers > 1: + with get_context("spawn").Pool( + workers, initializer=_init_worker, initargs=(path,) + ) as pool: + written, failed = write(pool.imap(_convert_chunk, tasks)) + else: + global _SOURCE_ENV # noqa: PLW0603 + _init_worker(path) + try: + written, failed = write(map(_convert_chunk, tasks)) + finally: + _SOURCE_ENV.close() + _SOURCE_ENV = None + assert written + failed == length + assert failed == len(errors) + for suffix in ("smiles.txt", "source_indices.npy"): + sidecar = source / f"{split}_{suffix}" + if sidecar.exists(): + shutil.copy2(sidecar, output / sidecar.name) + return { + "source_molecules": length, + "normalizations": dict(repairs), + "errors": errors, + "retained": written, + "valid": written, + "hydrogen_roundtrips": written, + "heavy_atom_counts": {str(n): count for n, count in sorted(counts.items())}, + "max_heavy_atoms": max(counts, default=0), + "mean_heavy_atoms": sum(n * c for n, c in counts.items()) / max(written, 1), + } + + +def build_implicit_dataset(source_root: Path, root: Path, workers: int = 0): + source_root, root = source_root.resolve(), root.resolve() + if root == source_root or workers < 0: + raise ValueError("Use a separate destination and non-negative workers") + source, output = source_root / "processed", root / "processed" + source_summary = json.loads((source / "preprocessing_summary.json").read_text()) + if source_summary.get("includes_hydrogens") is not True: + raise ValueError("Expected an explicit-H source cache") + identity = { + "source_root": str(source_root), + "includes_hydrogens": False, + "hydrogen_mode": "implicit", + "schema_version": 2, + "source_max_total_atoms": source_summary["max_total_atoms"], + "source_files": {}, + } + for split in ("train", "val", "test"): + stat = (source / f"{split}_data.lmdb/data.mdb").stat() + identity["source_files"][split] = { + "bytes": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + } + if output.exists(): + summary = json.loads((output / "preprocessing_summary.json").read_text()) + if summary.get("status") == "ready" and all( + summary.get(k) == v for k, v in identity.items() + ): + print("Reusing complete implicit-H cache", root, flush=True) + return summary + raise FileExistsError(f"Different or incomplete cache: {output}") + root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".implicit_", dir=root) as temporary: + processed = Path(temporary) / "processed" + processed.mkdir() + splits = {} + for split in ("train", "val", "test"): + splits[split] = convert_split(source, processed, split, workers) + print(split, json.dumps(splits[split]), flush=True) + failures = { + split: report["errors"] + for split, report in splits.items() + if report["errors"] + } + if failures: + error_path = root / "conversion_errors.json" + error_path.write_text(json.dumps(failures, indent=2)) + raise ValueError(f"Dataset conversion failed; full audit: {error_path}") + manifest = processed / "preprocessing_summary.json" + manifest.write_text(json.dumps({**identity, "status": "computing_statistics"})) + preprocessing = Preprocessing( + root=temporary, + train_dataset={ + "_target_": ( + "morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset" + ), + "root": temporary, + "vocab": None, + "distributions": None, + "hydrogen_mode": "implicit", + }, + atom_tokens_path=str(processed / "atom_tokens.txt"), + edge_tokens_path=str(processed / "edge_tokens.txt"), + charge_tokens_path=str(processed / "charge_tokens.txt"), + distributions_path=str(processed / "distributions.pt"), + num_workers=workers, + ) + assert "H" not in preprocessing.vocab.atom_tokens + summary = { + **identity, + "status": "ready", + "job_id": os.environ.get("SLURM_JOB_ID"), + "molecules": {s: v["retained"] for s, v in splits.items()}, + "splits": splits, + "coordinate_std_A": float(preprocessing.distributions.coordinate_std), + } + manifest.write_text(json.dumps(summary, indent=2) + "\n") + os.rename(processed, output) + print(json.dumps(summary, indent=2), flush=True) + return summary + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--root", type=Path, required=True) + parser.add_argument("--workers", type=int, default=0) + args = parser.parse_args() + build_implicit_dataset(args.source_root, args.root, args.workers) + + +if __name__ == "__main__": + main() diff --git a/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/geom.py b/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/geom.py new file mode 100644 index 0000000000000000000000000000000000000000..e3ec6169f043803f3f0ada457332e53ab48ca567 --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/geom.py @@ -0,0 +1,552 @@ +import os +import pickle +from collections.abc import Iterable +from multiprocessing import get_context + +import lmdb +import torch +from rdkit import Chem +from torch.utils.data import Dataset +from torch_geometric.data import Data, download_url +from tqdm import tqdm + +from morph._vendor.geom_drugs_preprocessing import process_geom_drugs +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, +) + +# 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", +} + +PICKLE_PROTOCOL = 4 + +# 128 GiB virtual address reservation for the LMDB file. This is NOT disk +# usage — the file grows sparsely with actual data. Large enough to hold the +# full processed GEOM train split with headroom. +LMDB_MAP_SIZE = 128 * (1024**3) + +# Reserved metadata key for entry count. Integer data keys are 8-byte +# big-endian, so this textual key cannot collide with them. +LEN_KEY = b"__len__" + +# FlowMol3 atom vocabulary. Conformers containing any element outside this set +# are dropped during processing. The outlier elements observed in the raw GEOM +# train SMILES are vanishingly rare compared to the kept set (counts from +# notebooks/atom_type_counts.ipynb): B=14, Si=8, Bi=3 — i.e. ≤2e-6 of all +# atoms, vs. ≥200 for the rarest kept element (I). +ALLOWED_ATOM_SYMBOLS = frozenset({"H", "C", "N", "O", "S", "F", "Cl", "Br", "P", "I"}) + + +def _int_key(i: int) -> bytes: + return i.to_bytes(8, "big") + + +def process_one_conformer(mol: Chem.Mol): + """Process a single RDKit conformer and extract features. + + Returns a Data object or None if processing fails. + """ + if mol is None or not mol_is_valid(mol, allow_charged=True): + return None + + mol = sanitize_mol_correctly(mol) + if mol is None: + return None + + # Force Kekulé form so aromatic ring bonds become SINGLE/DOUBLE rather than + # BondType.AROMATIC. Sanitization above re-perceives aromaticity, undoing + # the kekulization applied by the GEOM-Drugs Revisited pre-filter; this + # call restores it so AROMATIC never reaches the bond-type tensor (Nikitin + # et al., 2025 — kekulization is the paper's recommended fix for the + # valency ambiguity of aromatic bonds). + try: + Chem.Kekulize(mol, clearAromaticFlags=True) + except Exception: + return None + + try: + N = mol.GetNumAtoms() + if N >= 72: + return None + if any(a.GetSymbol() not in ALLOWED_ATOM_SYMBOLS for a in mol.GetAtoms()): + return None + conf = mol.GetConformer() + pos = conf.GetPositions() + pos = torch.tensor(pos, dtype=torch.float) + + charges = [] + atomic_number = [] + for atom in mol.GetAtoms(): + atomic_number.append(atom.GetAtomicNum()) + charges.append(atom.GetFormalCharge()) + + z = torch.tensor(atomic_number, dtype=torch.long) + charges = torch.tensor(charges, dtype=torch.int64) + + rows, cols, edge_types = [], [], [] + for bond in mol.GetBonds(): + start, end = bond.GetBeginAtomIdx(), bond.GetEndAtomIdx() + rows += [start, end] + cols += [end, start] + edge_types += 2 * [BOND_IDX_MAP[bond.GetBondType()]] + + if len(rows) == 0: + edge_index = torch.tensor([[], []], dtype=torch.long) + edge_type = torch.tensor([], dtype=torch.long) + else: + edge_index = torch.tensor([rows, cols], dtype=torch.long) + edge_type = torch.tensor(edge_types, dtype=torch.long) + + perm = (edge_index[0] * N + edge_index[1]).argsort() + edge_index = edge_index[:, perm] + edge_type = edge_type[perm] + + smiles = smiles_from_mol(mol, canonical=True) or "" + + data = Data( + z=z, + pos=pos, + charges=charges, + edge_index=edge_index, + smiles=smiles, + edge_attr=edge_type, + ) + + return data + except Exception as e: + print(f"Error processing molecule: {e}") + return None + + +def mol_to_bytes(data: Data) -> bytes: + """Serialize a processed molecule Data object to compact pickle bytes. + + Stores only the raw tensor data as a plain dict, avoiding the overhead + of pickling full PyG Data objects. + """ + dict_repr = { + "z": data.z, + "pos": data.pos, + "charges": data.charges, + "edge_index": data.edge_index, + "edge_attr": data.edge_attr, + "smiles": data.smiles, + } + return pickle.dumps(dict_repr, protocol=PICKLE_PROTOCOL) + + +def mol_from_bytes(data: bytes) -> Data: + """Deserialize pickle bytes back into a PyG Data object.""" + obj = pickle.loads(data) + return Data( + z=obj["z"], + pos=obj["pos"], + charges=obj["charges"], + edge_index=obj["edge_index"], + smiles=obj["smiles"], + edge_attr=obj["edge_attr"], + ) + + +def process_one_conformer_to_bytes(mol: Chem.Mol) -> bytes | None: + """Process one conformer and return serialized bytes if valid.""" + data = process_one_conformer(mol) + if data is None: + return None + return mol_to_bytes(data) + + +def open_read_env(lmdb_path: str) -> lmdb.Environment: + """Open an LMDB environment configured for multi-worker read access. + + ``lock=False`` + ``readonly=True`` allows multiple processes (DataLoader + workers) to share the mmap without the writer lock. ``readahead=False`` + is better for random access patterns typical in training. + """ + return lmdb.open( + lmdb_path, + readonly=True, + lock=False, + readahead=False, + meminit=False, + subdir=True, + max_readers=512, + ) + + +def read_lmdb_length(lmdb_path: str) -> int: + """Return the number of molecules stored in an LMDB env.""" + env = open_read_env(lmdb_path) + try: + with env.begin() as txn: + raw = txn.get(LEN_KEY) + if raw is not None: + return int(raw) + return txn.stat()["entries"] + finally: + env.close() + + +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 + + +def process_conformers_to_lmdb( + conformers: list[Chem.Mol], + lmdb_path: str, + num_workers: int | None = None, + chunksize: int = 128, + map_size: int = LMDB_MAP_SIZE, + commit_every: int = 10_000, +) -> tuple[int, int]: + """Process conformers in parallel and stream serialized bytes into LMDB. + + Writes happen as workers complete so peak memory stays bounded regardless + of dataset size (unlike accumulating a full in-memory list first). + """ + if num_workers is None: + cpu_count = os.cpu_count() or 1 + num_workers = max(1, cpu_count - 1) + + if num_workers <= 1: + iterator = (process_one_conformer_to_bytes(c) for c in conformers) + return write_bytes_to_lmdb( + iterator, + lmdb_path, + total=len(conformers), + map_size=map_size, + commit_every=commit_every, + desc="Processing conformers", + ) + + ctx = get_context("spawn") + with ctx.Pool(processes=num_workers) as pool: + outputs = pool.imap_unordered( + process_one_conformer_to_bytes, conformers, chunksize=chunksize + ) + return write_bytes_to_lmdb( + outputs, + lmdb_path, + total=len(conformers), + map_size=map_size, + commit_every=commit_every, + desc="Processing conformers", + ) + + +class GEOM(Dataset): + """GEOM dataset backed by an LMDB environment. + + The processed data lives in ``/processed/_data.lmdb/`` as a + key-value store with 8-byte big-endian integer keys mapping to the same + compact pickle-dict bytes produced by :func:`mol_to_bytes`. + + Reads are memory-mapped: the dataset does not load into RAM, and multiple + DataLoader workers share the mmap. The LMDB handle is opened lazily on + first access so it's safe under both ``fork`` and ``spawn`` worker start + methods. + """ + + def __init__( + self, + root: str, + split: str = "train", + vocab: Vocab = None, + distributions: Distributions = None, + preprocess_workers: int | None = None, + ): + self.root = root + self.split = split + self.vocab = vocab + self.distributions = distributions + self.preprocess_workers = preprocess_workers + + self.raw_dir = os.path.join(root, "raw") + # Output dir of the GEOM-Drugs Revisited pre-filter (Nikitin et al., + # 2025). Sits between ``raw/`` and ``processed/`` and holds the + # sanitized pickle files plus the empirical ``valency_dict.json``. + self.filtered_dir = os.path.join(root, "raw_filtered") + self.processed_dir = os.path.join(root, "processed") + + os.makedirs(self.processed_dir, exist_ok=True) + + self.lmdb_path = os.path.join(self.processed_dir, f"{split}_data.lmdb") + + if not os.path.exists(self.lmdb_path): + self.download() + self.prefilter() + self.process() + + self._length = read_lmdb_length(self.lmdb_path) + self._env: lmdb.Environment | None = None + self._txn = None + self._txn_pid: int | None = None + + def _ensure_env(self) -> lmdb.Environment: + if self._env is None: + self._env = open_read_env(self.lmdb_path) + return self._env + + def _ensure_txn(self): + """Return a per-process, reused read transaction. + + Opening a fresh ``env.begin()`` per ``get()`` leaks ~65 kB of native + memory per transaction in the lmdb binding (measured), which OOM-killed + read-heavy conditional tasks. One long-lived read + txn per worker — keyed by PID so forked/spawned workers each get their + own — reads from the same static snapshot with zero per-read growth. + Also fixes a latent use-after-close: ``mol_from_bytes(bytes(raw))`` runs + after the txn would have closed under the old ``with`` block. + """ + pid = os.getpid() + if self._txn is not None and self._txn_pid == pid: + return self._txn + self._txn = self._ensure_env().begin(buffers=True) + self._txn_pid = pid + return self._txn + + def __getstate__(self): + """Omit live LMDB handles when serializing for DataLoader workers.""" + # Strip live env/txn handles so DataLoader workers using spawn can + # pickle the dataset. Each worker reopens lazily on first access. + state = self.__dict__.copy() + state["_env"] = None + state["_txn"] = None + state["_txn_pid"] = None + return state + + def download(self) -> None: + """Fetch raw GEOM-Drugs pickle files from bits.csb.pitt.edu. + + Mirrors the PyG ``InMemoryDataset.download`` pattern: each of the three + split pickles is downloaded into ``raw/`` if it is not already present. + Files that already exist on disk are left untouched so re-running + training does not re-fetch ~8 GB. + """ + 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: + """Apply the GEOM-Drugs Revisited pre-filter to all available splits. + + Defers to ``morph._vendor.geom_drugs_preprocessing.process_geom_drugs``, + which sanitizes/kekulizes molecules, drops disconnected fragments, runs + a covalent-radius topology check on every conformer, and writes the + cleaned pickle files plus a ``valency_dict.json`` into ``raw_filtered/``. + + Runs once per dataset root: if every filtered pickle is already on disk + the step is skipped. + """ + 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) + + # Reclaim ~8 GB by truncating each raw pickle whose filtered copy now + # exists. A zero-byte sentinel is left in place so ``download()`` still + # short-circuits on subsequent runs. + 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) + + print("Preparing conformers for processing...") + all_conformers = [ + conformer + for _smiles, conformers_list in tqdm(raw_data, desc="Preparing data") + for conformer in conformers_list + ] + del raw_data + + print(f"Total conformers to process: {len(all_conformers)}") + n_written, n_failed = process_conformers_to_lmdb( + all_conformers, + self.lmdb_path, + num_workers=self.preprocess_workers, + ) + print(f"Successfully processed: {n_written} molecules") + print(f"Failed: {n_failed} molecules") + del all_conformers + + self._write_smiles_sidecar() + + def _write_smiles_sidecar(self) -> None: + """Build the unique-SMILES sidecar by reading the LMDB env once.""" + smiles_path = os.path.join(self.processed_dir, f"{self.split}_smiles.txt") + unique: set[str] = set() + env = open_read_env(self.lmdb_path) + try: + with env.begin() as txn, txn.cursor() as cur: + for key, value in tqdm(cur, desc="Collecting SMILES"): + if bytes(key) == LEN_KEY: + continue + unique.add(pickle.loads(bytes(value))["smiles"]) + finally: + env.close() + with open(smiles_path, "w") as f: + f.write("\n".join(sorted(unique))) + + def get_all_smiles(self) -> list[str]: + """Read SMILES in the active 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() + + def iter_bytes(self) -> Iterable[bytes]: + """Iterate raw serialized molecule bytes in insertion order.""" + env = self._ensure_env() + with env.begin() as txn: + for i in range(self._length): + raw = txn.get(_int_key(i)) + if raw is None: + continue + yield bytes(raw) + + def __len__(self): + """Return the number of records in the active split.""" + return self._length + + def __getitem__(self, index): + """Decode one raw molecular record from LMDB.""" + raw = self._ensure_txn().get(_int_key(index)) + if raw is None: + raise IndexError(index) + return mol_from_bytes(bytes(raw)) + + def get(self, index): + """Return a raw record using the dataset indexing interface.""" + return self.__getitem__(index) + + +class FlowMatchingGEOMDataset(TokenizedDecodeMixin, GEOM): + """Decode GEOM records into tokenized flow-matching 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"), + preprocess_workers: int | None = None, + ): + super().__init__( + root, + split=split, + vocab=vocab, + distributions=distributions, + preprocess_workers=preprocess_workers, + ) + + 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 tokenized atoms and bonds for one target molecule.""" + if self.vocab is None: + raise RuntimeError( + "A vocab is required for tokenized GEOM samples; " + "use .get() for raw data" + ) + return self._decode_to_lean(super().__getitem__(index), index) + + def get(self, index): + """Return raw PyG fields without requiring vocabulary or statistics.""" + # Contract: .get() returns RAW source data (PyG fields z / pos / + # edge_attr / charges / smiles) so the scaffold-cache build and + # preprocessing can iterate before/without a vocab. ``__getitem__`` is + # the canonical ChemAtomicData builder (needs vocab + distributions). + # + # NOTE: must call the *base* ``__getitem__`` explicitly. ``super().get()`` + # dispatches through ``GEOM.get`` → ``self.__getitem__``, which resolves + # back to THIS subclass's processed ``__getitem__`` (vocab-dependent, + # runs per-mol RDKit when compute_properties=True) — not the raw reader. + return super().__getitem__(index) diff --git a/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/qm9.py b/chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/qm9.py new file mode 100644 index 0000000000000000000000000000000000000000..0ffb06411d0e56c4409d84802389c3a8479f63b7 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/flow_matching/__init__.py b/chembl3d/implicit_h/runtime/src/morph/flow_matching/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d9b707d8cad88bfd9db1a9318b10da933375a2a --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/flow_matching/__init__.py @@ -0,0 +1 @@ +"""Flow-matching core: graph priors, interpolation paths, and integration.""" diff --git a/chembl3d/implicit_h/runtime/src/morph/flow_matching/assignment.py b/chembl3d/implicit_h/runtime/src/morph/flow_matching/assignment.py new file mode 100644 index 0000000000000000000000000000000000000000..64be2a6d5728675c8922c37057ccdaaf3792c679 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/flow_matching/integration.py b/chembl3d/implicit_h/runtime/src/morph/flow_matching/integration.py new file mode 100644 index 0000000000000000000000000000000000000000..11062d840a5279bf09009980345cfe52305a1c17 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/flow_matching/interpolation.py b/chembl3d/implicit_h/runtime/src/morph/flow_matching/interpolation.py new file mode 100644 index 0000000000000000000000000000000000000000..8cb3a45f05e451e2b1f81390928597a7ec672358 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/flow_matching/prior.py b/chembl3d/implicit_h/runtime/src/morph/flow_matching/prior.py new file mode 100644 index 0000000000000000000000000000000000000000..55d2a0955cd0c40e381c4c55502978a593d06e9c --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/flow_matching/sampling_step.py b/chembl3d/implicit_h/runtime/src/morph/flow_matching/sampling_step.py new file mode 100644 index 0000000000000000000000000000000000000000..4b246a1bcceb3513f24df93016b92dbda488e5a7 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/flow_matching/schedules.py b/chembl3d/implicit_h/runtime/src/morph/flow_matching/schedules.py new file mode 100644 index 0000000000000000000000000000000000000000..079f8c8b847bc383eb54d42453cae384721e889c --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/inference/__init__.py b/chembl3d/implicit_h/runtime/src/morph/inference/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..670600d41fcaca8484c6a075fc47774d017f474a --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/inference/__init__.py @@ -0,0 +1,21 @@ +"""In-process inference utilities backing the ``morph`` CLI.""" + +from morph.inference.generate import ( + build_sampling_module, + generate_denovo, + load_checkpoint, + make_predict_trainer, + record_mol, +) +from morph.inference.registry import DEFAULTS, MODELS, resolve + +__all__ = [ + "DEFAULTS", + "MODELS", + "build_sampling_module", + "generate_denovo", + "load_checkpoint", + "make_predict_trainer", + "record_mol", + "resolve", +] diff --git a/chembl3d/implicit_h/runtime/src/morph/inference/generate.py b/chembl3d/implicit_h/runtime/src/morph/inference/generate.py new file mode 100644 index 0000000000000000000000000000000000000000..554c76043befac25bbf4a083fffcecbee381dd56 --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/inference/generate.py @@ -0,0 +1,295 @@ +# 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) + 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) + 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/chembl3d/implicit_h/runtime/src/morph/inference/registry.py b/chembl3d/implicit_h/runtime/src/morph/inference/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..7dcca12feea471c927baf183a11bea380b714ea3 --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/inference/registry.py @@ -0,0 +1,65 @@ +"""Model registry for the ``morph`` CLI. + +A model is a **bundle**: a single directory holding the checkpoint plus the vocab ++ distributions files (``atom_tokens.txt``, ``edge_tokens.txt``, +``charge_tokens.txt``, ``distributions.pt``). Bundles are hosted externally and +downloaded on demand (see the README) — nothing is shipped in the repo. The CLI +runs entirely from a bundle: no dataset is ever touched. + +``--weights-dir`` points at a downloaded bundle; each registry entry names the +checkpoint file within it and the hydra overrides that rebuild the architecture. +""" + +from __future__ import annotations + +from pathlib import Path + +# src/morph/inference/registry.py -> repo root +_REPO = Path(__file__).resolve().parents[3] +# Generic local bundle root (gitignored). No checkpoint is registered until a +# fresh first-class model has been trained and exported. +DEFAULT_WEIGHTS_DIR = _REPO / "assets" / "model_ckpts" + +_VOCAB_FILES = { + "atom_tokens_path": "atom_tokens.txt", + "edge_tokens_path": "edge_tokens.txt", + "charge_tokens_path": "charge_tokens.txt", + "distributions_path": "distributions.pt", +} + +# Add entries only for checkpoints trained with the current repository model. +MODELS: dict[str, dict] = {} +DEFAULTS: dict[str, str] = {} + + +def vocab_overrides(weights_dir) -> list[str]: + """Hydra overrides pointing ``data.preprocessing`` at a bundle's vocab/dist.""" + d = Path(weights_dir) + return [f"data.preprocessing.{k}={d / fname}" for k, fname in _VOCAB_FILES.items()] + + +def resolve( + mode: str, name: str | None = None, checkpoint: str | None = None, weights_dir=None +) -> dict: + """Resolve a model entry against a bundle directory. + + Returns ``{name, checkpoint, overrides, weights_dir}`` where ``overrides`` + already includes the vocab/dist paths for ``weights_dir`` (default: + :data:`DEFAULT_WEIGHTS_DIR`). ``checkpoint`` overrides the bundle's default + checkpoint file; ``name`` picks a non-default model. + """ + if name is None: + name = DEFAULTS.get(mode) + if name is None: + raise KeyError(f"No default model registered for mode {mode!r}.") + if name not in MODELS: + raise KeyError(f"Unknown model {name!r}. Known: {sorted(MODELS)}") + entry = MODELS[name] + wd = Path(weights_dir) if weights_dir is not None else DEFAULT_WEIGHTS_DIR + ckpt = checkpoint if checkpoint is not None else str(wd / entry["checkpoint_name"]) + return { + "name": name, + "checkpoint": ckpt, + "overrides": [*entry["overrides"], *vocab_overrides(wd)], + "weights_dir": str(wd), + } diff --git a/chembl3d/implicit_h/runtime/src/morph/model/README.md b/chembl3d/implicit_h/runtime/src/morph/model/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d8f28600adbce10e359cf262e88682644cb942e8 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/__init__.py b/chembl3d/implicit_h/runtime/src/morph/model/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1497c59eef37001653fa1f7767cac1a31c4e5ac0 --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/model/__init__.py @@ -0,0 +1 @@ +"""Model package: backbones, reusable layers, losses, and Lightning modules.""" diff --git a/chembl3d/implicit_h/runtime/src/morph/model/backbones/__init__.py b/chembl3d/implicit_h/runtime/src/morph/model/backbones/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3d4ba7a4c605c984ddfedbbe7028fdd62086396e --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/model/backbones/__init__.py @@ -0,0 +1 @@ +"""The molecular DiT backbone and its conditional extension.""" diff --git a/chembl3d/implicit_h/runtime/src/morph/model/backbones/conditional_dit.py b/chembl3d/implicit_h/runtime/src/morph/model/backbones/conditional_dit.py new file mode 100644 index 0000000000000000000000000000000000000000..4287090a6a5fb5f8c100023de6a9fd41784cb28b --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/backbones/dit.py b/chembl3d/implicit_h/runtime/src/morph/model/backbones/dit.py new file mode 100644 index 0000000000000000000000000000000000000000..745fc58d6ce78ab0260d7d6162671592faa04b14 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/build.py b/chembl3d/implicit_h/runtime/src/morph/model/build.py new file mode 100644 index 0000000000000000000000000000000000000000..fa287a60f9118cae9ab03cfeddf44d5f6dfd1229 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/finetune/__init__.py b/chembl3d/implicit_h/runtime/src/morph/model/finetune/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..47b5a9eb5d013717c4851897a7d253d4a157279c --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/finetune/base/__init__.py b/chembl3d/implicit_h/runtime/src/morph/model/finetune/base/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..005c1613e1a4df3198bd5f565e73a0004e9e5c8c --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/finetune/base/conditional_backbone.py b/chembl3d/implicit_h/runtime/src/morph/model/finetune/base/conditional_backbone.py new file mode 100644 index 0000000000000000000000000000000000000000..f9c2e279574ac3c93195f335f041f855ed2a1f5e --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/finetune/base/context_encoder.py b/chembl3d/implicit_h/runtime/src/morph/model/finetune/base/context_encoder.py new file mode 100644 index 0000000000000000000000000000000000000000..08ef01fce6650b5f2752efc776714adacf393707 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/finetune/base/finetune_base.py b/chembl3d/implicit_h/runtime/src/morph/model/finetune/base/finetune_base.py new file mode 100644 index 0000000000000000000000000000000000000000..644084c72cc4600a182d373d3247be3f48281e66 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/finetune/scaffold/__init__.py b/chembl3d/implicit_h/runtime/src/morph/model/finetune/scaffold/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b0d8cde88fb6ab386fb9b6a83f6e359ca19ed3ce --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/finetune/scaffold/scaffold.py b/chembl3d/implicit_h/runtime/src/morph/model/finetune/scaffold/scaffold.py new file mode 100644 index 0000000000000000000000000000000000000000..01867fd78848d1b8de0ea3cefac731bbf8cfc4c8 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/finetune/scaffold/scaffold_context.py b/chembl3d/implicit_h/runtime/src/morph/model/finetune/scaffold/scaffold_context.py new file mode 100644 index 0000000000000000000000000000000000000000..8ffea81ddf0ed41fb3ccc5adc735b2ddeb1be146 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/layers/__init__.py b/chembl3d/implicit_h/runtime/src/morph/model/layers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0a05254bc5f23cf8659009d164755c1c23312b00 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/layers/attention.py b/chembl3d/implicit_h/runtime/src/morph/model/layers/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..7a54b3842f9965e913b6fbc807a9b213a6299fc0 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/layers/embedding.py b/chembl3d/implicit_h/runtime/src/morph/model/layers/embedding.py new file mode 100644 index 0000000000000000000000000000000000000000..10bc72dabda728e6e36aa916b70a7b886442973d --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/layers/gmm.py b/chembl3d/implicit_h/runtime/src/morph/model/layers/gmm.py new file mode 100644 index 0000000000000000000000000000000000000000..d64bcd8ca9f257052d2d5bde9543efe2459c0305 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/layers/heads.py b/chembl3d/implicit_h/runtime/src/morph/model/layers/heads.py new file mode 100644 index 0000000000000000000000000000000000000000..45ff2ae7907244eaa16c644305ce79c9d743a799 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/layers/kernels.py b/chembl3d/implicit_h/runtime/src/morph/model/layers/kernels.py new file mode 100644 index 0000000000000000000000000000000000000000..592fbbe70160531c527c5eea0dd6dce1a90b397c --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/layers/lora.py b/chembl3d/implicit_h/runtime/src/morph/model/layers/lora.py new file mode 100644 index 0000000000000000000000000000000000000000..784a0da934778ebc5a23ba49bfd690e8dffc619a --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/lightning/__init__.py b/chembl3d/implicit_h/runtime/src/morph/model/lightning/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..40c82d6620249d9e0eed505b47c19d01ca8a1fd0 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/lightning/_optimizer.py b/chembl3d/implicit_h/runtime/src/morph/model/lightning/_optimizer.py new file mode 100644 index 0000000000000000000000000000000000000000..f4182a65333d9762218383c04a238c19cb645f3b --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/lightning/_sampling.py b/chembl3d/implicit_h/runtime/src/morph/model/lightning/_sampling.py new file mode 100644 index 0000000000000000000000000000000000000000..79870381a02a5df045b2f2845382048d2662200d --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/model/lightning/_sampling.py @@ -0,0 +1,378 @@ +"""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 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/chembl3d/implicit_h/runtime/src/morph/model/lightning/_setup.py b/chembl3d/implicit_h/runtime/src/morph/model/lightning/_setup.py new file mode 100644 index 0000000000000000000000000000000000000000..93f6248f6cc35915a8b97146198784d2386a4cb2 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/lightning/_training.py b/chembl3d/implicit_h/runtime/src/morph/model/lightning/_training.py new file mode 100644 index 0000000000000000000000000000000000000000..8a30ac7eda62a76ef8b14bba627663374b27479c --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/model/lightning/_training.py @@ -0,0 +1,530 @@ +"""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 _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. + + Subclasses override to hard-zero del / sub-a / sub-e (scaffold-scaffold) + probabilities and optionally coordinates at kept positions, while leaving + insertions intact so decorations can still grow. + """ + return x1_pred, do_sub_a_probs, do_sub_e_probs, do_del_probs, num_ins_pred + + 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/chembl3d/implicit_h/runtime/src/morph/model/lightning/_validation.py b/chembl3d/implicit_h/runtime/src/morph/model/lightning/_validation.py new file mode 100644 index 0000000000000000000000000000000000000000..760a4cb12e4c5e9175c3d5105947df70d8c7eeb2 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/lightning/base.py b/chembl3d/implicit_h/runtime/src/morph/model/lightning/base.py new file mode 100644 index 0000000000000000000000000000000000000000..8e47fd5a045105016798dbd037468c6188f4dab8 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/losses/__init__.py b/chembl3d/implicit_h/runtime/src/morph/model/losses/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..92286d0d607b74160e4907c06028c11e27d9b7a8 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/model/losses/functional.py b/chembl3d/implicit_h/runtime/src/morph/model/losses/functional.py new file mode 100644 index 0000000000000000000000000000000000000000..c631817fe0d68a79eea1be16dcafc60fe7fdd097 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/__init__.py b/chembl3d/implicit_h/runtime/src/morph/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..29bbd812cde4782119dc27b0c2a7073a93ba50c9 --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/utils/__init__.py @@ -0,0 +1 @@ +"""Shared utilities: tensor/graph helpers, RDKit conversion, metrics, viz.""" diff --git a/chembl3d/implicit_h/runtime/src/morph/utils/checkpoints.py b/chembl3d/implicit_h/runtime/src/morph/utils/checkpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..4b6b6fd189f1ef5a8194e09c3104231f52b7f662 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/edge_alignment.py b/chembl3d/implicit_h/runtime/src/morph/utils/edge_alignment.py new file mode 100644 index 0000000000000000000000000000000000000000..b15835716bd6e19e8cb2a17af2741ce9c88199b1 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/graph_math.py b/chembl3d/implicit_h/runtime/src/morph/utils/graph_math.py new file mode 100644 index 0000000000000000000000000000000000000000..a3cbce3098c507a30512fd13db3ab3b7784e406c --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/loss_accumulation.py b/chembl3d/implicit_h/runtime/src/morph/utils/loss_accumulation.py new file mode 100644 index 0000000000000000000000000000000000000000..d4c8523585949eb02ae953c3597541216c9a0389 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/lr_schedulers.py b/chembl3d/implicit_h/runtime/src/morph/utils/lr_schedulers.py new file mode 100644 index 0000000000000000000000000000000000000000..11f5b6ac9551480c8d4b539ba75614f9add1d887 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/metrics/__init__.py b/chembl3d/implicit_h/runtime/src/morph/utils/metrics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6f7b65dd9bceb97f8944f4c1eef7f08d4ed5ddfd --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/metrics/chemistry.py b/chembl3d/implicit_h/runtime/src/morph/utils/metrics/chemistry.py new file mode 100644 index 0000000000000000000000000000000000000000..8ab3c2672b3eb53e8275712eccea0c38ccf3c8f3 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/metrics/distribution.py b/chembl3d/implicit_h/runtime/src/morph/utils/metrics/distribution.py new file mode 100644 index 0000000000000000000000000000000000000000..e7928186f4eac061c0d58f59a7da4db6102af192 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/metrics/plotting.py b/chembl3d/implicit_h/runtime/src/morph/utils/metrics/plotting.py new file mode 100644 index 0000000000000000000000000000000000000000..42f09ef1f616be57c9267af13e0a0b28cb607fb2 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/metrics/posecheck_strain.py b/chembl3d/implicit_h/runtime/src/morph/utils/metrics/posecheck_strain.py new file mode 100644 index 0000000000000000000000000000000000000000..984523e52d9cb7f69a7755144ea24abcae115666 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/metrics/tensor.py b/chembl3d/implicit_h/runtime/src/morph/utils/metrics/tensor.py new file mode 100644 index 0000000000000000000000000000000000000000..72a99071dc8d06d18ac464017ff6c827bc972dde --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/rdkit_utils.py b/chembl3d/implicit_h/runtime/src/morph/utils/rdkit_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..30045af8c3dd72f6490bce3b322d28d1e3961baf --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/utils/rdkit_utils.py @@ -0,0 +1,296 @@ +"""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) + 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: + err = Chem.SanitizeMol(mol, catchErrors=True) + if err: + return None + + try: + for atom in mol.GetAtoms(): + atom.SetNoImplicit(not implicit_hydrogens) + atom.UpdatePropertyCache() + except Exception: + return None + + 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/chembl3d/implicit_h/runtime/src/morph/utils/run_config.py b/chembl3d/implicit_h/runtime/src/morph/utils/run_config.py new file mode 100644 index 0000000000000000000000000000000000000000..4c45abf0f0e16a1a9a4dcbaf8f17d17f61a65665 --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/runtime/src/morph/utils/training_monitor.py b/chembl3d/implicit_h/runtime/src/morph/utils/training_monitor.py new file mode 100644 index 0000000000000000000000000000000000000000..3077c372bd94863cf8e2c40eb191cc14bc3a4cb7 --- /dev/null +++ b/chembl3d/implicit_h/runtime/src/morph/utils/training_monitor.py @@ -0,0 +1,54 @@ +"""Small per-rank throughput and memory records for long training runs.""" + +import json +import time +from pathlib import Path + +import torch +from pytorch_lightning import Callback + + +class TrainingMonitor(Callback): + """Report each rank without introducing distributed collectives.""" + + def __init__(self, every_n_batches: int = 100): + self.every_n_batches = every_n_batches + + def on_train_epoch_start(self, trainer, pl_module): + """Exclude validation and checkpoint time from training windows.""" + self.started = time.perf_counter() + self.molecules = 0 + self.batches = 0 + if pl_module.device.type == "cuda": + torch.cuda.reset_peak_memory_stats(pl_module.device) + + def on_train_batch_end(self, trainer, pl_module, outputs, batch, batch_idx): + """Append a throughput/memory sample at a bounded logging interval.""" + self.molecules += int(batch[0].num_graphs) + self.batches += 1 + if self.batches != 1 and self.batches % self.every_n_batches: + return + if pl_module.device.type == "cuda": + torch.cuda.synchronize(pl_module.device) + elapsed = time.perf_counter() - self.started + metrics = { + "epoch": trainer.current_epoch, + "step": trainer.global_step, + "batch": batch_idx, + "rank": trainer.global_rank, + "molecules_per_second": self.molecules / elapsed, + } + if pl_module.device.type == "cuda": + metrics.update( + allocated_GiB=torch.cuda.memory_allocated(pl_module.device) / 2**30, + peak_allocated_GiB=torch.cuda.max_memory_allocated(pl_module.device) + / 2**30, + reserved_GiB=torch.cuda.memory_reserved(pl_module.device) / 2**30, + ) + path = Path(f"runtime-rank{trainer.global_rank}.jsonl") + with path.open("a") as stream: + stream.write(json.dumps(metrics) + "\n") + if trainer.is_global_zero: + print("TRAIN_RUNTIME", json.dumps(metrics), flush=True) + self.started = time.perf_counter() + self.molecules = 0 diff --git a/chembl3d/implicit_h/runtime/src/morph/utils/vocab_utils.py b/chembl3d/implicit_h/runtime/src/morph/utils/vocab_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..c696dfa14301ebd68f955d86366dc43ac4e0ea6e --- /dev/null +++ b/chembl3d/implicit_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/chembl3d/implicit_h/training_config.yaml b/chembl3d/implicit_h/training_config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0a77bc575eeb1b48b02fe44d0f8fd1696079f2b5 --- /dev/null +++ b/chembl3d/implicit_h/training_config.yaml @@ -0,0 +1,388 @@ +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: gpu + 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: + seconds: 256247 + do_train: true + do_validate: false + do_predict: false + checkpoint_path: null + 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 + runtime_monitor: + _target_: morph.utils.training_monitor.TrainingMonitor + every_n_batches: 100 +logging: + enabled: true + project: morph-chembl3d-implicit-h + name: ${oc.env:MORPH_RUN_ID,base} + 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: + dataset_name: chembl3d + root_path: ${oc.env:PROJECT_ROOT}/data + preprocess_workers: 10 + 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: ${data.root_path}/chembl3d_max100_implicit_h + 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 + num_workers: ${data.preprocess_workers} + vocab: null + 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} + 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_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + preprocess_workers: ${data.preprocess_workers} + vocab: ${data.vocab} + distributions: null + source_dir: ${data.source_dir} + hydrogen_mode: ${data.hydrogen_mode} + val: + - _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + vocab: ${data.vocab} + distributions: null + hydrogen_mode: ${data.hydrogen_mode} + test: + - _target_: morph.dataset.unconditional.chembl3d.FlowMatchingChEMBL3DDataset + root: ${data.preprocessing.root} + vocab: ${data.vocab} + distributions: null + hydrogen_mode: ${data.hydrogen_mode} + interpolator: ${data.interpolator} + rotate: ${model.rotate} + n_augmentations: ${model.n_augmentations} + num_workers: + train: 20 + val: 2 + test: 2 + 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 + source_dir: ${oc.env:CHEMBL3D_SOURCE_DIR,null} + max_total_atoms: 100 + hydrogen_mode: implicit +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/chembl3d/implicit_h/validation.json b/chembl3d/implicit_h/validation.json new file mode 100644 index 0000000000000000000000000000000000000000..87118aee0daa81b61cee0d90186f2bc01f26ae9b --- /dev/null +++ b/chembl3d/implicit_h/validation.json @@ -0,0 +1,45 @@ +{ + "variant": "implicit_h", + "dataset_access": false, + "checkpoints": [ + { + "filename": "epoch_100.ckpt", + "completed_epochs": 100, + "global_step": 230300, + "strict_load": true, + "finite_current_and_ema_forward": true + }, + { + "filename": "epoch_200.ckpt", + "completed_epochs": 200, + "global_step": 460600, + "strict_load": true, + "finite_current_and_ema_forward": true + }, + { + "filename": "epoch_300.ckpt", + "completed_epochs": 300, + "global_step": 690900, + "strict_load": true, + "finite_current_and_ema_forward": true + } + ], + "sampling_smoke_test": { + "variant": "implicit_h", + "completed_epochs": 300, + "seed": 42, + "integration_steps": 100, + "weights": "EMA", + "coordinate_units": "angstrom", + "num_attempted": 1, + "num_valid": 1, + "samples": [ + { + "index": 0, + "valid": true, + "smiles": "COc1cc(-c2c(C(=O)O)ccc3nc(N4C(=O)COC4=O)oc23)cc(OC)c1OC", + "represented_atoms": 31 + } + ] + } +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..126bf5c9d39108fad9973acc9b7111f9f72697f8 --- /dev/null +++ b/manifest.json @@ -0,0 +1,1138 @@ +{ + "README.md": { + "bytes": 4375, + "sha256": "6136d1fdba31b3185a3a62b0697f1171bc11b44d45b4e5b826fbea43989b7bda" + }, + "chembl3d/explicit_h/atom_tokens.txt": { + "bytes": 30, + "sha256": "71cbe3ea2ef59215ec5608e1d2f1392c54f5df2fa590f4f25a836087a86ccbe6" + }, + "chembl3d/explicit_h/charge_tokens.txt": { + "bytes": 7, + "sha256": "e8f533e310170a31a7e5e5fe125447fbe0253de205966ae295713c98c6ac531c" + }, + "chembl3d/explicit_h/config.yaml": { + "bytes": 10310, + "sha256": "d56b85cacb4fcd14123b0c87d3fe671bc0a2be7676abcce27a5bceb8d3f4090c" + }, + "chembl3d/explicit_h/distributions.json": { + "bytes": 357060, + "sha256": "6e7cfdacc801345dbc68cb7970662ed36238e9bef733314e7f8432e41f027fd3" + }, + "chembl3d/explicit_h/distributions.pt": { + "bytes": 105093, + "sha256": "c92bdce3e70763f23b3132a910a9939791fd147f98e11f7d6ea9c0e6ef9622b4" + }, + "chembl3d/explicit_h/edge_tokens.txt": { + "bytes": 16, + "sha256": "42b635ef938d2ec5a601044e4b01f3f84ed121f1bfaac984d86f180a8ccba3fd" + }, + "chembl3d/explicit_h/epoch_200.ckpt": { + "bytes": 509357065, + "sha256": "087f67faf38847d34780d061b0d192e61f8b4365d50cab6c32e0cc7dc5368743" + }, + "chembl3d/explicit_h/epoch_300.ckpt": { + "bytes": 509357129, + "sha256": "ad7b4535cf8d40ead915c59e525fbe3ec555e6f03885b78ed54c45497793f32b" + }, + "chembl3d/explicit_h/epoch_400.ckpt": { + "bytes": 509357129, + "sha256": "efb5c2f85972df19eca85d2d69cb56f6f52453d91ee26293b3e68b299e2bac8f" + }, + "chembl3d/explicit_h/metadata.json": { + "bytes": 1788, + "sha256": "8931209027e377f4caef4d98c0d2a8e9a804aaaedb820c628b31d86c47e12fea" + }, + "chembl3d/explicit_h/runtime/README.md": { + "bytes": 112, + "sha256": "76a480a106e3875283cd1fcf35ad0d330c0e7bbac507ef64606d01b0dfa5a212" + }, + "chembl3d/explicit_h/runtime/configs/callbacks/callbacks.yaml": { + "bytes": 699, + "sha256": "dce0adebf5956dce687d64a074238f9c12866373bfe21df6658d967d6bfd1f90" + }, + "chembl3d/explicit_h/runtime/configs/data/chembl3d/chembl3d.yaml": { + "bytes": 1205, + "sha256": "d7d74b4c0612f5123e47d7f3d815bd60106f274b2428519836788297c0a127db" + }, + "chembl3d/explicit_h/runtime/configs/data/chembl3d/max100.yaml": { + "bytes": 252, + "sha256": "3ca6e6f14cb57c9c3098e7b67d6284ca985e3cffdef39ea7d6312578e4bea888" + }, + "chembl3d/explicit_h/runtime/configs/data/geom/geom.yaml": { + "bytes": 4001, + "sha256": "751ba5dd5851da2d4887b9e5d622627a52bf0e783ca3097844d3233bd659354d" + }, + "chembl3d/explicit_h/runtime/configs/data/geom/scaffold_decoration.yaml": { + "bytes": 1107, + "sha256": "d317728a98961e98a1f03bcc3b0f8c513169ca23b68ea07ba807b99066e7ca80" + }, + "chembl3d/explicit_h/runtime/configs/data/qm9/qm9.yaml": { + "bytes": 3648, + "sha256": "3beea93868a1e889b049dcc2ccbc3b7489fa4672e964138654bc512ea01ab9f2" + }, + "chembl3d/explicit_h/runtime/configs/data/qm9/scaffold_decoration.yaml": { + "bytes": 890, + "sha256": "40b36f2bc25bf4052cdd676a26d9e528cb4f7b13deddbb1660f454f97b159c40" + }, + "chembl3d/explicit_h/runtime/configs/default.yaml": { + "bytes": 1294, + "sha256": "bcf9744acbf53afa4305ffd6e9cf95e6f99571750488eaa8cad85896a51aca66" + }, + "chembl3d/explicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml": { + "bytes": 90, + "sha256": "f09553bc670e6c87ac8c1f8bbd0a8df73f1e83b02a3dcf9d6d06b915ac3bce93" + }, + "chembl3d/explicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml": { + "bytes": 875, + "sha256": "8a0c29056910bed37852032f067ac7d74e91088822333109d51233c03d155741" + }, + "chembl3d/explicit_h/runtime/configs/defaults/core.yaml": { + "bytes": 431, + "sha256": "0dcfb68e2fece1dc277f647d3b3769af882cf8175e0c3b479bc50029e2538b1c" + }, + "chembl3d/explicit_h/runtime/configs/defaults/geom/geom.yaml": { + "bytes": 137, + "sha256": "ae774057727cddbc5a4db883a1c53253534da5b10fb7bb8d4cec7c576d61e5d1" + }, + "chembl3d/explicit_h/runtime/configs/defaults/geom/scaffold_decoration.yaml": { + "bytes": 828, + "sha256": "bcce546bce2b0b7bfaf7c0663e05423fd03c952bf6e52cbbf31976723661f0d1" + }, + "chembl3d/explicit_h/runtime/configs/defaults/qm9/qm9.yaml": { + "bytes": 132, + "sha256": "f83bd8e10802df07c2b1933dd40597807e3ee8a8cc39349ab14eb6f722f8475e" + }, + "chembl3d/explicit_h/runtime/configs/defaults/qm9/scaffold_decoration.yaml": { + "bytes": 348, + "sha256": "3bb8b6a757ea4921c31df2c0869949baef703df980b964d9636f52b64ac39267" + }, + "chembl3d/explicit_h/runtime/configs/embeddings/geom.yaml": { + "bytes": 803, + "sha256": "06793f4d446d899197e3db28d064592af9049612ee78cb0718a63e3d6fdc3151" + }, + "chembl3d/explicit_h/runtime/configs/embeddings/qm9.yaml": { + "bytes": 849, + "sha256": "de57af7f1ff9b44dbb952f852c0177789196105b77f396298ff8e0218c0626b7" + }, + "chembl3d/explicit_h/runtime/configs/heads/head.yaml": { + "bytes": 2475, + "sha256": "238e2346041e0aedccd16a6ed4066e163004c0e45a2cbc6fd1f6d17e16eb44ed" + }, + "chembl3d/explicit_h/runtime/configs/insertion_head/gmm.yaml": { + "bytes": 488, + "sha256": "1e26914eed9b4971e0f6ff0b61bbdb34103233d346719a55ba6273dd3d785c05" + }, + "chembl3d/explicit_h/runtime/configs/insertion_head/unimodal.yaml": { + "bytes": 495, + "sha256": "547a6391c3b341e3e369f8f4c7aab9752c67dfbbb533388d25956e9011b305e8" + }, + "chembl3d/explicit_h/runtime/configs/integrator/integrator.yaml": { + "bytes": 1580, + "sha256": "8f0d9aafb0b2727cd58154e1fd97df693c8b7d4f5a3fb5efcb9188553f54660f" + }, + "chembl3d/explicit_h/runtime/configs/logging/wandb_logger.yaml": { + "bytes": 139, + "sha256": "a9a2787e048d4eb70f2ff3c49d4355f1a5a382047c8619203b1f9131d829928d" + }, + "chembl3d/explicit_h/runtime/configs/loss_weights/calibrated_geom.yaml": { + "bytes": 644, + "sha256": "d4763c84aa9e1eb77fb2b456384332a4b3a84b072d560918b2357222a43f4ca1" + }, + "chembl3d/explicit_h/runtime/configs/loss_weights/calibrated_geom_frozen.yaml": { + "bytes": 1141, + "sha256": "a08acb61d67a852051dbded08f8bb67f21101888fd0a22a96a522781d28c570d" + }, + "chembl3d/explicit_h/runtime/configs/loss_weights/tuned_weights.yaml": { + "bytes": 420, + "sha256": "2bb5884465666d6fc638f787fd79257e4126c69a5cc55b55615fc45e02e65894" + }, + "chembl3d/explicit_h/runtime/configs/loss_weights/uniform.yaml": { + "bytes": 336, + "sha256": "3c14d6443048f69d436fe03792703a33c67f368ecbb349de2fd145da4c96eabc" + }, + "chembl3d/explicit_h/runtime/configs/model/backbones/dit.yaml": { + "bytes": 4039, + "sha256": "65b0f08be8fb307b8b01e7b460fd083fe60d48148fbece378d7e5a23dd4a9fb0" + }, + "chembl3d/explicit_h/runtime/configs/model/finetune/lora_finetune.yaml": { + "bytes": 328, + "sha256": "6334a5112499c0f3bc1fe5de224e2b6b3265dc524c8d6b9048113868fb864558" + }, + "chembl3d/explicit_h/runtime/configs/model/finetune/scaffold_finetune.yaml": { + "bytes": 5659, + "sha256": "1aad0d33e9b5ae95d9f50b5201a29bb8677fe42ea2bcf59e0e61d053ef1dfddb" + }, + "chembl3d/explicit_h/runtime/configs/optimizer/muon_aux_adam.yaml": { + "bytes": 1212, + "sha256": "3b0a6189f3ba81f85f7d26c6f6ad7f64dcb25098a14e411211d7a62983a7180c" + }, + "chembl3d/explicit_h/runtime/configs/schedule/beta.yaml": { + "bytes": 78, + "sha256": "75c485edfd70016b255282adcf5de25dfb95c9e32bb6587017fb8935ffe64eaa" + }, + "chembl3d/explicit_h/runtime/configs/schedule/cubic.yaml": { + "bytes": 54, + "sha256": "a998a2d3390ab5daf4df079852ae8daa907687cf059f55c500409c9493949c0c" + }, + "chembl3d/explicit_h/runtime/configs/schedule/fast_power.yaml": { + "bytes": 126, + "sha256": "22f36106bd43ca253ffea412e00539f2b07efbc49109b97c1232df6a827a2baf" + }, + "chembl3d/explicit_h/runtime/configs/schedule/linear.yaml": { + "bytes": 55, + "sha256": "f7881af26080ab5569e67b39f6eae2733c033b9623020e6271959f09fac45fa0" + }, + "chembl3d/explicit_h/runtime/configs/schedule/smoothstep.yaml": { + "bytes": 70, + "sha256": "f6e8651953d93f0d18c997d40c199b3d80ddd352a2041cc51e29adc9a7f22778" + }, + "chembl3d/explicit_h/runtime/configs/trainer/trainer.yaml": { + "bytes": 1402, + "sha256": "6f78e83ad46529bee7a2878b20727ff403420a515c1629bd73a608a2fb654f5f" + }, + "chembl3d/explicit_h/runtime/pyproject.toml": { + "bytes": 4433, + "sha256": "a67c11df733a8fe8d5a2351e2d4dbd0c4554e3c7564378b69df05b68485e790e" + }, + "chembl3d/explicit_h/runtime/requirements-tested.txt": { + "bytes": 311, + "sha256": "8936ae2284ca10a995df625fa4a33f88949bf6884a5456e1c7cae164af0c8a32" + }, + "chembl3d/explicit_h/runtime/src/morph/__init__.py": { + "bytes": 79, + "sha256": "bb402ea5c6f79e569205cc2bf4168d06430753ce8499ca4ffb8614743626fd59" + }, + "chembl3d/explicit_h/runtime/src/morph/__main__.py": { + "bytes": 149, + "sha256": "95a7983479559ca2b15c7afa3881b3142ef4636583501184469b972df7646d4f" + }, + "chembl3d/explicit_h/runtime/src/morph/_vendor/__init__.py": { + "bytes": 72, + "sha256": "8480cd966a03870a7142e1763094bf00e221d2c3c2b1af1d4d458fb8b7f37a20" + }, + "chembl3d/explicit_h/runtime/src/morph/_vendor/geom_drugs_preprocessing.py": { + "bytes": 11328, + "sha256": "33a9c21d29df269a5f342a0b0e8bced0fc620559f0cb3a7a4cfabd5c369ceccb" + }, + "chembl3d/explicit_h/runtime/src/morph/_vendor/muon.py": { + "bytes": 16405, + "sha256": "c61af8c2c4a635cc682b84e279851cb54fedc6fb935d8eb11aafbeb1c5e81fc6" + }, + "chembl3d/explicit_h/runtime/src/morph/cli.py": { + "bytes": 3711, + "sha256": "ac05049bba821c131267d294c747b5f4242fedab0709459799ff0f73a0443211" + }, + "chembl3d/explicit_h/runtime/src/morph/config.py": { + "bytes": 704, + "sha256": "bd3562c030611f87cc514308f155ad93740dd59927732f71b2e15009012d78ff" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/__init__.py": { + "bytes": 620, + "sha256": "44ae493354fed0fec4b410f46e0c1fb0351b5fbe5f404ff50f77181e60a39cee" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/conditional/__init__.py": { + "bytes": 238, + "sha256": "e46c747069acbf3d82407ccfcf96f2e33608c010a429f9abdb064bb71795d134" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/__init__.py": { + "bytes": 167, + "sha256": "6ef414b31cceb7303c174d720e0d0666efd6f6678c97b3cf86d33c799d2153dd" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/cutters.py": { + "bytes": 12282, + "sha256": "b29e0d7b1758461cda868bcba56d58b21ce76a45a9365bed73f7a2735103616f" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/datamodule.py": { + "bytes": 1402, + "sha256": "ecd4a5819d3654f8f76e3e0d059a60bfc9d0ac2ec343514d45517e7094c7c282" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/lmdb.py": { + "bytes": 4252, + "sha256": "0055352099c32b5ab6921861a716fbeb0b24bfe3b6b3adba05e990003224a5f9" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/conditional/scaffold/wrappers.py": { + "bytes": 7720, + "sha256": "a44d61452b676dc1dfd8d6bd44c09b22d463c8129b23cf98ec3903d10a693f2c" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/__init__.py": { + "bytes": 376, + "sha256": "7bab9c675f0850efc50e14a9898accfde5f8d1c1e2420414ac8c0986822d27e0" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/atomic_data.py": { + "bytes": 708, + "sha256": "7d59ec16ed5b3cf82b57b49dd4d7799862709353a65f4d473d4571ba91b86fa6" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/batch.py": { + "bytes": 23924, + "sha256": "7b2b2afd749b621b1ca890d192e4dc9378b61ead9ef34b90f04dc449e6bad369" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/containers.py": { + "bytes": 18831, + "sha256": "82f1fa3e27483f03b19a18800a9c8f011f7d23e3b9789e484fa233a824bda3a6" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/flow_matching_wrapper.py": { + "bytes": 7568, + "sha256": "7672ce6dc416bbccae1471b8e4fb66f44adf7eaf8f0f2fa151f1268e9513a17d" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/insertion_data.py": { + "bytes": 14393, + "sha256": "7e99e4d59b29ea537708b5f7887ed3e61b4b31ebf80d55fe5a0c6823ff8a439c" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/insertion_targets.py": { + "bytes": 6614, + "sha256": "2e62e89125310572735d916e97c8a544cfb3eb76fb167ecd134b5f8b0424df8c" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/lightning_datamodule.py": { + "bytes": 7057, + "sha256": "4d525cbcde4a64d7cac41837e0379c40097cc38fbbc2f54ff30ff7e3ab83de4c" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/preprocessing.py": { + "bytes": 21599, + "sha256": "942e14db48190ecc9fe39f5880bd2ee7e351e21271b12e16dc2b9dec0c1703fa" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/properties.py": { + "bytes": 1952, + "sha256": "4dc63574f5fde284245078adf50fb2d8a9ac16443b8276154c03b7703cc2e937" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/representation.py": { + "bytes": 6591, + "sha256": "0ecac55e1faf9748826ec8f4c7f1a274263214ea3f68cc69de7047b55dd6a65f" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/tokenized_decode.py": { + "bytes": 3670, + "sha256": "907639f6ae51e1eac109f4ce5d877952da18f1f3f2fe25e6b41f463d4b52dba4" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/core/vocab.py": { + "bytes": 2236, + "sha256": "f8618488f7bcdc8a58d8b9f3443b764eac60985a33ead1bcc2d17d3bdc213329" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/__init__.py": { + "bytes": 403, + "sha256": "d7cd3b85745ad1a4d884c0a11a64b3ab9cedcc64e30f604dbe83f5beb14fa97b" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py": { + "bytes": 14643, + "sha256": "90c98ebcc87a035e8e9eaa56417211772a8228305772c0b147a69649cd15bdc1" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/chembl3d_filter.py": { + "bytes": 7695, + "sha256": "f2774ac26de41ec1fed10fdd57fcc5238794eaf557ba32f5f79d98448a4a6cbf" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/geom.py": { + "bytes": 19754, + "sha256": "bbfea2f96d014afaa1dc60e94303faac6a41c6036e1f9ee3ed58279798d86524" + }, + "chembl3d/explicit_h/runtime/src/morph/dataset/unconditional/qm9.py": { + "bytes": 9520, + "sha256": "06b6086cb1234b283cd3a45daad2e06d3bdb5d45810b873f860c82969ccbfe53" + }, + "chembl3d/explicit_h/runtime/src/morph/flow_matching/__init__.py": { + "bytes": 78, + "sha256": "674f4630aa8421d7e75c913741c6efb1a163fbc38ce909c6cdb011f9ba9ef48e" + }, + "chembl3d/explicit_h/runtime/src/morph/flow_matching/assignment.py": { + "bytes": 16050, + "sha256": "b6ad66538f5bd7f14e0eee2a0ffd25a4110d1504e057a0a9eb63a354d9f6aadb" + }, + "chembl3d/explicit_h/runtime/src/morph/flow_matching/integration.py": { + "bytes": 33356, + "sha256": "f64b7b9ed2a79c11932b72e498e57eef924b5c85a05aa6893ddfe11e2d4595e9" + }, + "chembl3d/explicit_h/runtime/src/morph/flow_matching/interpolation.py": { + "bytes": 22169, + "sha256": "6ef2e03626fd4b3194c68df6e2410ab6e630f4891f97a8328b4eb0b3e18c7dcd" + }, + "chembl3d/explicit_h/runtime/src/morph/flow_matching/prior.py": { + "bytes": 2389, + "sha256": "0f9b77274238b23be7a2ad88ee8e6dcbb785817fcfa3a35aa863b489d2a6e05d" + }, + "chembl3d/explicit_h/runtime/src/morph/flow_matching/sampling_step.py": { + "bytes": 2806, + "sha256": "c768682a47da1b7852838e20b39317528717fc0485cbc35030b3ca71d7d845bb" + }, + "chembl3d/explicit_h/runtime/src/morph/flow_matching/schedules.py": { + "bytes": 6776, + "sha256": "6fd8396a05052ae7831babc75ca40a7e748fb9edffe82bb2559533afa949b2a9" + }, + "chembl3d/explicit_h/runtime/src/morph/inference/__init__.py": { + "bytes": 461, + "sha256": "c3a77e08bf1a779bd114dcbebd9225598c4525bcb879add848a9b107f7cb9ec7" + }, + "chembl3d/explicit_h/runtime/src/morph/inference/generate.py": { + "bytes": 11072, + "sha256": "d5ba369c7364cca92c966c4f286d705ab43f21f2c30c61d1ebcdd184d3ed18db" + }, + "chembl3d/explicit_h/runtime/src/morph/inference/registry.py": { + "bytes": 2588, + "sha256": "8b86381194abb69a8131e2e94f8cf28c7802971f484056a5f9c0d3db01ff16c3" + }, + "chembl3d/explicit_h/runtime/src/morph/model/README.md": { + "bytes": 10319, + "sha256": "9c06b5b90300f7449048640417116aa8869dc1a75f106cbdd9344a89f6e446d3" + }, + "chembl3d/explicit_h/runtime/src/morph/model/__init__.py": { + "bytes": 80, + "sha256": "1c7c3875387d5ba7f9166a804ea5a7ef7bd469728c1ad5c5003150d44e33be51" + }, + "chembl3d/explicit_h/runtime/src/morph/model/backbones/__init__.py": { + "bytes": 64, + "sha256": "a1a1419590c11aa6ed429f8b6b293470e30fe767f5571d2517b0883efb071e0c" + }, + "chembl3d/explicit_h/runtime/src/morph/model/backbones/conditional_dit.py": { + "bytes": 25451, + "sha256": "411fe1d0fd17c24e03e153d57f299aa0436bc5749e2064d5574681878429795d" + }, + "chembl3d/explicit_h/runtime/src/morph/model/backbones/dit.py": { + "bytes": 31262, + "sha256": "e2798baaf31bc8004a17c2432c56af9c4b12651d259cc0deaf5f18c83c6ddd33" + }, + "chembl3d/explicit_h/runtime/src/morph/model/build.py": { + "bytes": 3442, + "sha256": "e69f4231815f09c094d787f11edc49e222cbdc970edc6e416b6b94a9b88d4b21" + }, + "chembl3d/explicit_h/runtime/src/morph/model/finetune/__init__.py": { + "bytes": 282, + "sha256": "8746b1e29e5a3b3b1805389257270b90f5d30995783f2cf41b9fb533c485fc0c" + }, + "chembl3d/explicit_h/runtime/src/morph/model/finetune/base/__init__.py": { + "bytes": 803, + "sha256": "6e0ded0b3a5c2a76ccb85b05cf63bcc54e145bd1be07d933a737d6151433d856" + }, + "chembl3d/explicit_h/runtime/src/morph/model/finetune/base/conditional_backbone.py": { + "bytes": 2853, + "sha256": "1353e96443eacf3ce88473a30d02ab4f1c8b6a9c90763ea61c58d353b806b378" + }, + "chembl3d/explicit_h/runtime/src/morph/model/finetune/base/context_encoder.py": { + "bytes": 15124, + "sha256": "8d5dfc91ec73120161ed0a51dcc7edac442004ad4d5213ba34d9d11cfd1eee15" + }, + "chembl3d/explicit_h/runtime/src/morph/model/finetune/base/finetune_base.py": { + "bytes": 9526, + "sha256": "2efa00fd8eed1096a9cf8799dee4abfd8b27de1c0600e22a906dddaa089c4ca0" + }, + "chembl3d/explicit_h/runtime/src/morph/model/finetune/scaffold/__init__.py": { + "bytes": 366, + "sha256": "6345e71c8b03ee5eb0dcd105c0532acf73a1795bf6360b2847be600bcecd40ba" + }, + "chembl3d/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold.py": { + "bytes": 1984, + "sha256": "6de7a6083b4303ae422f03f15b5b40b1233d48bfc2dd1e0aa83a4eaa8adf40dd" + }, + "chembl3d/explicit_h/runtime/src/morph/model/finetune/scaffold/scaffold_context.py": { + "bytes": 6086, + "sha256": "d9446254770e3c3ea2c634a0636ab82a718514a7cb69760d756e9235cfbe2814" + }, + "chembl3d/explicit_h/runtime/src/morph/model/layers/__init__.py": { + "bytes": 189, + "sha256": "79671fbdea358c330a45b7fd3d842119b32b1d548804b3d441bbfb7dbd703b69" + }, + "chembl3d/explicit_h/runtime/src/morph/model/layers/attention.py": { + "bytes": 21038, + "sha256": "7efc416ac8fec0236909661ad0693a0aef613ff97a964fb6d8252cb49c60b5c6" + }, + "chembl3d/explicit_h/runtime/src/morph/model/layers/embedding.py": { + "bytes": 14356, + "sha256": "efe924190da5904e05495f421dcc313134cebe0dee9bf665060e1e5781cd95ef" + }, + "chembl3d/explicit_h/runtime/src/morph/model/layers/gmm.py": { + "bytes": 6753, + "sha256": "628e90c73bdbb3e70104214f80bbf8a94896ddbe22a9a3fc40cce34b20d2565f" + }, + "chembl3d/explicit_h/runtime/src/morph/model/layers/heads.py": { + "bytes": 30913, + "sha256": "7113c29a6be81c93ca980dc19d0847d2015ac36702683e4f1adedd549aed46ae" + }, + "chembl3d/explicit_h/runtime/src/morph/model/layers/kernels.py": { + "bytes": 12483, + "sha256": "be0c52b69c7612946244755e2865de0491440a41163cf828168eb750a8f929cf" + }, + "chembl3d/explicit_h/runtime/src/morph/model/layers/lora.py": { + "bytes": 5947, + "sha256": "7194750605e7b5df9a02e4c4a39cf29c982f117c40198c99c73211c85ecdfe4b" + }, + "chembl3d/explicit_h/runtime/src/morph/model/lightning/__init__.py": { + "bytes": 339, + "sha256": "c53943a4eb1e45568cebfb538655cc5eb7ead46284bbcd515800102c89177e52" + }, + "chembl3d/explicit_h/runtime/src/morph/model/lightning/_optimizer.py": { + "bytes": 19578, + "sha256": "f77519db8d3458c05aec1543cb4cd487995cf398abcc71b62ea1835cb132165e" + }, + "chembl3d/explicit_h/runtime/src/morph/model/lightning/_sampling.py": { + "bytes": 14773, + "sha256": "f71ebbb0c230366bf4bb2a2d0da715a1c523120ac3c48e863a2fed27e35c421c" + }, + "chembl3d/explicit_h/runtime/src/morph/model/lightning/_setup.py": { + "bytes": 6537, + "sha256": "835f510094110c4dbc590a88d9b94d41474ae87b442785a949328feda5b9afce" + }, + "chembl3d/explicit_h/runtime/src/morph/model/lightning/_training.py": { + "bytes": 23197, + "sha256": "8f412ce29ba758a1717aa509b953659c41b5ce02693f75949469e5146eaa73b3" + }, + "chembl3d/explicit_h/runtime/src/morph/model/lightning/_validation.py": { + "bytes": 11509, + "sha256": "03890457e504eed96f23dd8633a6af4317a9ed61e80b3e4ec935aaf9e63038d7" + }, + "chembl3d/explicit_h/runtime/src/morph/model/lightning/base.py": { + "bytes": 9294, + "sha256": "ce9159e7b387ece0a3ce010b164f675b81980d2f617b5b05d00f96695ee33e28" + }, + "chembl3d/explicit_h/runtime/src/morph/model/losses/__init__.py": { + "bytes": 190, + "sha256": "bb9de5950496ed184a32e11834d8e6f3b7d406773509ffa6c406a86bb5249fae" + }, + "chembl3d/explicit_h/runtime/src/morph/model/losses/functional.py": { + "bytes": 11765, + "sha256": "42ae52f604a0cb8b7fe08955cfd273945ca068727994f20b02c80cdee3a00e1b" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/__init__.py": { + "bytes": 78, + "sha256": "eb76ca86f046931eaea693a696317e8b3ce0b8ad8695a92e3744fd1892f2b9b8" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/checkpoints.py": { + "bytes": 2301, + "sha256": "750302eb039e3947a5d4c84e4e7d04eb46e974cc0031ef1d197a3573e20eae4b" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/edge_alignment.py": { + "bytes": 4896, + "sha256": "0edcde93cfa3a2d825e783ea5cf64715aa248e59ffb8c6a23d4490d74976a71e" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/graph_math.py": { + "bytes": 6755, + "sha256": "2bae326c3538d757aac66dcfc4a0b5d023b17bd987d814d3dab9a57f9ce60552" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/loss_accumulation.py": { + "bytes": 15115, + "sha256": "0f3eca42145e715290576dd86f4bcfb1553714ec4cabeda9246cfaf1e4f4323c" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/lr_schedulers.py": { + "bytes": 5554, + "sha256": "b954c12df413ca6676263ef1af960e2a4ff52692acfd4223e72a6e02122a3638" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/metrics/__init__.py": { + "bytes": 6730, + "sha256": "abec143d21e36343fff54db0ca8042111b9e70da426567b13ec36d6cb05696b6" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/metrics/chemistry.py": { + "bytes": 34172, + "sha256": "f4578f3c42ea39cdc8913f11b564ee729bb300abf62bbfa0e70c2549f831ebfe" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/metrics/distribution.py": { + "bytes": 3348, + "sha256": "0a6c25c4e65843308059a3effc44e1072837e747926074c1d7c47ba7ee72cb46" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/metrics/plotting.py": { + "bytes": 4270, + "sha256": "686d491d7fbc83cf35f772649a869dc1a271966d2a4fa6162d6e2334db735723" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/metrics/posecheck_strain.py": { + "bytes": 6695, + "sha256": "60a4751cce0e23d87d8602135de0c9850182fe97f20632cb9095445bdc6b0365" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/metrics/tensor.py": { + "bytes": 13503, + "sha256": "3fcb867cde5d47f81725acc3b862fc56af5ecce23dee3a11ebe6d05c1772c6fe" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/rdkit_utils.py": { + "bytes": 8769, + "sha256": "fdff24cdedbfa66d3def924e53acc36ff967525b4b469181fd888bcfab28c4f0" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/run_config.py": { + "bytes": 7936, + "sha256": "c055d95bfd259ec5a4fde5caa3d60cd11461b34731dc0f201de3cdeff214b8b3" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/training_monitor.py": { + "bytes": 2142, + "sha256": "d4a8e598d5dfc3ded5803f48e1b3bb9efaf3b6647a49ff03e32fe696cb11c836" + }, + "chembl3d/explicit_h/runtime/src/morph/utils/vocab_utils.py": { + "bytes": 6343, + "sha256": "7b5506971b5092865b86311173b24d7fb70a881f123c57fd8696c412a25d618b" + }, + "chembl3d/explicit_h/runtime/uv.lock": { + "bytes": 601648, + "sha256": "2e673b0aa80e9f47ee53bd92e0181cb46a83dfd5768fdf002c8c417ca2eb9378" + }, + "chembl3d/explicit_h/training_config.yaml": { + "bytes": 12699, + "sha256": "7a6a2d312629d3667181d8df5aa3bc895a2c5e038f1245a115bce735dbb5f0c3" + }, + "chembl3d/explicit_h/validation.json": { + "bytes": 1090, + "sha256": "9f64f3517f0237574d49fa26fc180e5a3b045c24671b32781f08979eb4ae2225" + }, + "chembl3d/implicit_h/atom_tokens.txt": { + "bytes": 28, + "sha256": "c7b8859e8b0fd79dfe48603073fcf10ded2d93da0e8de7a63fcc56515ab4d28f" + }, + "chembl3d/implicit_h/charge_tokens.txt": { + "bytes": 7, + "sha256": "e8f533e310170a31a7e5e5fe125447fbe0253de205966ae295713c98c6ac531c" + }, + "chembl3d/implicit_h/config.yaml": { + "bytes": 10335, + "sha256": "c510dd1d253cc80e03842259b3fb1b6226860cc65853644227e09587f46e9dc8" + }, + "chembl3d/implicit_h/distributions.json": { + "bytes": 300638, + "sha256": "e48bce8ad2290ce50db04b2a96cea72cb8b87457c82b979cacee8a7e2f923a53" + }, + "chembl3d/implicit_h/distributions.pt": { + "bytes": 89925, + "sha256": "ba4101ee7f3284dd2087b15573f1730aaa70ee808d874ced396fcd64d55a66d5" + }, + "chembl3d/implicit_h/edge_tokens.txt": { + "bytes": 16, + "sha256": "42b635ef938d2ec5a601044e4b01f3f84ed121f1bfaac984d86f180a8ccba3fd" + }, + "chembl3d/implicit_h/epoch_100.ckpt": { + "bytes": 509282633, + "sha256": "df34096b7b25342afb1c429cc6c491a0bc1b121f6735f411cd70acb2ac9b747a" + }, + "chembl3d/implicit_h/epoch_200.ckpt": { + "bytes": 509282633, + "sha256": "b5e13c261bff867ecb376df8fe369e85bafb4935ecaa343c5da83f6820458112" + }, + "chembl3d/implicit_h/epoch_300.ckpt": { + "bytes": 509282633, + "sha256": "ec003ebc2e6412b3fcfdf05b9de145f411d602974d5f83b08c55e2e74742184e" + }, + "chembl3d/implicit_h/metadata.json": { + "bytes": 1781, + "sha256": "17cefde98be2377f5390bb2f4391ef54f1b7127bb3de1f24790724288bbc655c" + }, + "chembl3d/implicit_h/runtime/README.md": { + "bytes": 112, + "sha256": "94207bc93e666b081d37d3ad55f2e5b34bd11d8fcfbe98be029e01ec870b5bd4" + }, + "chembl3d/implicit_h/runtime/configs/callbacks/callbacks.yaml": { + "bytes": 699, + "sha256": "dce0adebf5956dce687d64a074238f9c12866373bfe21df6658d967d6bfd1f90" + }, + "chembl3d/implicit_h/runtime/configs/data/chembl3d/chembl3d.yaml": { + "bytes": 1205, + "sha256": "d7d74b4c0612f5123e47d7f3d815bd60106f274b2428519836788297c0a127db" + }, + "chembl3d/implicit_h/runtime/configs/data/chembl3d/implicit_h.yaml": { + "bytes": 775, + "sha256": "f642fd18c835357d9ea05eb6c98be66b42b154b009c557b309f9876a1935c37a" + }, + "chembl3d/implicit_h/runtime/configs/data/chembl3d/max100.yaml": { + "bytes": 252, + "sha256": "3ca6e6f14cb57c9c3098e7b67d6284ca985e3cffdef39ea7d6312578e4bea888" + }, + "chembl3d/implicit_h/runtime/configs/data/geom/geom.yaml": { + "bytes": 4001, + "sha256": "751ba5dd5851da2d4887b9e5d622627a52bf0e783ca3097844d3233bd659354d" + }, + "chembl3d/implicit_h/runtime/configs/data/geom/scaffold_decoration.yaml": { + "bytes": 1107, + "sha256": "d317728a98961e98a1f03bcc3b0f8c513169ca23b68ea07ba807b99066e7ca80" + }, + "chembl3d/implicit_h/runtime/configs/data/qm9/qm9.yaml": { + "bytes": 3648, + "sha256": "3beea93868a1e889b049dcc2ccbc3b7489fa4672e964138654bc512ea01ab9f2" + }, + "chembl3d/implicit_h/runtime/configs/data/qm9/scaffold_decoration.yaml": { + "bytes": 890, + "sha256": "40b36f2bc25bf4052cdd676a26d9e528cb4f7b13deddbb1660f454f97b159c40" + }, + "chembl3d/implicit_h/runtime/configs/default.yaml": { + "bytes": 1294, + "sha256": "bcf9744acbf53afa4305ffd6e9cf95e6f99571750488eaa8cad85896a51aca66" + }, + "chembl3d/implicit_h/runtime/configs/defaults/chembl3d/chembl3d.yaml": { + "bytes": 90, + "sha256": "f09553bc670e6c87ac8c1f8bbd0a8df73f1e83b02a3dcf9d6d06b915ac3bce93" + }, + "chembl3d/implicit_h/runtime/configs/defaults/chembl3d/implicit_h_2x4090.yaml": { + "bytes": 324, + "sha256": "b1e940c1a99b5e657ea289c80e7e9676f6661bccdf2416cce2141aa0c09afeac" + }, + "chembl3d/implicit_h/runtime/configs/defaults/chembl3d/train_2x4090.yaml": { + "bytes": 929, + "sha256": "23feda2e93acf718152f4df387a54ec3be81b875a8cfbfc8dc8caf84c21a4ee9" + }, + "chembl3d/implicit_h/runtime/configs/defaults/core.yaml": { + "bytes": 431, + "sha256": "0dcfb68e2fece1dc277f647d3b3769af882cf8175e0c3b479bc50029e2538b1c" + }, + "chembl3d/implicit_h/runtime/configs/defaults/geom/geom.yaml": { + "bytes": 137, + "sha256": "ae774057727cddbc5a4db883a1c53253534da5b10fb7bb8d4cec7c576d61e5d1" + }, + "chembl3d/implicit_h/runtime/configs/defaults/geom/scaffold_decoration.yaml": { + "bytes": 828, + "sha256": "bcce546bce2b0b7bfaf7c0663e05423fd03c952bf6e52cbbf31976723661f0d1" + }, + "chembl3d/implicit_h/runtime/configs/defaults/qm9/qm9.yaml": { + "bytes": 132, + "sha256": "f83bd8e10802df07c2b1933dd40597807e3ee8a8cc39349ab14eb6f722f8475e" + }, + "chembl3d/implicit_h/runtime/configs/defaults/qm9/scaffold_decoration.yaml": { + "bytes": 348, + "sha256": "3bb8b6a757ea4921c31df2c0869949baef703df980b964d9636f52b64ac39267" + }, + "chembl3d/implicit_h/runtime/configs/embeddings/geom.yaml": { + "bytes": 803, + "sha256": "06793f4d446d899197e3db28d064592af9049612ee78cb0718a63e3d6fdc3151" + }, + "chembl3d/implicit_h/runtime/configs/embeddings/qm9.yaml": { + "bytes": 849, + "sha256": "de57af7f1ff9b44dbb952f852c0177789196105b77f396298ff8e0218c0626b7" + }, + "chembl3d/implicit_h/runtime/configs/heads/head.yaml": { + "bytes": 2475, + "sha256": "238e2346041e0aedccd16a6ed4066e163004c0e45a2cbc6fd1f6d17e16eb44ed" + }, + "chembl3d/implicit_h/runtime/configs/insertion_head/gmm.yaml": { + "bytes": 488, + "sha256": "1e26914eed9b4971e0f6ff0b61bbdb34103233d346719a55ba6273dd3d785c05" + }, + "chembl3d/implicit_h/runtime/configs/insertion_head/unimodal.yaml": { + "bytes": 495, + "sha256": "547a6391c3b341e3e369f8f4c7aab9752c67dfbbb533388d25956e9011b305e8" + }, + "chembl3d/implicit_h/runtime/configs/integrator/integrator.yaml": { + "bytes": 1580, + "sha256": "8f0d9aafb0b2727cd58154e1fd97df693c8b7d4f5a3fb5efcb9188553f54660f" + }, + "chembl3d/implicit_h/runtime/configs/logging/wandb_logger.yaml": { + "bytes": 139, + "sha256": "a9a2787e048d4eb70f2ff3c49d4355f1a5a382047c8619203b1f9131d829928d" + }, + "chembl3d/implicit_h/runtime/configs/loss_weights/calibrated_geom.yaml": { + "bytes": 644, + "sha256": "d4763c84aa9e1eb77fb2b456384332a4b3a84b072d560918b2357222a43f4ca1" + }, + "chembl3d/implicit_h/runtime/configs/loss_weights/calibrated_geom_frozen.yaml": { + "bytes": 1141, + "sha256": "a08acb61d67a852051dbded08f8bb67f21101888fd0a22a96a522781d28c570d" + }, + "chembl3d/implicit_h/runtime/configs/loss_weights/tuned_weights.yaml": { + "bytes": 420, + "sha256": "2bb5884465666d6fc638f787fd79257e4126c69a5cc55b55615fc45e02e65894" + }, + "chembl3d/implicit_h/runtime/configs/loss_weights/uniform.yaml": { + "bytes": 336, + "sha256": "3c14d6443048f69d436fe03792703a33c67f368ecbb349de2fd145da4c96eabc" + }, + "chembl3d/implicit_h/runtime/configs/model/backbones/dit.yaml": { + "bytes": 4039, + "sha256": "65b0f08be8fb307b8b01e7b460fd083fe60d48148fbece378d7e5a23dd4a9fb0" + }, + "chembl3d/implicit_h/runtime/configs/model/finetune/lora_finetune.yaml": { + "bytes": 328, + "sha256": "6334a5112499c0f3bc1fe5de224e2b6b3265dc524c8d6b9048113868fb864558" + }, + "chembl3d/implicit_h/runtime/configs/model/finetune/scaffold_finetune.yaml": { + "bytes": 5659, + "sha256": "1aad0d33e9b5ae95d9f50b5201a29bb8677fe42ea2bcf59e0e61d053ef1dfddb" + }, + "chembl3d/implicit_h/runtime/configs/optimizer/muon_aux_adam.yaml": { + "bytes": 1212, + "sha256": "3b0a6189f3ba81f85f7d26c6f6ad7f64dcb25098a14e411211d7a62983a7180c" + }, + "chembl3d/implicit_h/runtime/configs/schedule/beta.yaml": { + "bytes": 78, + "sha256": "75c485edfd70016b255282adcf5de25dfb95c9e32bb6587017fb8935ffe64eaa" + }, + "chembl3d/implicit_h/runtime/configs/schedule/cubic.yaml": { + "bytes": 54, + "sha256": "a998a2d3390ab5daf4df079852ae8daa907687cf059f55c500409c9493949c0c" + }, + "chembl3d/implicit_h/runtime/configs/schedule/fast_power.yaml": { + "bytes": 126, + "sha256": "22f36106bd43ca253ffea412e00539f2b07efbc49109b97c1232df6a827a2baf" + }, + "chembl3d/implicit_h/runtime/configs/schedule/linear.yaml": { + "bytes": 55, + "sha256": "f7881af26080ab5569e67b39f6eae2733c033b9623020e6271959f09fac45fa0" + }, + "chembl3d/implicit_h/runtime/configs/schedule/smoothstep.yaml": { + "bytes": 70, + "sha256": "f6e8651953d93f0d18c997d40c199b3d80ddd352a2041cc51e29adc9a7f22778" + }, + "chembl3d/implicit_h/runtime/configs/trainer/trainer.yaml": { + "bytes": 1402, + "sha256": "6f78e83ad46529bee7a2878b20727ff403420a515c1629bd73a608a2fb654f5f" + }, + "chembl3d/implicit_h/runtime/pyproject.toml": { + "bytes": 4433, + "sha256": "a67c11df733a8fe8d5a2351e2d4dbd0c4554e3c7564378b69df05b68485e790e" + }, + "chembl3d/implicit_h/runtime/requirements-tested.txt": { + "bytes": 311, + "sha256": "8936ae2284ca10a995df625fa4a33f88949bf6884a5456e1c7cae164af0c8a32" + }, + "chembl3d/implicit_h/runtime/src/morph/__init__.py": { + "bytes": 79, + "sha256": "bb402ea5c6f79e569205cc2bf4168d06430753ce8499ca4ffb8614743626fd59" + }, + "chembl3d/implicit_h/runtime/src/morph/__main__.py": { + "bytes": 149, + "sha256": "95a7983479559ca2b15c7afa3881b3142ef4636583501184469b972df7646d4f" + }, + "chembl3d/implicit_h/runtime/src/morph/_vendor/__init__.py": { + "bytes": 72, + "sha256": "8480cd966a03870a7142e1763094bf00e221d2c3c2b1af1d4d458fb8b7f37a20" + }, + "chembl3d/implicit_h/runtime/src/morph/_vendor/geom_drugs_preprocessing.py": { + "bytes": 11328, + "sha256": "33a9c21d29df269a5f342a0b0e8bced0fc620559f0cb3a7a4cfabd5c369ceccb" + }, + "chembl3d/implicit_h/runtime/src/morph/_vendor/muon.py": { + "bytes": 16405, + "sha256": "c61af8c2c4a635cc682b84e279851cb54fedc6fb935d8eb11aafbeb1c5e81fc6" + }, + "chembl3d/implicit_h/runtime/src/morph/cli.py": { + "bytes": 3711, + "sha256": "ac05049bba821c131267d294c747b5f4242fedab0709459799ff0f73a0443211" + }, + "chembl3d/implicit_h/runtime/src/morph/config.py": { + "bytes": 704, + "sha256": "bd3562c030611f87cc514308f155ad93740dd59927732f71b2e15009012d78ff" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/__init__.py": { + "bytes": 620, + "sha256": "44ae493354fed0fec4b410f46e0c1fb0351b5fbe5f404ff50f77181e60a39cee" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/conditional/__init__.py": { + "bytes": 238, + "sha256": "e46c747069acbf3d82407ccfcf96f2e33608c010a429f9abdb064bb71795d134" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/__init__.py": { + "bytes": 167, + "sha256": "6ef414b31cceb7303c174d720e0d0666efd6f6678c97b3cf86d33c799d2153dd" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/cutters.py": { + "bytes": 12282, + "sha256": "b29e0d7b1758461cda868bcba56d58b21ce76a45a9365bed73f7a2735103616f" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/datamodule.py": { + "bytes": 1402, + "sha256": "ecd4a5819d3654f8f76e3e0d059a60bfc9d0ac2ec343514d45517e7094c7c282" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/lmdb.py": { + "bytes": 4252, + "sha256": "0055352099c32b5ab6921861a716fbeb0b24bfe3b6b3adba05e990003224a5f9" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/conditional/scaffold/wrappers.py": { + "bytes": 7720, + "sha256": "a44d61452b676dc1dfd8d6bd44c09b22d463c8129b23cf98ec3903d10a693f2c" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/__init__.py": { + "bytes": 376, + "sha256": "7bab9c675f0850efc50e14a9898accfde5f8d1c1e2420414ac8c0986822d27e0" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/atomic_data.py": { + "bytes": 708, + "sha256": "7d59ec16ed5b3cf82b57b49dd4d7799862709353a65f4d473d4571ba91b86fa6" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/batch.py": { + "bytes": 23924, + "sha256": "7b2b2afd749b621b1ca890d192e4dc9378b61ead9ef34b90f04dc449e6bad369" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/containers.py": { + "bytes": 19258, + "sha256": "ebc3c190f56551c95ad278fd769a5a88b6fb93bba02760f3899c553d1e55656d" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/flow_matching_wrapper.py": { + "bytes": 7568, + "sha256": "7672ce6dc416bbccae1471b8e4fb66f44adf7eaf8f0f2fa151f1268e9513a17d" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/insertion_data.py": { + "bytes": 14393, + "sha256": "7e99e4d59b29ea537708b5f7887ed3e61b4b31ebf80d55fe5a0c6823ff8a439c" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/insertion_targets.py": { + "bytes": 6614, + "sha256": "2e62e89125310572735d916e97c8a544cfb3eb76fb167ecd134b5f8b0424df8c" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/lightning_datamodule.py": { + "bytes": 7057, + "sha256": "4d525cbcde4a64d7cac41837e0379c40097cc38fbbc2f54ff30ff7e3ab83de4c" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/preprocessing.py": { + "bytes": 21599, + "sha256": "942e14db48190ecc9fe39f5880bd2ee7e351e21271b12e16dc2b9dec0c1703fa" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/properties.py": { + "bytes": 1952, + "sha256": "4dc63574f5fde284245078adf50fb2d8a9ac16443b8276154c03b7703cc2e937" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/representation.py": { + "bytes": 6591, + "sha256": "0ecac55e1faf9748826ec8f4c7f1a274263214ea3f68cc69de7047b55dd6a65f" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/tokenized_decode.py": { + "bytes": 3670, + "sha256": "907639f6ae51e1eac109f4ce5d877952da18f1f3f2fe25e6b41f463d4b52dba4" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/core/vocab.py": { + "bytes": 2236, + "sha256": "f8618488f7bcdc8a58d8b9f3443b764eac60985a33ead1bcc2d17d3bdc213329" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/__init__.py": { + "bytes": 403, + "sha256": "d7cd3b85745ad1a4d884c0a11a64b3ab9cedcc64e30f604dbe83f5beb14fa97b" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d.py": { + "bytes": 15969, + "sha256": "679676d400c4b60903ab4a6e430babdcea0067099bd2c7d8f60499f4a3463823" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d_filter.py": { + "bytes": 7695, + "sha256": "f2774ac26de41ec1fed10fdd57fcc5238794eaf557ba32f5f79d98448a4a6cbf" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/chembl3d_implicit.py": { + "bytes": 12351, + "sha256": "80e46ce56ac303a0ef2c3c5dad6b0a81d060434eca1df2c4231a07349cc46830" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/geom.py": { + "bytes": 19754, + "sha256": "bbfea2f96d014afaa1dc60e94303faac6a41c6036e1f9ee3ed58279798d86524" + }, + "chembl3d/implicit_h/runtime/src/morph/dataset/unconditional/qm9.py": { + "bytes": 9520, + "sha256": "06b6086cb1234b283cd3a45daad2e06d3bdb5d45810b873f860c82969ccbfe53" + }, + "chembl3d/implicit_h/runtime/src/morph/flow_matching/__init__.py": { + "bytes": 78, + "sha256": "674f4630aa8421d7e75c913741c6efb1a163fbc38ce909c6cdb011f9ba9ef48e" + }, + "chembl3d/implicit_h/runtime/src/morph/flow_matching/assignment.py": { + "bytes": 16050, + "sha256": "b6ad66538f5bd7f14e0eee2a0ffd25a4110d1504e057a0a9eb63a354d9f6aadb" + }, + "chembl3d/implicit_h/runtime/src/morph/flow_matching/integration.py": { + "bytes": 33356, + "sha256": "f64b7b9ed2a79c11932b72e498e57eef924b5c85a05aa6893ddfe11e2d4595e9" + }, + "chembl3d/implicit_h/runtime/src/morph/flow_matching/interpolation.py": { + "bytes": 22169, + "sha256": "6ef2e03626fd4b3194c68df6e2410ab6e630f4891f97a8328b4eb0b3e18c7dcd" + }, + "chembl3d/implicit_h/runtime/src/morph/flow_matching/prior.py": { + "bytes": 2389, + "sha256": "0f9b77274238b23be7a2ad88ee8e6dcbb785817fcfa3a35aa863b489d2a6e05d" + }, + "chembl3d/implicit_h/runtime/src/morph/flow_matching/sampling_step.py": { + "bytes": 2806, + "sha256": "c768682a47da1b7852838e20b39317528717fc0485cbc35030b3ca71d7d845bb" + }, + "chembl3d/implicit_h/runtime/src/morph/flow_matching/schedules.py": { + "bytes": 6776, + "sha256": "6fd8396a05052ae7831babc75ca40a7e748fb9edffe82bb2559533afa949b2a9" + }, + "chembl3d/implicit_h/runtime/src/morph/inference/__init__.py": { + "bytes": 461, + "sha256": "c3a77e08bf1a779bd114dcbebd9225598c4525bcb879add848a9b107f7cb9ec7" + }, + "chembl3d/implicit_h/runtime/src/morph/inference/generate.py": { + "bytes": 11072, + "sha256": "d5ba369c7364cca92c966c4f286d705ab43f21f2c30c61d1ebcdd184d3ed18db" + }, + "chembl3d/implicit_h/runtime/src/morph/inference/registry.py": { + "bytes": 2588, + "sha256": "8b86381194abb69a8131e2e94f8cf28c7802971f484056a5f9c0d3db01ff16c3" + }, + "chembl3d/implicit_h/runtime/src/morph/model/README.md": { + "bytes": 10319, + "sha256": "9c06b5b90300f7449048640417116aa8869dc1a75f106cbdd9344a89f6e446d3" + }, + "chembl3d/implicit_h/runtime/src/morph/model/__init__.py": { + "bytes": 80, + "sha256": "1c7c3875387d5ba7f9166a804ea5a7ef7bd469728c1ad5c5003150d44e33be51" + }, + "chembl3d/implicit_h/runtime/src/morph/model/backbones/__init__.py": { + "bytes": 64, + "sha256": "a1a1419590c11aa6ed429f8b6b293470e30fe767f5571d2517b0883efb071e0c" + }, + "chembl3d/implicit_h/runtime/src/morph/model/backbones/conditional_dit.py": { + "bytes": 25451, + "sha256": "411fe1d0fd17c24e03e153d57f299aa0436bc5749e2064d5574681878429795d" + }, + "chembl3d/implicit_h/runtime/src/morph/model/backbones/dit.py": { + "bytes": 31262, + "sha256": "e2798baaf31bc8004a17c2432c56af9c4b12651d259cc0deaf5f18c83c6ddd33" + }, + "chembl3d/implicit_h/runtime/src/morph/model/build.py": { + "bytes": 3442, + "sha256": "e69f4231815f09c094d787f11edc49e222cbdc970edc6e416b6b94a9b88d4b21" + }, + "chembl3d/implicit_h/runtime/src/morph/model/finetune/__init__.py": { + "bytes": 282, + "sha256": "8746b1e29e5a3b3b1805389257270b90f5d30995783f2cf41b9fb533c485fc0c" + }, + "chembl3d/implicit_h/runtime/src/morph/model/finetune/base/__init__.py": { + "bytes": 803, + "sha256": "6e0ded0b3a5c2a76ccb85b05cf63bcc54e145bd1be07d933a737d6151433d856" + }, + "chembl3d/implicit_h/runtime/src/morph/model/finetune/base/conditional_backbone.py": { + "bytes": 2853, + "sha256": "1353e96443eacf3ce88473a30d02ab4f1c8b6a9c90763ea61c58d353b806b378" + }, + "chembl3d/implicit_h/runtime/src/morph/model/finetune/base/context_encoder.py": { + "bytes": 15124, + "sha256": "8d5dfc91ec73120161ed0a51dcc7edac442004ad4d5213ba34d9d11cfd1eee15" + }, + "chembl3d/implicit_h/runtime/src/morph/model/finetune/base/finetune_base.py": { + "bytes": 9526, + "sha256": "2efa00fd8eed1096a9cf8799dee4abfd8b27de1c0600e22a906dddaa089c4ca0" + }, + "chembl3d/implicit_h/runtime/src/morph/model/finetune/scaffold/__init__.py": { + "bytes": 366, + "sha256": "6345e71c8b03ee5eb0dcd105c0532acf73a1795bf6360b2847be600bcecd40ba" + }, + "chembl3d/implicit_h/runtime/src/morph/model/finetune/scaffold/scaffold.py": { + "bytes": 1984, + "sha256": "6de7a6083b4303ae422f03f15b5b40b1233d48bfc2dd1e0aa83a4eaa8adf40dd" + }, + "chembl3d/implicit_h/runtime/src/morph/model/finetune/scaffold/scaffold_context.py": { + "bytes": 6086, + "sha256": "d9446254770e3c3ea2c634a0636ab82a718514a7cb69760d756e9235cfbe2814" + }, + "chembl3d/implicit_h/runtime/src/morph/model/layers/__init__.py": { + "bytes": 189, + "sha256": "79671fbdea358c330a45b7fd3d842119b32b1d548804b3d441bbfb7dbd703b69" + }, + "chembl3d/implicit_h/runtime/src/morph/model/layers/attention.py": { + "bytes": 21038, + "sha256": "7efc416ac8fec0236909661ad0693a0aef613ff97a964fb6d8252cb49c60b5c6" + }, + "chembl3d/implicit_h/runtime/src/morph/model/layers/embedding.py": { + "bytes": 14356, + "sha256": "efe924190da5904e05495f421dcc313134cebe0dee9bf665060e1e5781cd95ef" + }, + "chembl3d/implicit_h/runtime/src/morph/model/layers/gmm.py": { + "bytes": 6753, + "sha256": "628e90c73bdbb3e70104214f80bbf8a94896ddbe22a9a3fc40cce34b20d2565f" + }, + "chembl3d/implicit_h/runtime/src/morph/model/layers/heads.py": { + "bytes": 30913, + "sha256": "7113c29a6be81c93ca980dc19d0847d2015ac36702683e4f1adedd549aed46ae" + }, + "chembl3d/implicit_h/runtime/src/morph/model/layers/kernels.py": { + "bytes": 12483, + "sha256": "be0c52b69c7612946244755e2865de0491440a41163cf828168eb750a8f929cf" + }, + "chembl3d/implicit_h/runtime/src/morph/model/layers/lora.py": { + "bytes": 5947, + "sha256": "7194750605e7b5df9a02e4c4a39cf29c982f117c40198c99c73211c85ecdfe4b" + }, + "chembl3d/implicit_h/runtime/src/morph/model/lightning/__init__.py": { + "bytes": 339, + "sha256": "c53943a4eb1e45568cebfb538655cc5eb7ead46284bbcd515800102c89177e52" + }, + "chembl3d/implicit_h/runtime/src/morph/model/lightning/_optimizer.py": { + "bytes": 19578, + "sha256": "f77519db8d3458c05aec1543cb4cd487995cf398abcc71b62ea1835cb132165e" + }, + "chembl3d/implicit_h/runtime/src/morph/model/lightning/_sampling.py": { + "bytes": 14773, + "sha256": "f71ebbb0c230366bf4bb2a2d0da715a1c523120ac3c48e863a2fed27e35c421c" + }, + "chembl3d/implicit_h/runtime/src/morph/model/lightning/_setup.py": { + "bytes": 6537, + "sha256": "835f510094110c4dbc590a88d9b94d41474ae87b442785a949328feda5b9afce" + }, + "chembl3d/implicit_h/runtime/src/morph/model/lightning/_training.py": { + "bytes": 23197, + "sha256": "8f412ce29ba758a1717aa509b953659c41b5ce02693f75949469e5146eaa73b3" + }, + "chembl3d/implicit_h/runtime/src/morph/model/lightning/_validation.py": { + "bytes": 11509, + "sha256": "03890457e504eed96f23dd8633a6af4317a9ed61e80b3e4ec935aaf9e63038d7" + }, + "chembl3d/implicit_h/runtime/src/morph/model/lightning/base.py": { + "bytes": 9294, + "sha256": "ce9159e7b387ece0a3ce010b164f675b81980d2f617b5b05d00f96695ee33e28" + }, + "chembl3d/implicit_h/runtime/src/morph/model/losses/__init__.py": { + "bytes": 190, + "sha256": "bb9de5950496ed184a32e11834d8e6f3b7d406773509ffa6c406a86bb5249fae" + }, + "chembl3d/implicit_h/runtime/src/morph/model/losses/functional.py": { + "bytes": 11765, + "sha256": "42ae52f604a0cb8b7fe08955cfd273945ca068727994f20b02c80cdee3a00e1b" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/__init__.py": { + "bytes": 78, + "sha256": "eb76ca86f046931eaea693a696317e8b3ce0b8ad8695a92e3744fd1892f2b9b8" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/checkpoints.py": { + "bytes": 2301, + "sha256": "750302eb039e3947a5d4c84e4e7d04eb46e974cc0031ef1d197a3573e20eae4b" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/edge_alignment.py": { + "bytes": 4896, + "sha256": "0edcde93cfa3a2d825e783ea5cf64715aa248e59ffb8c6a23d4490d74976a71e" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/graph_math.py": { + "bytes": 6755, + "sha256": "2bae326c3538d757aac66dcfc4a0b5d023b17bd987d814d3dab9a57f9ce60552" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/loss_accumulation.py": { + "bytes": 15115, + "sha256": "0f3eca42145e715290576dd86f4bcfb1553714ec4cabeda9246cfaf1e4f4323c" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/lr_schedulers.py": { + "bytes": 5554, + "sha256": "b954c12df413ca6676263ef1af960e2a4ff52692acfd4223e72a6e02122a3638" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/metrics/__init__.py": { + "bytes": 6730, + "sha256": "abec143d21e36343fff54db0ca8042111b9e70da426567b13ec36d6cb05696b6" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/metrics/chemistry.py": { + "bytes": 34152, + "sha256": "61b598b99fad41571f9334b28f94fbeeeb0282d1b80423b1f2bc8f14180884e8" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/metrics/distribution.py": { + "bytes": 3348, + "sha256": "0a6c25c4e65843308059a3effc44e1072837e747926074c1d7c47ba7ee72cb46" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/metrics/plotting.py": { + "bytes": 4270, + "sha256": "686d491d7fbc83cf35f772649a869dc1a271966d2a4fa6162d6e2334db735723" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/metrics/posecheck_strain.py": { + "bytes": 6695, + "sha256": "60a4751cce0e23d87d8602135de0c9850182fe97f20632cb9095445bdc6b0365" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/metrics/tensor.py": { + "bytes": 13503, + "sha256": "3fcb867cde5d47f81725acc3b862fc56af5ecce23dee3a11ebe6d05c1772c6fe" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/rdkit_utils.py": { + "bytes": 9185, + "sha256": "7607eb79964d2f97c8eb62cdc598e47536aac7b0ca3b3086113adf99176e379c" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/run_config.py": { + "bytes": 8025, + "sha256": "0c289120887a051379523af528ebc9310f356f3b92efb898ddaf773dbf698aa2" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/training_monitor.py": { + "bytes": 2142, + "sha256": "d4a8e598d5dfc3ded5803f48e1b3bb9efaf3b6647a49ff03e32fe696cb11c836" + }, + "chembl3d/implicit_h/runtime/src/morph/utils/vocab_utils.py": { + "bytes": 6343, + "sha256": "7b5506971b5092865b86311173b24d7fb70a881f123c57fd8696c412a25d618b" + }, + "chembl3d/implicit_h/training_config.yaml": { + "bytes": 12555, + "sha256": "0f1934ad9a551486b00b2afd3598ef787661ca603ef89da65a5013979f3c6c5d" + }, + "chembl3d/implicit_h/validation.json": { + "bytes": 1052, + "sha256": "69c74535c38ccbe821583f3ae89e3bebfa461f4563bfcd0303d5ad1e84490e68" + }, + "sample.py": { + "bytes": 4886, + "sha256": "0105e6b0b648ab4c2c7761180ea7c274e0def24f219b9d3bce8501df48dc71db" + } +} diff --git a/sample.py b/sample.py new file mode 100644 index 0000000000000000000000000000000000000000..33f3b33e55a5cd1391dc6fd257171b3e1e552666 --- /dev/null +++ b/sample.py @@ -0,0 +1,115 @@ +"""Dataset-free sampling from a downloaded maltefranke/morph bundle.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import sys + + +def load_bundle(root, variant, epoch, device="cpu", steps=100): + """Load the exact bundled runtime, statistics, current weights and EMA.""" + root = Path(root).resolve() + bundle = root / "chembl3d" / variant + runtime = bundle / "runtime" + if "morph" in sys.modules: + raise RuntimeError("Load each runtime in a fresh Python process.") + sys.path.insert(0, str(runtime / "src")) + os.environ.setdefault("MORPH_COMPILE", "0") + os.environ.setdefault("MORPH_ATTENTION_BACKEND", "sdpa") + os.environ.setdefault("MORPH_FLASH_ATTN", "0") + os.environ.setdefault("MORPH_MUON_EXECUTION", "native") + + import torch + from omegaconf import OmegaConf + import morph.config # registers the training config's resolvers + from morph.inference.generate import _build_module, load_checkpoint + + if device == "auto": + device = "cuda" if torch.cuda.is_available() else "cpu" + cfg = OmegaConf.load(bundle / "config.yaml") + cfg.data.preprocessing.root = str(bundle) + for field, filename in { + "atom_tokens_path": "atom_tokens.txt", + "edge_tokens_path": "edge_tokens.txt", + "charge_tokens_path": "charge_tokens.txt", + "distributions_path": "distributions.pt", + }.items(): + path = bundle / filename + if not path.is_file(): + raise FileNotFoundError(path) + cfg.data.preprocessing[field] = str(path) + cfg.integrator.integrator.num_integration_steps = steps + module, vocab, distributions = _build_module(cfg) + checkpoint = bundle / f"epoch_{epoch:03d}.ckpt" + load_checkpoint(module, checkpoint) + module.eval().to(device) + return module, vocab, distributions, cfg + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parent) + parser.add_argument("--variant", choices=["explicit_h", "implicit_h"], required=True) + parser.add_argument("--epoch", type=int, required=True) + parser.add_argument("--num-mols", type=int, default=16) + parser.add_argument("--batch-size", type=int, default=16) + parser.add_argument("--steps", type=int, default=100) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") + parser.add_argument("--output", type=Path, default=Path("molecules.sdf")) + args = parser.parse_args() + if min(args.num_mols, args.batch_size, args.steps) < 1: + parser.error("num-mols, batch-size, and steps must be positive") + + import torch + + torch.manual_seed(args.seed) + module, vocab, _, cfg = load_bundle( + args.root, args.variant, args.epoch, args.device, args.steps + ) + from morph.inference.generate import _prior_loader, final_mol + from rdkit import Chem + + loader = _prior_loader( + module.distributions, args.num_mols, args.batch_size, + uniform_n_atoms_padding=int(cfg.data.uniform_n_atoms_padding), + n_atoms_strategy=str(cfg.data.n_atoms_strategy), + ) + records = [] + args.output.parent.mkdir(parents=True, exist_ok=True) + with Chem.SDWriter(str(args.output)) as writer, torch.inference_mode(): + for batch_index, batch in enumerate(loader): + batch = tuple(graph.to(module.device) for graph in batch) + result = module.predict_step(batch, batch_index) + for trajectory, valid in zip(result["all_mols"], result["is_valid"], strict=True): + frame = trajectory[-1] + if not torch.isfinite(frame.x).all(): + raise RuntimeError("Non-finite generated coordinates") + mol = final_mol(trajectory, vocab) + index = len(records) + records.append({ + "index": index, "valid": bool(valid), + "smiles": Chem.MolToSmiles(mol) if mol is not None else None, + "represented_atoms": int(frame.x.shape[0]), + }) + if valid and mol is not None: + mol.SetProp("_Name", f"sample_{index}") + writer.write(mol) + assert len(records) == args.num_mols + report = { + "variant": args.variant, "completed_epochs": args.epoch, + "seed": args.seed, "integration_steps": args.steps, + "weights": "EMA", "coordinate_units": "angstrom", + "num_attempted": len(records), + "num_valid": sum(row["valid"] for row in records), + "samples": records, + } + args.output.with_suffix(".json").write_text(json.dumps(report, indent=2) + "\n") + print(json.dumps({k: v for k, v in report.items() if k != "samples"}), flush=True) + + +if __name__ == "__main__": + main()