| from __future__ import annotations |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
|
|
| from pino.embeddings import OlfactoryEmbeddingEngine |
| from pino.heads import PIMTHeads |
| from pino.pimt_model import DEFAULT_EMBEDDING_DIM, PhysicsInformedMixtureTransformer |
|
|
|
|
| def test_ingredient_order_permutation_invariance() -> None: |
| """ |
| Shuffling the order of ingredient tokens in a formulation must leave the |
| transformer encoder's output unchanged, because the model uses no |
| positional encodings and attends over a permutation-invariant token set. |
| """ |
| engine = OlfactoryEmbeddingEngine(use_fallback=True) |
| smiles_list = ["CCO", "CC(=O)O", "c1ccccc1", "CC(C)Cc1ccc(C(C)C)cc1"] |
| embeddings = np.stack([engine.get_embedding(s) for s in smiles_list], axis=0) |
| embeddings = torch.from_numpy(embeddings).float() |
|
|
| |
| timesteps = 5 |
| states = torch.rand(timesteps, len(smiles_list), 4) |
|
|
| model = PhysicsInformedMixtureTransformer( |
| embedding_dim=DEFAULT_EMBEDDING_DIM, |
| state_dim=4, |
| hidden_dim=256, |
| num_heads=4, |
| num_layers=2, |
| ) |
| model.eval() |
| heads = PIMTHeads(hidden_dim=256, objective_dim=138).eval() |
|
|
| with torch.no_grad(): |
| latent = model(embeddings.unsqueeze(0), states.unsqueeze(0)) |
| pred = heads(latent, states.unsqueeze(0)) |
|
|
| perm = torch.randperm(len(smiles_list)) |
| perm_embeddings = embeddings[perm] |
| perm_states = states[:, perm, :] |
| latent_perm = model(perm_embeddings.unsqueeze(0), perm_states.unsqueeze(0)) |
| pred_perm = heads(latent_perm, perm_states.unsqueeze(0)) |
|
|
| assert torch.allclose(pred["objective"], pred_perm["objective"], atol=1e-5) |
| for key in ("seasonality", "gender_profile", "wearability", "substantivity"): |
| assert torch.allclose(pred["subjective"][key], pred_perm["subjective"][key], atol=1e-5) |
|
|
|
|
| def test_head_output_shapes() -> None: |
| """Verify both dual heads emit tensors of the expected shapes.""" |
| model = PhysicsInformedMixtureTransformer( |
| embedding_dim=138, state_dim=4, hidden_dim=256, num_heads=4, num_layers=2 |
| ) |
| heads = PIMTHeads(hidden_dim=256, objective_dim=138) |
| model.eval() |
| heads.eval() |
|
|
| bsz, t_steps, n_mol = 2, 7, 6 |
| tokens = torch.rand(bsz, n_mol, 138) |
| physics = torch.rand(bsz, t_steps, n_mol, 4) |
| |
| src_key_padding_mask = torch.zeros(bsz, n_mol, dtype=torch.bool) |
| src_key_padding_mask[:, -1] = True |
|
|
| with torch.no_grad(): |
| latent = model(tokens, physics, src_key_padding_mask) |
| pred = heads(latent, physics, src_key_padding_mask) |
|
|
| assert pred["objective"].shape == (bsz, 3, 138) |
| assert pred["subjective"]["seasonality"].shape == (bsz, 4) |
| assert pred["subjective"]["gender_profile"].shape == (bsz, 1) |
| assert pred["subjective"]["wearability"].shape == (bsz, 2) |
| assert pred["subjective"]["substantivity"].shape == (bsz, 1) |
|
|
|
|
| def test_objective_target_curve_physics() -> None: |
| """Check that the genre-specific volatility curves follow perfumery physics.""" |
| from pino.semantics import get_objective_targets |
|
|
| targets = get_objective_targets("citrus_cologne", timesteps=50) |
| |
| assert targets[0, 0] > targets[-1, 0] |
| |
| assert targets[-1, 2] > targets[0, 2] |
| |
| lavender_series = targets[:, 1] |
| peak_idx = int(np.argmax(lavender_series)) |
| assert 5 < peak_idx < 45, "Lavender heart note did not peak in mid-dry-down" |
|
|