feat(optimizer): masked MSE, strategy seeds, adversarial IFRA guard; add accords/materials/importer
fd5ba83 | from __future__ import annotations | |
| """ | |
| Formula importer: turn a parts-based human formula into a Pino-ready | |
| { cas, weight_fraction } formula by resolving trade names via the materials | |
| library and proprietary bases via accords. | |
| """ | |
| import json | |
| import logging | |
| from pathlib import Path | |
| from typing import Any | |
| from .accords import expand_accord_by_name | |
| from .materials import is_accord, resolve_material | |
| from .registry import AromaRegistry | |
| logger = logging.getLogger("pino.importer") | |
| def _normalise_name(name: str) -> str: | |
| """Strip concentration suffixes like '10%' for library lookup.""" | |
| return name.strip().replace(" ", " ") | |
| def import_formula( | |
| path: str | Path, | |
| registry: AromaRegistry | None = None, | |
| ) -> dict[str, Any]: | |
| """ | |
| Import a parts-based formula JSON and return a Pino-ready formula record. | |
| The input JSON is a list of {"name": str, "parts": int} records. | |
| Trade names are resolved through ``materials.py``; proprietary bases are | |
| expanded via the accord library in ``accords.py``. Missing materials that | |
| cannot be resolved are returned in the ``unresolved`` list so the caller can | |
| decide how to proceed (register them, approximate, or drop). | |
| """ | |
| path = Path(path) | |
| with path.open("r", encoding="utf-8") as f: | |
| records = json.load(f) | |
| if registry is None: | |
| registry = AromaRegistry() | |
| formula = [] | |
| unresolved = [] | |
| total_parts = sum(r.get("parts", 0) for r in records) | |
| for r in records: | |
| name = r["name"] | |
| parts = r.get("parts", 0) | |
| if parts <= 0: | |
| continue | |
| wf_global = parts / total_parts | |
| normalised = _normalise_name(name) | |
| material = resolve_material(normalised) | |
| if material and is_accord(normalised): | |
| # Expand the accord and scale to the formula-level weight fraction. | |
| accord_items = expand_accord_by_name(material["accord"], wf_global) | |
| for item in accord_items: | |
| formula.append(item) | |
| elif material: | |
| # Single material; use CAS if present, otherwise SMILES. | |
| cas = material.get("cas") | |
| smiles = material.get("smiles") | |
| resolved_cas = cas | |
| if not resolved_cas and smiles: | |
| # Try to register by SMILES on the fly. | |
| rec = AromaRegistry.validate_smiles(smiles) | |
| if rec: | |
| resolved_cas = f"SMILES:{smiles}" | |
| else: | |
| unresolved.append(name) | |
| continue | |
| formula.append( | |
| { | |
| "cas": resolved_cas or name, | |
| "name": name, # Keep the original trade name so the resolver can look it up in materials.py | |
| "display_name": material.get("name", name), | |
| "weight_fraction": wf_global, | |
| } | |
| ) | |
| elif registry.get(normalised): | |
| # Already in the registry. | |
| rec = registry.get(normalised) | |
| if rec is None: | |
| unresolved.append(name) | |
| continue | |
| formula.append( | |
| { | |
| "cas": rec["cas"], | |
| "name": rec["name"], | |
| "weight_fraction": wf_global, | |
| } | |
| ) | |
| else: | |
| unresolved.append(name) | |
| # Normalize weight fractions after accord expansion. | |
| total = sum(item["weight_fraction"] for item in formula) | |
| if total > 0: | |
| for item in formula: | |
| item["weight_fraction"] /= total | |
| # Aggregate duplicate CAS entries so each ingredient appears once. | |
| aggregated: dict[str, dict[str, Any]] = {} | |
| for item in formula: | |
| cas = item["cas"] | |
| if cas in aggregated: | |
| aggregated[cas]["weight_fraction"] += item["weight_fraction"] | |
| else: | |
| aggregated[cas] = item.copy() | |
| formula = list(aggregated.values()) | |
| # Re-normalize after aggregation. | |
| total = sum(item["weight_fraction"] for item in formula) | |
| if total > 0: | |
| for item in formula: | |
| item["weight_fraction"] /= total | |
| return { | |
| "formula": formula, | |
| "unresolved": unresolved, | |
| "metadata": {"source": str(path), "total_parts": total_parts}, | |
| } | |
| def main() -> None: | |
| import argparse | |
| parser = argparse.ArgumentParser(description="Import a parts-based formula") | |
| parser.add_argument("--file", required=True, help="Path to formula JSON") | |
| parser.add_argument("--out", help="Path to write Pino-ready formula JSON") | |
| args = parser.parse_args() | |
| logging.basicConfig(level=logging.INFO) | |
| result = import_formula(args.file) | |
| print(json.dumps(result, indent=2, ensure_ascii=False)) | |
| if args.out: | |
| Path(args.out).write_text(json.dumps(result, indent=2, ensure_ascii=False)) | |
| if __name__ == "__main__": | |
| main() | |