File size: 3,684 Bytes
b233cf7
 
 
 
 
 
 
 
83db774
b233cf7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83db774
b233cf7
 
 
 
 
 
 
 
 
 
83db774
b233cf7
 
 
 
 
83db774
b233cf7
 
83db774
 
b233cf7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83db774
b233cf7
83db774
 
 
 
 
b233cf7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
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()

    # Synthetic physical states (T, S, 4).
    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)
    # Valid tokens are False; the last token is a zero-padded placeholder marked True.
    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)
    # Citrus top note should decay from start to end.
    assert targets[0, 0] > targets[-1, 0]
    # Wood base note should rise.
    assert targets[-1, 2] > targets[0, 2]
    # Lavender heart note should peak in the middle of the dry-down.
    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"