File size: 13,884 Bytes
b233cf7
 
680d9cb
 
 
 
 
b233cf7
 
680d9cb
b233cf7
680d9cb
b233cf7
680d9cb
 
 
b233cf7
680d9cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b233cf7
 
680d9cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b233cf7
680d9cb
b233cf7
680d9cb
 
 
 
b233cf7
680d9cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b233cf7
680d9cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b233cf7
680d9cb
b233cf7
680d9cb
 
 
 
 
 
b233cf7
680d9cb
 
 
 
 
 
 
 
 
 
b233cf7
 
 
680d9cb
 
 
b233cf7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680d9cb
b233cf7
 
 
 
 
680d9cb
 
b233cf7
 
 
 
ad424e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import Any

import numpy as np

logger = logging.getLogger("pino.semantics")

DESCRIPTOR_DIM = 138

# ---------------------------------------------------------------------------
# Vocabulary / annotation loading
# ---------------------------------------------------------------------------


def _project_data_dir() -> Path:
    """Return the project-level data/ directory."""
    # src/pino/semantics.py -> src/pino/ -> src/ -> project root
    return Path(__file__).resolve().parents[2] / "data"


def load_vocabulary(path: str | Path | None = None) -> list[str]:
    """Load the 138-D pyrfume descriptor vocabulary from disk."""
    path = Path(path) if path else _project_data_dir() / "pyrfume_vocabulary.json"
    if not path.exists():
        raise FileNotFoundError(f"Vocabulary file not found: {path}")
    data = json.loads(path.read_text(encoding="utf-8"))
    return data["vocabulary"]


def load_descriptor_annotations(path: str | Path | None = None) -> dict[str, list[float]]:
    """Load the CAS/SMILES -> 138-D binary descriptor vector mapping."""
    path = Path(path) if path else _project_data_dir() / "pyrfume_descriptors_by_cas.json"
    if not path.exists():
        raise FileNotFoundError(f"Descriptor annotations not found: {path}")
    return json.loads(path.read_text(encoding="utf-8"))


def load_odt_annotations(path: str | Path | None = None) -> dict[str, float]:
    """Load the CAS/SMILES -> ODT (mg/m³) mapping."""
    path = Path(path) if path else _project_data_dir() / "pyrfume_odt_by_cas.json"
    if not path.exists():
        raise FileNotFoundError(f"ODT annotations not found: {path}")
    return {k: float(v) for k, v in json.loads(path.read_text(encoding="utf-8")).items()}


# ---------------------------------------------------------------------------
# Per-compound lookup helpers
# ---------------------------------------------------------------------------


def _registry_key(cas: str | None, smiles: str | None) -> str | None:
    if cas and not cas.startswith("SMILES:"):
        return cas
    return smiles


def get_compound_descriptor_vector(
    cas: str | None,
    smiles: str | None,
    descriptor_map: dict[str, list[float]] | None = None,
) -> np.ndarray | None:
    """Return the 138-D binary descriptor vector for a compound, if known."""
    if descriptor_map is None:
        descriptor_map = load_descriptor_annotations()
    key = _registry_key(cas, smiles)
    if key is None:
        return None
    vec = descriptor_map.get(key)
    if vec is None:
        return None
    return np.array(vec, dtype=np.float32)


def get_compound_odt(
    cas: str | None,
    smiles: str | None,
    odt_map: dict[str, float] | None = None,
) -> float | None:
    """Return the ODT (mg/m³) for a compound, if known."""
    if odt_map is None:
        odt_map = load_odt_annotations()
    key = _registry_key(cas, smiles)
    if key is None:
        return None
    return odt_map.get(key)


# ---------------------------------------------------------------------------
# OAV-weighted mixture descriptor targets
# ---------------------------------------------------------------------------


def compute_oav_targets(
    composition: list[dict[str, Any]],
    concentrations: np.ndarray,
    *,
    descriptor_map: dict[str, list[float]] | None = None,
    odt_map: dict[str, float] | None = None,
    eps: float = 1e-12,
) -> np.ndarray:
    """
    Compute time-resolved OAV-weighted mixture descriptor vectors.

    Args:
        composition: list of compound dicts, each with at least 'cas' and/or 'smiles'.
        concentrations: (timesteps, n_compounds) array of instantaneous concentrations
            in mg/m³.

    Returns:
        targets: (timesteps, 138) array of mixture descriptor vectors.
    """
    timesteps, n_compounds = concentrations.shape
    if descriptor_map is None:
        descriptor_map = load_descriptor_annotations()
    if odt_map is None:
        odt_map = load_odt_annotations()

    # Build per-compound descriptor vectors and ODTs.
    descriptors: list[np.ndarray] = []
    odt_values: list[float] = []
    for comp in composition:
        cas = comp.get("cas")
        smiles = comp.get("smiles")
        vec = get_compound_descriptor_vector(cas, smiles, descriptor_map=descriptor_map)
        odt = get_compound_odt(cas, smiles, odt_map=odt_map)
        if vec is None or odt is None:
            # Compound lacks annotation; leave it out of the target.
            descriptors.append(np.zeros(DESCRIPTOR_DIM, dtype=np.float32))
            odt_values.append(1.0)  # dummy, will multiply by zero vector
            continue
        descriptors.append(vec)
        odt_values.append(odt)

    descriptors = np.vstack(descriptors)  # (n_compounds, 138)
    odt_values = np.array(odt_values, dtype=np.float32)  # (n_compounds,)

    # log10(OAV + 1) is used as a positive monotonic weight that equals log-OAV
    # for strongly supra-threshold compounds (OAV >> 1) while keeping sub-threshold
    # ingredients from collapsing the target to a zero vector.
    oav = concentrations / (odt_values[None, :] + eps)  # (timesteps, n_compounds)
    weights = np.log10(oav + 1.0)  # (timesteps, n_compounds)

    weighted = weights[:, :, None] * descriptors[None, :, :]  # (T, C, 138)
    targets = weighted.sum(axis=1)  # (T, 138)

    # Normalise each timestep vector to unit length (cosine-style target).
    norms = np.linalg.norm(targets, axis=1, keepdims=True)
    targets = np.divide(targets, norms, out=np.zeros_like(targets), where=norms > eps)

    return targets.astype(np.float32)


# ---------------------------------------------------------------------------
# Legacy / compatibility API
# ---------------------------------------------------------------------------


def get_objective_targets(
    generation_strategy: str,
    timesteps: int,
    *,
    composition: list[dict[str, Any]] | None = None,
    concentrations: np.ndarray | None = None,
    seed: int = 2026,
) -> np.ndarray:
    """
    Return a (timesteps, 138) objective odor-descriptor target matrix.

    If composition and concentrations are provided, the target is a real
    OAV-weighted mixture descriptor vector. Otherwise, a deterministic
    fallback profile is returned for compatibility.
    """
    if composition is not None and concentrations is not None:
        return compute_oav_targets(composition, concentrations)

    # Fallback: simple genre-agnostic descriptor profile for tests/back-compat.
    rng = np.random.default_rng(seed)
    tau = np.linspace(0.0, 1.0, timesteps, dtype=np.float32)
    targets = np.zeros((timesteps, DESCRIPTOR_DIM), dtype=np.float32)
    # Use a few broad descriptors to keep the tensor non-degenerate.
    targets[:, 0] = np.exp(-6.0 * tau)  # citrus / top decay
    targets[:, 1] = 4.0 * tau * np.exp(-4.0 * tau)  # floral / heart surge
    targets[:, 2] = 0.1 + 0.9 * (1.0 - np.exp(-3.0 * tau))  # woody / base rise
    targets[:, 3] = 0.1 + 0.9 * (1.0 - np.exp(-3.0 * tau))  # musk / base rise
    targets[:, 4:] = rng.uniform(0.0, 1e-4, size=(timesteps, DESCRIPTOR_DIM - 4)).astype(np.float32)
    return np.clip(targets, 0.0, 1.0).astype(np.float32)


# ---------------------------------------------------------------------------
# Weak-label semantic targets (kept for compatibility; derived from strategy)
# ---------------------------------------------------------------------------

GENRE_SEMANTICS: dict[str, dict[str, np.ndarray]] = {
    "citrus_cologne": {
        "seasonality": np.array([0.30, 0.50, 0.10, 0.10], dtype=np.float32),
        "gender_profile": np.array([0.0], dtype=np.float32),
        "wearability": np.array([0.85, 0.30, 0.70, 0.40], dtype=np.float32),
    },
    "fougere": {
        "seasonality": np.array([0.35, 0.25, 0.30, 0.10], dtype=np.float32),
        "gender_profile": np.array([-0.6], dtype=np.float32),
        "wearability": np.array([0.70, 0.60, 0.75, 0.65], dtype=np.float32),
    },
    "floral_woody": {
        "seasonality": np.array([0.40, 0.20, 0.30, 0.10], dtype=np.float32),
        "gender_profile": np.array([0.3], dtype=np.float32),
        "wearability": np.array([0.60, 0.70, 0.80, 0.70], dtype=np.float32),
    },
    "amber_oriental": {
        "seasonality": np.array([0.10, 0.05, 0.30, 0.55], dtype=np.float32),
        "gender_profile": np.array([-0.2], dtype=np.float32),
        "wearability": np.array([0.20, 0.95, 0.50, 0.90], dtype=np.float32),
    },
    "wildcard": {
        "seasonality": np.array([0.25, 0.25, 0.25, 0.25], dtype=np.float32),
        "gender_profile": np.array([0.0], dtype=np.float32),
        "wearability": np.array([0.50, 0.50, 0.50, 0.50], dtype=np.float32),
    },
}


def get_semantic_targets(generation_strategy: str) -> dict[str, np.ndarray]:
    """Return the weak-label semantic targets for a given generation strategy."""
    return GENRE_SEMANTICS.get(generation_strategy, GENRE_SEMANTICS["wildcard"])


def get_psychometric_targets(generation_strategy: str) -> np.ndarray:
    """Return a 7-D psychometric target vector."""
    base = get_semantic_targets(generation_strategy)
    season = base["seasonality"]
    gender = base["gender_profile"].reshape(-1)
    wear = base["wearability"]
    wear_compressed = np.array([
        (wear[0] + wear[2]) / 2.0,
        (wear[1] + wear[3]) / 2.0,
    ], dtype=np.float32)
    return np.concatenate([season, gender, wear_compressed]).astype(np.float32)


def compute_formula_psychometric_targets(
    formula: list[dict[str, Any]],
    pyramid_targets: np.ndarray,
    vocab: list[str] | None = None,
) -> np.ndarray:
    """Derive psychometric targets from actual formula composition.

    Instead of using hardcoded genre lookups, compute seasonality, gender,
    and wearability from the pyramid descriptor profile.

    - Gender: feminine descriptors (floral, rose, sweet, powdery) push negative;
      masculine descriptors (woody, leather, tobacco, smoky) push positive.
    - Wearability: base-note weight fraction determines day vs night orientation.
    - Seasonality: citrus/fresh/green content pushes summer; warm/spicy pushes winter.

    Args:
        formula: list of {cas, weight_fraction, ...} dicts.
        pyramid_targets: (3, 138) array of binary pyramid labels.
        vocab: optional vocabulary list for descriptor name lookup.

    Returns:
        7-D float32 vector: [spring, summer, autumn, winter, gender, day, night]
    """
    pyramid = np.asarray(pyramid_targets, dtype=np.float32)
    if pyramid.shape != (3, 138):
        return get_psychometric_targets("wildcard")

    if vocab is None:
        try:
            vocab = load_vocabulary()
        except Exception:
            vocab = [str(i) for i in range(138)]

    # Build descriptor name → index map
    desc_idx = {name: i for i, name in enumerate(vocab) if i < 138}

    def _desc_score(indices_of_names: list[str]) -> float:
        total = 0.0
        for name in indices_of_names:
            idx = desc_idx.get(name)
            if idx is not None:
                total += float(pyramid[2, idx]) * 0.5 + float(pyramid[1, idx]) * 0.3 + float(pyramid[0, idx]) * 0.2
        return total

    # --- Gender: feminine (negative) vs masculine (positive) ---
    feminine = _desc_score(["floral", "rose", "sweet", "powdery", "creamy", "vanilla",
                            "jasmine", "lily", "waxy", "fruity"])
    masculine = _desc_score(["leather", "tobacco", "smoky", "animal", "phenolic",
                             "earthy", "mossy", "spicy"])
    total_g = feminine + masculine
    gender = np.clip((masculine - feminine) / max(total_g, 1.0) * 1.5, -0.8, 0.5)

    # --- Seasonality ---
    summer_descs = _desc_score(["citrus", "fresh", "green", "cooling", "fruity",
                                "mint", "cucumber", "watery", "ozone"])
    winter_descs = _desc_score(["warm", "spicy", "cinnamon", "clove", "incense",
                                "vanilla", "amber", "balsamic"])
    spring_descs = _desc_score(["floral", "rose", "fresh", "green", "lily"])
    autumn_descs = _desc_score(["woody", "earthy", "mossy", "dry", "leather"])

    season_raw = np.array([spring_descs, summer_descs, autumn_descs, winter_descs],
                          dtype=np.float32)
    season_raw = np.maximum(season_raw, 0.05)  # floor
    seasonality = season_raw / season_raw.sum()

    # --- Wearability ---
    top_weight = float(pyramid[0].sum()) / 138.0
    base_weight = float(pyramid[2].sum()) / 138.0
    # More base notes → night-oriented; more top notes → day-oriented
    night_oriented = base_weight / max(top_weight + base_weight, 0.01)
    day_wear = np.clip(1.0 - night_oriented, 0.3, 0.9)
    night_wear = np.clip(0.3 + night_oriented * 0.6, 0.3, 0.95)

    return np.array([
        seasonality[0], seasonality[1], seasonality[2], seasonality[3],
        gender, day_wear, night_wear
    ], dtype=np.float32)


def sample_noisy_psychometric_targets(
    generation_strategy: str,
    *,
    seasonality_noise: float = 0.05,
    gender_noise: float = 0.1,
    wearability_noise: float = 0.05,
    rng: np.random.Generator | None = None,
) -> np.ndarray:
    """Add label noise to the 7-D psychometric target vector."""
    rng = rng or np.random.default_rng()
    base = get_psychometric_targets(generation_strategy)
    noisy = base.copy()
    noisy[:4] += rng.normal(0, seasonality_noise, size=4).astype(np.float32)
    noisy[:4] = np.clip(noisy[:4], 0.0, 1.0)
    noisy[:4] = noisy[:4] / noisy[:4].sum() if noisy[:4].sum() > 0 else noisy[:4]
    noisy[4] += rng.normal(0, gender_noise, size=1).astype(np.float32)[0]
    noisy[4] = np.clip(noisy[4], -1.0, 1.0)
    noisy[5:] += rng.normal(0, wearability_noise, size=2).astype(np.float32)
    noisy[5:] = np.clip(noisy[5:], 0.0, 1.0)
    return noisy