Codex commited on
Commit
7d2a23e
·
1 Parent(s): 1621ff9

Deploy SQuADDS ML inference API

Browse files
Files changed (26) hide show
  1. .gitattributes +0 -35
  2. .gitignore +2 -0
  3. Dockerfile +16 -0
  4. README.md +22 -6
  5. app.py +6 -0
  6. artifacts/transmon_cross_hamiltonian_inverse/X_names +2 -0
  7. artifacts/transmon_cross_hamiltonian_inverse/model/best_inverse_model_surrogate_defined_loss.keras +0 -0
  8. artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_X_anharmonicity_MHz.save +0 -0
  9. artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_X_linear_anharmonicity_MHz.save +0 -0
  10. artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_X_linear_qubit_frequency_GHz.save +0 -0
  11. artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_X_qubit_frequency_GHz.save +0 -0
  12. artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_design_options.connection_pads.readout.claw_length_one_hot_encoding.save +0 -0
  13. artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_design_options.connection_pads.readout.ground_spacing_one_hot_encoding.save +0 -0
  14. artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_design_options.cross_length_one_hot_encoding.save +0 -0
  15. artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_linear_design_options.connection_pads.readout.claw_length.save +0 -0
  16. artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_linear_design_options.connection_pads.readout.ground_spacing.save +0 -0
  17. artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_linear_design_options.cross_length.save +0 -0
  18. artifacts/transmon_cross_hamiltonian_inverse/y_columns.npy +0 -0
  19. deployment_manifest.json +111 -0
  20. examples/agent_tool_schema.json +29 -0
  21. examples/predict_transmon_hamiltonian.json +10 -0
  22. requirements.txt +5 -0
  23. squadds_ml_api/__init__.py +2 -0
  24. squadds_ml_api/api.py +56 -0
  25. squadds_ml_api/registry.py +235 -0
  26. squadds_ml_api/schemas.py +21 -0
.gitattributes DELETED
@@ -1,35 +0,0 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ *.pyc
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --upgrade pip && pip install -r requirements.txt
11
+
12
+ COPY . .
13
+
14
+ EXPOSE 7860
15
+
16
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,26 @@
1
  ---
2
- title: Squadds Ml Inference Api
3
- emoji: 🌖
4
- colorFrom: purple
5
- colorTo: indigo
6
  sdk: docker
7
- pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SQuADDS ML Inference API
 
 
 
3
  sdk: docker
4
+ app_port: 7860
5
+ license: mit
6
  ---
7
 
8
+ # SQuADDS ML Inference API
9
+
10
+ Auto-generated deployment bundle for serving ML_qubit_design models with a FastAPI app.
11
+
12
+ ## Endpoints
13
+
14
+ - `GET /health`
15
+ - `GET /models`
16
+ - `POST /predict`
17
+
18
+ ## Included Models
19
+
20
+ - `transmon_cross_hamiltonian_inverse`: Inverse model that predicts TransmonCross geometry parameters from target Hamiltonian values.
21
+
22
+ ## Skipped Models
23
+
24
+ - `transmon_cross_cap_matrix_inverse`: No model checkpoint found. Expected one of: model/best_keras_model_one_hot_encoding.keras, model/best_keras_model_surrogate_defined_loss.keras, model/best_keras_model_model2_surrogate.keras
25
+ - `coupler_ncap_cap_matrix_inverse`: No model checkpoint found. Expected one of: model/best_keras_model_one_hot_encoding.keras, model/best_keras_model_surrogate_defined_loss.keras, model/best_keras_model_model2_surrogate.keras
26
+ - `cavity_claw_route_meander_inverse`: No model checkpoint found. Expected one of: model/best_keras_model_one_hot_encoding.keras, model/best_keras_model_surrogate.keras, model/best_keras_model_model2_surrogate.keras
app.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from squadds_ml_api.api import create_app
4
+
5
+
6
+ app = create_app(Path(__file__).resolve().parent)
artifacts/transmon_cross_hamiltonian_inverse/X_names ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ qubit_frequency_GHz
2
+ anharmonicity_MHz
artifacts/transmon_cross_hamiltonian_inverse/model/best_inverse_model_surrogate_defined_loss.keras ADDED
Binary file (20.8 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_X_anharmonicity_MHz.save ADDED
Binary file (1.04 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_X_linear_anharmonicity_MHz.save ADDED
Binary file (1.04 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_X_linear_qubit_frequency_GHz.save ADDED
Binary file (1.04 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_X_qubit_frequency_GHz.save ADDED
Binary file (1.04 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_design_options.connection_pads.readout.claw_length_one_hot_encoding.save ADDED
Binary file (1.07 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_design_options.connection_pads.readout.ground_spacing_one_hot_encoding.save ADDED
Binary file (1.07 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_design_options.cross_length_one_hot_encoding.save ADDED
Binary file (1.06 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_linear_design_options.connection_pads.readout.claw_length.save ADDED
Binary file (1.07 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_linear_design_options.connection_pads.readout.ground_spacing.save ADDED
Binary file (1.07 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/scalers/scaler_y_linear_design_options.cross_length.save ADDED
Binary file (1.06 kB). View file
 
artifacts/transmon_cross_hamiltonian_inverse/y_columns.npy ADDED
Binary file (433 Bytes). View file
 
deployment_manifest.json ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "generated_at": "2026-04-10T23:34:07.750772+00:00",
3
+ "space": {
4
+ "repo_id": "SQuADDS/squadds-ml-inference-api",
5
+ "title": "SQuADDS ML Inference API",
6
+ "sdk": "docker",
7
+ "app_port": 7860,
8
+ "license": "mit"
9
+ },
10
+ "models": [
11
+ {
12
+ "id": "transmon_cross_hamiltonian_inverse",
13
+ "display_name": "TransmonCross Hamiltonian to Geometry",
14
+ "description": "Inverse model that predicts TransmonCross geometry parameters from target Hamiltonian values.",
15
+ "artifact_dir": "artifacts/transmon_cross_hamiltonian_inverse",
16
+ "model_path": "model/best_inverse_model_surrogate_defined_loss.keras",
17
+ "input_names_path": "X_names",
18
+ "input_names_format": "text_lines",
19
+ "output_names_path": "y_columns.npy",
20
+ "output_names_format": "npy",
21
+ "x_scaler_pattern": "scalers/scaler_X_{name}.save",
22
+ "y_scaler_pattern": "scalers/scaler_y_{name}_one_hot_encoding.save",
23
+ "input_units": {
24
+ "qubit_frequency_GHz": "GHz",
25
+ "anharmonicity_MHz": "MHz"
26
+ },
27
+ "output_units": {
28
+ "design_options.connection_pads.readout.claw_length": "m",
29
+ "design_options.connection_pads.readout.ground_spacing": "m",
30
+ "design_options.cross_length": "m"
31
+ },
32
+ "status": "ready",
33
+ "status_detail": "",
34
+ "tags": [
35
+ "inverse-design",
36
+ "transmon",
37
+ "hamiltonian"
38
+ ],
39
+ "prediction_output_index": 0
40
+ },
41
+ {
42
+ "id": "transmon_cross_cap_matrix_inverse",
43
+ "display_name": "TransmonCross Cap Matrix to Geometry",
44
+ "description": "Inverse model that predicts TransmonCross geometry parameters from cap matrix targets.",
45
+ "artifact_dir": "",
46
+ "model_path": "",
47
+ "input_names_path": "X_names",
48
+ "input_names_format": "text_lines",
49
+ "output_names_path": "y_columns.npy",
50
+ "output_names_format": "npy",
51
+ "x_scaler_pattern": "scalers/scaler_X_{name}.save",
52
+ "y_scaler_pattern": "scalers/scaler_y_{name}_one_hot_encoding.save",
53
+ "input_units": {},
54
+ "output_units": {},
55
+ "status": "missing_model_artifact",
56
+ "status_detail": "No model checkpoint found. Expected one of: model/best_keras_model_one_hot_encoding.keras, model/best_keras_model_surrogate_defined_loss.keras, model/best_keras_model_model2_surrogate.keras",
57
+ "tags": [
58
+ "inverse-design",
59
+ "transmon",
60
+ "cap-matrix"
61
+ ],
62
+ "prediction_output_index": 0
63
+ },
64
+ {
65
+ "id": "coupler_ncap_cap_matrix_inverse",
66
+ "display_name": "NCap Coupler Cap Matrix to Geometry",
67
+ "description": "Inverse model that predicts NCap coupler geometry from cap matrix targets.",
68
+ "artifact_dir": "",
69
+ "model_path": "",
70
+ "input_names_path": "X_names",
71
+ "input_names_format": "text_lines",
72
+ "output_names_path": "y_columns.npy",
73
+ "output_names_format": "npy",
74
+ "x_scaler_pattern": "scalers/scaler_X_{name}.save",
75
+ "y_scaler_pattern": "scalers/scaler_y_{name}_one_hot_encoding.save",
76
+ "input_units": {},
77
+ "output_units": {},
78
+ "status": "missing_model_artifact",
79
+ "status_detail": "No model checkpoint found. Expected one of: model/best_keras_model_one_hot_encoding.keras, model/best_keras_model_surrogate_defined_loss.keras, model/best_keras_model_model2_surrogate.keras",
80
+ "tags": [
81
+ "inverse-design",
82
+ "coupler",
83
+ "cap-matrix"
84
+ ],
85
+ "prediction_output_index": 0
86
+ },
87
+ {
88
+ "id": "cavity_claw_route_meander_inverse",
89
+ "display_name": "Cavity Claw RouteMeander Targets to Geometry",
90
+ "description": "Inverse model that predicts cavity claw RouteMeander geometry from target cavity frequency and kappa.",
91
+ "artifact_dir": "",
92
+ "model_path": "",
93
+ "input_names_path": "X_names",
94
+ "input_names_format": "text_lines",
95
+ "output_names_path": "y_columns.npy",
96
+ "output_names_format": "npy",
97
+ "x_scaler_pattern": "scalers/scaler_X_{name}.save",
98
+ "y_scaler_pattern": "scalers/scaler_y_{name}_one_hot_encoding.save",
99
+ "input_units": {},
100
+ "output_units": {},
101
+ "status": "missing_model_artifact",
102
+ "status_detail": "No model checkpoint found. Expected one of: model/best_keras_model_one_hot_encoding.keras, model/best_keras_model_surrogate.keras, model/best_keras_model_model2_surrogate.keras",
103
+ "tags": [
104
+ "inverse-design",
105
+ "cavity",
106
+ "readout"
107
+ ],
108
+ "prediction_output_index": 0
109
+ }
110
+ ]
111
+ }
examples/agent_tool_schema.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "squadds_ml_predict",
3
+ "description": "Run inference against the SQuADDS ML Hugging Face Space to predict device geometry from target physics inputs.",
4
+ "input_schema": {
5
+ "type": "object",
6
+ "properties": {
7
+ "model_id": {
8
+ "type": "string",
9
+ "description": "Model identifier returned by GET /models."
10
+ },
11
+ "inputs": {
12
+ "description": "Single input object or batch of input objects using the exact input keys for the selected model."
13
+ },
14
+ "options": {
15
+ "type": "object",
16
+ "properties": {
17
+ "include_scaled_outputs": {
18
+ "type": "boolean",
19
+ "default": false
20
+ }
21
+ }
22
+ }
23
+ },
24
+ "required": [
25
+ "model_id",
26
+ "inputs"
27
+ ]
28
+ }
29
+ }
examples/predict_transmon_hamiltonian.json ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_id": "transmon_cross_hamiltonian_inverse",
3
+ "inputs": {
4
+ "qubit_frequency_GHz": 4.85,
5
+ "anharmonicity_MHz": -205.0
6
+ },
7
+ "options": {
8
+ "include_scaled_outputs": false
9
+ }
10
+ }
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.115.12
2
+ uvicorn[standard]==0.34.0
3
+ numpy==1.26.4
4
+ joblib==1.4.2
5
+ tensorflow-cpu==2.11.1
squadds_ml_api/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Runtime package for the SQuADDS ML Hugging Face Space."""
2
+
squadds_ml_api/api.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from fastapi import FastAPI, HTTPException
4
+
5
+ from .registry import BundleConfigError, ModelRegistry, RequestValidationError
6
+ from .schemas import PredictionRequest
7
+
8
+
9
+ def create_app(bundle_root: Path) -> FastAPI:
10
+ registry = ModelRegistry(bundle_root)
11
+
12
+ app = FastAPI(
13
+ title="SQuADDS ML Inference API",
14
+ version="0.1.0",
15
+ description=(
16
+ "HTTP API for running inference against ML models trained in "
17
+ "ML_qubit_design and packaged for the SQuADDS Hugging Face Space."
18
+ ),
19
+ )
20
+
21
+ @app.get("/")
22
+ def root() -> dict:
23
+ return {
24
+ "service": "SQuADDS ML Inference API",
25
+ "docs": "/docs",
26
+ "models_endpoint": "/models",
27
+ "predict_endpoint": "/predict",
28
+ }
29
+
30
+ @app.get("/health")
31
+ def health() -> dict:
32
+ return {
33
+ "status": "ok",
34
+ "available_models": registry.available_model_ids(),
35
+ "bundle_root": str(bundle_root),
36
+ }
37
+
38
+ @app.get("/models")
39
+ def list_models() -> dict:
40
+ return {"models": registry.describe_models()}
41
+
42
+ @app.post("/predict")
43
+ def predict(request: PredictionRequest) -> dict:
44
+ try:
45
+ payload = registry.predict(
46
+ model_id=request.model_id,
47
+ inputs=request.inputs,
48
+ include_scaled_outputs=request.options.include_scaled_outputs,
49
+ )
50
+ except RequestValidationError as exc:
51
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
52
+ except BundleConfigError as exc:
53
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
54
+ return payload
55
+
56
+ return app
squadds_ml_api/registry.py ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Iterable, List
7
+
8
+ import joblib
9
+ import numpy as np
10
+ from tensorflow.keras.models import load_model
11
+
12
+
13
+ class BundleConfigError(RuntimeError):
14
+ """Raised when the deployed bundle is missing required files or config."""
15
+
16
+
17
+ class RequestValidationError(ValueError):
18
+ """Raised when an inference request does not match the model contract."""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ModelSpec:
23
+ id: str
24
+ display_name: str
25
+ description: str
26
+ artifact_dir: str
27
+ model_path: str
28
+ input_names_path: str
29
+ input_names_format: str
30
+ output_names_path: str
31
+ output_names_format: str
32
+ x_scaler_pattern: str | None = None
33
+ y_scaler_pattern: str | None = None
34
+ input_units: Dict[str, str] = field(default_factory=dict)
35
+ output_units: Dict[str, str] = field(default_factory=dict)
36
+ status: str = "ready"
37
+ status_detail: str = ""
38
+ tags: List[str] = field(default_factory=list)
39
+ prediction_output_index: int = 0
40
+
41
+ @classmethod
42
+ def from_dict(cls, payload: Dict[str, Any]) -> "ModelSpec":
43
+ return cls(**payload)
44
+
45
+
46
+ def _read_names(path: Path, fmt: str) -> List[str]:
47
+ if fmt == "text_lines":
48
+ return [line.strip() for line in path.read_text().splitlines() if line.strip()]
49
+ if fmt == "csv_header":
50
+ first_line = path.read_text().splitlines()[0]
51
+ return [item.strip() for item in first_line.split(",") if item.strip()]
52
+ if fmt == "npy":
53
+ values = np.load(path, allow_pickle=True)
54
+ return [str(item) for item in values.tolist()]
55
+ raise BundleConfigError(f"Unsupported name file format: {fmt}")
56
+
57
+
58
+ class EndpointModel:
59
+ def __init__(self, bundle_root: Path, spec: ModelSpec):
60
+ self.bundle_root = bundle_root
61
+ self.spec = spec
62
+ self.artifact_root = bundle_root / spec.artifact_dir
63
+
64
+ if not self.artifact_root.exists():
65
+ raise BundleConfigError(
66
+ f"Artifact directory for model '{spec.id}' is missing: {self.artifact_root}"
67
+ )
68
+
69
+ self.input_names = _read_names(
70
+ self.artifact_root / spec.input_names_path, spec.input_names_format
71
+ )
72
+ self.output_names = _read_names(
73
+ self.artifact_root / spec.output_names_path, spec.output_names_format
74
+ )
75
+
76
+ model_file = self.artifact_root / spec.model_path
77
+ if not model_file.exists():
78
+ raise BundleConfigError(
79
+ f"Model file for '{spec.id}' is missing: {model_file}"
80
+ )
81
+ self.model = load_model(model_file, compile=False)
82
+
83
+ def _load_scaler(self, pattern: str, name: str):
84
+ scaler_path = self.artifact_root / pattern.format(name=name)
85
+ if not scaler_path.exists():
86
+ raise BundleConfigError(
87
+ f"Required scaler for '{self.spec.id}' is missing: {scaler_path}"
88
+ )
89
+ return joblib.load(scaler_path)
90
+
91
+ def _normalize_rows(self, inputs: Dict[str, float] | List[Dict[str, float]]) -> List[Dict[str, float]]:
92
+ rows = inputs if isinstance(inputs, list) else [inputs]
93
+ normalized: List[Dict[str, float]] = []
94
+ for index, row in enumerate(rows):
95
+ if not isinstance(row, dict):
96
+ raise RequestValidationError(
97
+ f"Each input row must be an object, got {type(row).__name__} at index {index}."
98
+ )
99
+
100
+ missing = [name for name in self.input_names if name not in row]
101
+ extras = [name for name in row if name not in self.input_names]
102
+ if missing or extras:
103
+ raise RequestValidationError(
104
+ f"Model '{self.spec.id}' expects inputs {self.input_names}. "
105
+ f"Missing: {missing or 'none'}. Extra: {extras or 'none'}."
106
+ )
107
+
108
+ normalized.append({name: float(row[name]) for name in self.input_names})
109
+ return normalized
110
+
111
+ def _scale_inputs(self, rows: List[Dict[str, float]]) -> np.ndarray:
112
+ matrix = np.zeros((len(rows), len(self.input_names)), dtype=np.float32)
113
+ for row_idx, row in enumerate(rows):
114
+ for col_idx, name in enumerate(self.input_names):
115
+ value = row[name]
116
+ if self.spec.x_scaler_pattern:
117
+ scaler = self._load_scaler(self.spec.x_scaler_pattern, name)
118
+ scaled = scaler.transform([[value]])[0][0]
119
+ else:
120
+ scaled = value
121
+ matrix[row_idx, col_idx] = float(scaled)
122
+ return matrix
123
+
124
+ def _unscale_outputs(self, scaled_outputs: np.ndarray) -> np.ndarray:
125
+ matrix = np.asarray(scaled_outputs, dtype=np.float32).copy()
126
+ if not self.spec.y_scaler_pattern:
127
+ return matrix
128
+
129
+ for col_idx, name in enumerate(self.output_names):
130
+ scaler = self._load_scaler(self.spec.y_scaler_pattern, name)
131
+ column = matrix[:, col_idx].reshape(-1, 1)
132
+ matrix[:, col_idx] = scaler.inverse_transform(column).reshape(-1)
133
+ return matrix
134
+
135
+ def predict(
136
+ self,
137
+ inputs: Dict[str, float] | List[Dict[str, float]],
138
+ include_scaled_outputs: bool = False,
139
+ ) -> Dict[str, Any]:
140
+ rows = self._normalize_rows(inputs)
141
+ scaled_inputs = self._scale_inputs(rows)
142
+ raw_predictions = self.model.predict(scaled_inputs, verbose=0)
143
+
144
+ if isinstance(raw_predictions, list):
145
+ scaled_outputs = np.asarray(raw_predictions[self.spec.prediction_output_index])
146
+ else:
147
+ scaled_outputs = np.asarray(raw_predictions)
148
+
149
+ unscaled_outputs = self._unscale_outputs(scaled_outputs)
150
+
151
+ predictions = [
152
+ {
153
+ output_name: float(unscaled_outputs[row_idx, col_idx])
154
+ for col_idx, output_name in enumerate(self.output_names)
155
+ }
156
+ for row_idx in range(unscaled_outputs.shape[0])
157
+ ]
158
+
159
+ response: Dict[str, Any] = {
160
+ "model_id": self.spec.id,
161
+ "display_name": self.spec.display_name,
162
+ "predictions": predictions,
163
+ "metadata": {
164
+ "input_order": self.input_names,
165
+ "output_order": self.output_names,
166
+ "input_units": self.spec.input_units,
167
+ "output_units": self.spec.output_units,
168
+ "num_predictions": len(predictions),
169
+ },
170
+ }
171
+ if include_scaled_outputs:
172
+ response["scaled_predictions"] = [
173
+ {
174
+ output_name: float(scaled_outputs[row_idx, col_idx])
175
+ for col_idx, output_name in enumerate(self.output_names)
176
+ }
177
+ for row_idx in range(scaled_outputs.shape[0])
178
+ ]
179
+ return response
180
+
181
+
182
+ class ModelRegistry:
183
+ def __init__(self, bundle_root: Path):
184
+ self.bundle_root = Path(bundle_root)
185
+ manifest_path = self.bundle_root / "deployment_manifest.json"
186
+ if not manifest_path.exists():
187
+ raise BundleConfigError(
188
+ f"Deployment manifest is missing from bundle: {manifest_path}"
189
+ )
190
+
191
+ payload = json.loads(manifest_path.read_text())
192
+ self.bundle_info = payload.get("space", {})
193
+ self.specs = [ModelSpec.from_dict(item) for item in payload.get("models", [])]
194
+ self._models: Dict[str, EndpointModel] = {}
195
+
196
+ def available_model_ids(self) -> List[str]:
197
+ return [spec.id for spec in self.specs if spec.status == "ready"]
198
+
199
+ def describe_models(self) -> List[Dict[str, Any]]:
200
+ return [
201
+ {
202
+ "id": spec.id,
203
+ "display_name": spec.display_name,
204
+ "description": spec.description,
205
+ "status": spec.status,
206
+ "status_detail": spec.status_detail,
207
+ "input_units": spec.input_units,
208
+ "output_units": spec.output_units,
209
+ "tags": spec.tags,
210
+ }
211
+ for spec in self.specs
212
+ ]
213
+
214
+ def _get_model(self, model_id: str) -> EndpointModel:
215
+ spec = next((item for item in self.specs if item.id == model_id), None)
216
+ if spec is None:
217
+ raise RequestValidationError(
218
+ f"Unknown model_id '{model_id}'. Available models: {self.available_model_ids()}."
219
+ )
220
+ if spec.status != "ready":
221
+ raise RequestValidationError(
222
+ f"Model '{model_id}' is not deployable in this bundle: {spec.status_detail or spec.status}."
223
+ )
224
+ if model_id not in self._models:
225
+ self._models[model_id] = EndpointModel(self.bundle_root, spec)
226
+ return self._models[model_id]
227
+
228
+ def predict(
229
+ self,
230
+ model_id: str,
231
+ inputs: Dict[str, float] | List[Dict[str, float]],
232
+ include_scaled_outputs: bool = False,
233
+ ) -> Dict[str, Any]:
234
+ model = self._get_model(model_id)
235
+ return model.predict(inputs=inputs, include_scaled_outputs=include_scaled_outputs)
squadds_ml_api/schemas.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Union
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class PredictionOptions(BaseModel):
9
+ include_scaled_outputs: bool = Field(
10
+ default=False,
11
+ description="Include raw scaled model outputs alongside inverse-transformed values.",
12
+ )
13
+
14
+
15
+ class PredictionRequest(BaseModel):
16
+ model_id: str = Field(..., description="The deployed model identifier.")
17
+ inputs: Union[Dict[str, float], List[Dict[str, float]]] = Field(
18
+ ...,
19
+ description="Either a single input object or a batch of input objects.",
20
+ )
21
+ options: PredictionOptions = Field(default_factory=PredictionOptions)