| from __future__ import annotations |
|
|
| import json |
| import logging |
| from pathlib import Path |
| from typing import Any |
|
|
| import numpy as np |
| import requests |
| from huggingface_hub import InferenceClient |
|
|
| from .pimt_model import FragranceTrajectoryDataset |
| from .pimt_model_hf import PIMTConfig, PhysicsInformedMixtureTransformer |
|
|
| logger = logging.getLogger("pino.inference_cloud") |
|
|
|
|
| def _resolve_space_url(token: str | None = None) -> str: |
| """Return the canonical predict URL for the private HF training Space.""" |
| return "https://mattbitzesty-pino-pimt-training.hf.space/predict" |
|
|
|
|
| def encode_formula(formula: dict[str, Any]) -> dict[str, Any]: |
| """ |
| Convert a raw formula record into token/physics tensors ready for the cloud endpoint. |
| |
| For now this mirrors FragranceTrajectoryDataset's preprocessing for a single record. |
| """ |
| from .embeddings import OlfactoryEmbeddingEngine |
| from .pimt_model import FragranceTrajectoryDataset |
| from .semantics import get_objective_targets |
|
|
| engine = OlfactoryEmbeddingEngine(use_fallback=True) |
| tokens = [] |
| for comp in formula["formula"]: |
| smiles = comp.get("smiles", "") |
| if not smiles: |
| smiles = _smiles_for_cas(comp.get("cas", "")) |
| tokens.append(engine.get_embedding(smiles).tolist()) |
|
|
| t_steps = len(formula["trajectory"]) |
| n_mol = len(tokens) |
| states = np.zeros((t_steps, n_mol, 2), dtype=np.float32) |
| for t, step in enumerate(formula["trajectory"]): |
| for j, comp in enumerate(formula["formula"]): |
| if j >= n_mol: |
| break |
| name = comp.get("cas", f"ing_{j}") |
| states[t, j, 0] = step["x_liquid"].get(name, 0.0) |
| states[t, j, 1] = np.log10(max(step["OAV"].get(name, 0.0), 1e-10)) |
|
|
| src_key_padding_mask = np.zeros(n_mol, dtype=np.bool_) |
| return { |
| "tokens": tokens, |
| "physics": states.tolist(), |
| "src_key_padding_mask": src_key_padding_mask.tolist(), |
| } |
|
|
|
|
| def _smiles_for_cas(cas: str) -> str: |
| """Best-effort registry lookup.""" |
| try: |
| from .registry import AromaRegistry |
| reg = AromaRegistry() |
| rec = reg.get(cas) |
| reg.close() |
| return rec.get("smiles", "") if rec else "" |
| except Exception: |
| return "" |
|
|
|
|
| def predict_cloud( |
| formula: dict[str, Any], |
| token: str | None = None, |
| *, |
| _cached_model: Any = None, |
| ) -> dict[str, Any]: |
| """ |
| POST a processed formula to the HF Space inference endpoint and return predictions. |
| |
| Falls back to a local model if the Space endpoint is not yet available. A |
| cached model can be supplied to avoid repeated Hub downloads. |
| """ |
| url = _resolve_space_url(token) |
| payload = encode_formula(formula) |
| headers = {} |
| if token: |
| headers["Authorization"] = f"Bearer {token}" |
|
|
| try: |
| logger.info("Sending prediction request to %s", url) |
| response = requests.post(url, json=payload, headers=headers, timeout=120) |
| response.raise_for_status() |
| result = response.json() |
| if result.get("status") == "error": |
| raise RuntimeError(result.get("message", "Unknown cloud error")) |
| return result |
| except Exception as e: |
| logger.warning("Cloud inference failed (%s); falling back to local model", e) |
| return _predict_local(formula, cached_model=_cached_model) |
|
|
|
|
| def _predict_local(formula: dict[str, Any], *, cached_model: Any = None) -> dict[str, Any]: |
| """Local CPU fallback for development/testing, with optional cached model.""" |
| import torch |
|
|
| model = cached_model |
| if model is None: |
| model = PhysicsInformedMixtureTransformer.from_pretrained("mattbitzesty/pino-pimt") |
| model.eval() |
|
|
| payload = encode_formula(formula) |
| tokens = torch.tensor(payload["tokens"], dtype=torch.float32).unsqueeze(0) |
| physics = torch.tensor(payload["physics"], dtype=torch.float32).unsqueeze(0) |
| mask = torch.tensor(payload["src_key_padding_mask"], dtype=torch.bool).unsqueeze(0) |
|
|
| with torch.no_grad(): |
| output = model(tokens, physics, src_key_padding_mask=mask) |
|
|
| return { |
| "status": "success (local fallback)", |
| "objective": output.logits.cpu().numpy().tolist(), |
| "subjective": output.subjective.cpu().numpy().tolist(), |
| } |
|
|
|
|
| def predict_with_inference_client( |
| formula: dict[str, Any], |
| model_id: str = "mattbitzesty/pino-pimt", |
| token: str | None = None, |
| ) -> dict[str, Any]: |
| """ |
| Placeholder route using huggingface_hub.InferenceClient. |
| |
| Note: InferenceClient is designed for standard HF tasks (text, image, etc.). |
| For custom tensor payloads, the Space /predict endpoint via requests is preferred. |
| This function demonstrates the managed client signature. |
| """ |
| client = InferenceClient(model=model_id, token=token) |
| |
| |
| return predict_cloud(formula, token=token) |
|
|