File size: 9,850 Bytes
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 | from __future__ import annotations
import json
import logging
import random
from pathlib import Path
from typing import Any
import numpy as np
from pino.ifra import IFRA_RESTRICTIONS
from pino.registry import AromaRegistry
from pino.verifier import FragrancePipelineVerifier
logger = logging.getLogger("pino.synthetic")
def _light_ifra_check(formula: list[dict[str, Any]]) -> dict[str, Any]:
"""Fast pre-check against IFRA restrictions using raw dict input."""
violations = []
for f in formula:
cas = f["cas"]
pct = f["weight_fraction"] * 100.0
if cas in IFRA_RESTRICTIONS:
limit = IFRA_RESTRICTIONS[cas]["category_4_pct"]
if (limit == 0.0 and pct > 0.0) or (limit > 0.0 and pct > limit):
violations.append({"cas": cas, "used_pct": pct, "limit_pct": limit})
return {"passed": not violations, "violations": violations}
class SyntheticFormulationGenerator:
"""
High-throughput generator of physically plausible fragrance formulations.
Samples ingredient palettes from the validated aroma registry, applies
realistic concentration guardrails, filters through IFRA and the VLE
verifier, and returns a clean ML-ready corpus.
"""
def __init__(
self,
registry_path: str | Path = "src/pino/registry.db",
seed: int | None = None,
min_ingredients: int = 4,
max_ingredients: int = 10,
base_fraction_range: tuple[float, float] = (0.50, 0.90),
accord_mass_fraction: float = 0.30,
max_attempts: int = 100,
) -> None:
self.registry_path = Path(registry_path)
self.seed = seed
self.rng = random.Random(seed)
self.min_ingredients = min_ingredients
self.max_ingredients = max_ingredients
self.base_fraction_range = base_fraction_range
self.accord_mass_fraction = accord_mass_fraction
self.max_attempts = max_attempts
self._registry = AromaRegistry(str(self.registry_path))
self._verifier = FragrancePipelineVerifier()
def _load_aroma_chemicals(self) -> list[dict[str, Any]]:
rows = self._registry._conn.execute(
"SELECT cas, name, molecular_weight, vapor_pressure_pa, boiling_point_k, logp FROM aroma_chemicals"
).fetchall()
return [
{
"cas": row[0],
"name": row[1],
"molecular_weight": row[2],
"vapor_pressure_pa": row[3],
"boiling_point_k": row[4],
"logp": row[5],
}
for row in rows
]
def _pick_base(self, chemicals: list[dict[str, Any]]) -> dict[str, Any]:
# Prefer ethanol as the canonical volatile base if available; otherwise fallback.
for chem in chemicals:
if chem["cas"] == "64-17-5":
return chem
return self.rng.choice(chemicals)
def _sample_palette(self, chemicals: list[dict[str, Any]], base: dict[str, Any]) -> list[dict[str, Any]]:
n = self.rng.randint(self.min_ingredients, self.max_ingredients)
# Ensure base is included, then fill with diverse aroma chemicals.
candidates = [c for c in chemicals if c["cas"] != base["cas"]]
if len(candidates) < n - 1:
n = len(candidates) + 1
selected = self.rng.sample(candidates, n - 1)
return [base] + selected
def _distribute_weights(self, palette: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""
Realistic fragrance pyramid:
- Base solvent dominates (e.g., ethanol).
- Remaining mass is distributed across the accord with a slight bias toward
lower vapor pressure (heart/base) notes for stability.
"""
base_cas = palette[0]["cas"]
base_fraction = self.rng.uniform(*self.base_fraction_range)
accord_mass = 1.0 - base_fraction
# Use inverse volatility weighting for the accord to avoid all-top-note formulas.
volatilities = np.array([max(c["vapor_pressure_pa"] or 1e-12, 1e-12) for c in palette[1:]])
# Mix random and inverse volatility weights to keep diversity.
random_weights = np.array([self.rng.random() for _ in palette[1:]])
inverse_vol_weights = 1.0 / volatilities
inverse_vol_weights /= inverse_vol_weights.sum()
random_weights /= random_weights.sum()
blend = 0.6 * random_weights + 0.4 * inverse_vol_weights
blend /= blend.sum()
accord_fractions = blend * accord_mass
weights = {base_cas: base_fraction}
for chem, frac in zip(palette[1:], accord_fractions):
weights[chem["cas"]] = float(frac)
formula = [{"cas": c["cas"], "weight_fraction": weights[c["cas"]]} for c in palette]
return formula
def _apply_guardrails(self, formula: list[dict[str, Any]]) -> bool:
"""
Basic concentration guardrails before expensive VLE verification.
"""
total = sum(f["weight_fraction"] for f in formula)
if not np.isclose(total, 1.0, atol=1e-4):
return False
# No single accord ingredient dominates (>40% of non-base mass).
base_cas = formula[0]["cas"]
accord_total = sum(f["weight_fraction"] for f in formula if f["cas"] != base_cas)
for f in formula:
if f["cas"] != base_cas and f["weight_fraction"] / max(accord_total, 1e-12) > 0.40:
return False
return True
def generate_one(self, duration_seconds: float = 28800, interval_seconds: float = 600) -> dict[str, Any] | None:
"""
Generate and verify a single synthetic formulation. Returns the verified record
or None if rejected/out-of-domain.
"""
chemicals = self._load_aroma_chemicals()
base = self._pick_base(chemicals)
for attempt in range(self.max_attempts):
palette = self._sample_palette(chemicals, base)
formula = self._distribute_weights(palette)
if not self._apply_guardrails(formula):
continue
# Lightweight IFRA pre-check before VLE.
ifra_report = _light_ifra_check(formula)
if not ifra_report["passed"]:
continue
try:
result = self._verifier.run_sim(formula, duration_seconds=duration_seconds, interval_seconds=interval_seconds)
except Exception as e:
logger.debug("Verifier rejected formula: %s", e)
continue
if result["status"] in ("passed", "depleted"):
return {
"formula": formula,
"status": result["status"],
"message": result["message"],
"depletion_rates": result["depletion_rates"],
"trajectory": result["trajectory"],
}
logger.warning("Failed to generate a verified formulation after %d attempts", self.max_attempts)
return None
def generate_corpus(
self,
n_samples: int = 100,
duration_seconds: float = 28800,
interval_seconds: float = 600,
output_path: str | Path = "synthetic_corpus.json",
) -> dict[str, Any]:
"""
Generate a corpus of n verified formulations and save as JSON.
Returns a summary dict with pass/reject counts and output path.
"""
output_path = Path(output_path)
samples: list[dict[str, Any]] = []
status_counts = {"passed": 0, "depleted": 0, "rejected": 0}
for i in range(n_samples):
record = self.generate_one(duration_seconds=duration_seconds, interval_seconds=interval_seconds)
if record is None:
status_counts["rejected"] += 1
continue
samples.append(record)
status_counts[record["status"]] += 1
if (i + 1) % 10 == 0:
logger.info("Generated %d/%d verified formulations", len(samples), n_samples)
corpus = {
"metadata": {
"n_samples": len(samples),
"requested_samples": n_samples,
"duration_seconds": duration_seconds,
"interval_seconds": interval_seconds,
"registry_path": str(self.registry_path),
"seed": self.seed,
"status_counts": status_counts,
},
"samples": samples,
}
output_path.write_text(json.dumps(corpus, indent=2))
logger.info("Saved corpus with %d samples to %s", len(samples), output_path)
return corpus
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Generate synthetic PINO training corpus")
parser.add_argument("--n-samples", type=int, default=100, help="Number of verified formulations to generate")
parser.add_argument("--duration", type=float, default=28800, help="Simulation duration in seconds")
parser.add_argument("--interval", type=float, default=600, help="Output interval in seconds")
parser.add_argument("--output", type=Path, default="synthetic_corpus.json", help="Output JSON path")
parser.add_argument("--seed", type=int, default=42, help="Random seed")
parser.add_argument("--registry", type=Path, default="src/pino/registry.db", help="Path to SQLite registry")
parser.add_argument("--log-level", default="INFO", help="Logging level")
args = parser.parse_args()
logging.basicConfig(level=getattr(logging, args.log_level.upper()))
gen = SyntheticFormulationGenerator(registry_path=args.registry, seed=args.seed)
gen.generate_corpus(
n_samples=args.n_samples,
duration_seconds=args.duration,
interval_seconds=args.interval,
output_path=args.output,
)
|