claude-turing 1.5.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env python3
2
+ """Format-specific model export handlers.
3
+
4
+ Each handler knows how to export a specific model type to a production
5
+ format. Returns the export path and metadata.
6
+
7
+ Supported formats:
8
+ - joblib: scikit-learn, XGBoost, LightGBM (default)
9
+ - xgboost_json: XGBoost native JSON format
10
+ - lightgbm_text: LightGBM native text format
11
+ - onnx: ONNX via framework-specific converters
12
+ - torchscript: PyTorch JIT trace
13
+ - tflite: TensorFlow Lite
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import shutil
19
+ import sys
20
+ from pathlib import Path
21
+
22
+
23
+ # Registry of model types -> supported export formats
24
+ FORMAT_REGISTRY = {
25
+ "xgboost": ["joblib", "xgboost_json", "onnx"],
26
+ "lightgbm": ["joblib", "lightgbm_text", "onnx"],
27
+ "random_forest": ["joblib", "onnx"],
28
+ "gradient_boosting": ["joblib", "onnx"],
29
+ "logistic_regression": ["joblib", "onnx"],
30
+ "svm": ["joblib", "onnx"],
31
+ "mlp": ["joblib", "onnx"],
32
+ "pytorch": ["torchscript", "onnx"],
33
+ "tensorflow": ["tflite", "onnx"],
34
+ "keras": ["tflite", "onnx"],
35
+ "catboost": ["joblib", "onnx"],
36
+ }
37
+
38
+ # Default format for each model type
39
+ DEFAULT_FORMAT = {
40
+ "xgboost": "joblib",
41
+ "lightgbm": "joblib",
42
+ "random_forest": "joblib",
43
+ "gradient_boosting": "joblib",
44
+ "logistic_regression": "joblib",
45
+ "svm": "joblib",
46
+ "mlp": "joblib",
47
+ "pytorch": "torchscript",
48
+ "tensorflow": "tflite",
49
+ "keras": "tflite",
50
+ "catboost": "joblib",
51
+ }
52
+
53
+ # File extensions for each format
54
+ FORMAT_EXTENSIONS = {
55
+ "joblib": ".joblib",
56
+ "xgboost_json": ".json",
57
+ "lightgbm_text": ".txt",
58
+ "onnx": ".onnx",
59
+ "torchscript": ".pt",
60
+ "tflite": ".tflite",
61
+ }
62
+
63
+ # Dependencies required for each format
64
+ FORMAT_DEPENDENCIES = {
65
+ "joblib": ["joblib"],
66
+ "xgboost_json": ["xgboost>=1.7"],
67
+ "lightgbm_text": ["lightgbm>=3.0"],
68
+ "onnx": ["onnx", "onnxruntime"],
69
+ "torchscript": ["torch>=1.9"],
70
+ "tflite": ["tensorflow>=2.0"],
71
+ }
72
+
73
+
74
+ def get_supported_formats(model_type: str) -> list[str]:
75
+ """Get supported export formats for a model type."""
76
+ return FORMAT_REGISTRY.get(model_type, ["joblib"])
77
+
78
+
79
+ def get_default_format(model_type: str) -> str:
80
+ """Get the default export format for a model type."""
81
+ return DEFAULT_FORMAT.get(model_type, "joblib")
82
+
83
+
84
+ def detect_model_type(config: dict) -> str:
85
+ """Detect model type from experiment config."""
86
+ model_type = config.get("model", {}).get("type", "")
87
+ if not model_type:
88
+ model_type = config.get("model_type", "unknown")
89
+ return model_type.lower().replace("-", "_").replace(" ", "_")
90
+
91
+
92
+ def export_joblib(
93
+ model_path: str,
94
+ output_dir: str,
95
+ model_name: str,
96
+ ) -> dict:
97
+ """Export model as joblib bundle (copy if already joblib, else convert).
98
+
99
+ Returns dict with path, size_bytes, format, and dependencies.
100
+ """
101
+ src = Path(model_path)
102
+ if not src.exists():
103
+ return {"error": f"Model file not found: {model_path}"}
104
+
105
+ out_path = Path(output_dir)
106
+ out_path.mkdir(parents=True, exist_ok=True)
107
+
108
+ dst = out_path / f"{model_name}.joblib"
109
+ shutil.copy2(str(src), str(dst))
110
+
111
+ return {
112
+ "path": str(dst),
113
+ "format": "joblib",
114
+ "size_bytes": dst.stat().st_size,
115
+ "size_mb": round(dst.stat().st_size / 1024**2, 2),
116
+ "dependencies": FORMAT_DEPENDENCIES["joblib"],
117
+ }
118
+
119
+
120
+ def export_xgboost_json(
121
+ model_path: str,
122
+ output_dir: str,
123
+ model_name: str,
124
+ ) -> dict:
125
+ """Export XGBoost model to native JSON format."""
126
+ try:
127
+ import joblib
128
+ import xgboost as xgb
129
+ except ImportError as e:
130
+ return {"error": f"Missing dependency: {e}"}
131
+
132
+ src = Path(model_path)
133
+ if not src.exists():
134
+ return {"error": f"Model file not found: {model_path}"}
135
+
136
+ out_path = Path(output_dir)
137
+ out_path.mkdir(parents=True, exist_ok=True)
138
+
139
+ try:
140
+ model = joblib.load(str(src))
141
+ # Handle wrapped models (e.g., in a pipeline)
142
+ if hasattr(model, "get_booster"):
143
+ booster = model.get_booster()
144
+ elif isinstance(model, xgb.Booster):
145
+ booster = model
146
+ else:
147
+ return {"error": "Model is not an XGBoost model or doesn't have get_booster()"}
148
+
149
+ dst = out_path / f"{model_name}.json"
150
+ booster.save_model(str(dst))
151
+
152
+ return {
153
+ "path": str(dst),
154
+ "format": "xgboost_json",
155
+ "size_bytes": dst.stat().st_size,
156
+ "size_mb": round(dst.stat().st_size / 1024**2, 2),
157
+ "dependencies": FORMAT_DEPENDENCIES["xgboost_json"],
158
+ }
159
+ except Exception as e:
160
+ return {"error": f"XGBoost JSON export failed: {e}"}
161
+
162
+
163
+ def export_lightgbm_text(
164
+ model_path: str,
165
+ output_dir: str,
166
+ model_name: str,
167
+ ) -> dict:
168
+ """Export LightGBM model to native text format."""
169
+ try:
170
+ import joblib
171
+ import lightgbm as lgb
172
+ except ImportError as e:
173
+ return {"error": f"Missing dependency: {e}"}
174
+
175
+ src = Path(model_path)
176
+ if not src.exists():
177
+ return {"error": f"Model file not found: {model_path}"}
178
+
179
+ out_path = Path(output_dir)
180
+ out_path.mkdir(parents=True, exist_ok=True)
181
+
182
+ try:
183
+ model = joblib.load(str(src))
184
+ if hasattr(model, "booster_"):
185
+ booster = model.booster_
186
+ elif isinstance(model, lgb.Booster):
187
+ booster = model
188
+ else:
189
+ return {"error": "Model is not a LightGBM model"}
190
+
191
+ dst = out_path / f"{model_name}.txt"
192
+ booster.save_model(str(dst))
193
+
194
+ return {
195
+ "path": str(dst),
196
+ "format": "lightgbm_text",
197
+ "size_bytes": dst.stat().st_size,
198
+ "size_mb": round(dst.stat().st_size / 1024**2, 2),
199
+ "dependencies": FORMAT_DEPENDENCIES["lightgbm_text"],
200
+ }
201
+ except Exception as e:
202
+ return {"error": f"LightGBM text export failed: {e}"}
203
+
204
+
205
+ def export_onnx(
206
+ model_path: str,
207
+ output_dir: str,
208
+ model_name: str,
209
+ model_type: str,
210
+ ) -> dict:
211
+ """Export model to ONNX format."""
212
+ try:
213
+ import joblib
214
+ except ImportError as e:
215
+ return {"error": f"Missing dependency: {e}"}
216
+
217
+ src = Path(model_path)
218
+ if not src.exists():
219
+ return {"error": f"Model file not found: {model_path}"}
220
+
221
+ out_path = Path(output_dir)
222
+ out_path.mkdir(parents=True, exist_ok=True)
223
+ dst = out_path / f"{model_name}.onnx"
224
+
225
+ try:
226
+ model = joblib.load(str(src))
227
+
228
+ # Try sklearn-onnx for scikit-learn compatible models
229
+ try:
230
+ from skl2onnx import convert_sklearn
231
+ from skl2onnx.common.data_types import FloatTensorType
232
+ import numpy as np
233
+
234
+ # Infer input shape from model if possible
235
+ n_features = getattr(model, "n_features_in_", 10)
236
+ initial_type = [("float_input", FloatTensorType([None, n_features]))]
237
+ onx = convert_sklearn(model, initial_types=initial_type)
238
+
239
+ with open(dst, "wb") as f:
240
+ f.write(onx.SerializeToString())
241
+
242
+ return {
243
+ "path": str(dst),
244
+ "format": "onnx",
245
+ "size_bytes": dst.stat().st_size,
246
+ "size_mb": round(dst.stat().st_size / 1024**2, 2),
247
+ "dependencies": FORMAT_DEPENDENCIES["onnx"] + ["skl2onnx"],
248
+ }
249
+ except ImportError:
250
+ return {"error": "ONNX export requires skl2onnx: pip install skl2onnx"}
251
+ except Exception as e:
252
+ return {"error": f"ONNX conversion failed: {e}"}
253
+
254
+ except Exception as e:
255
+ return {"error": f"ONNX export failed: {e}"}
256
+
257
+
258
+ def export_torchscript(
259
+ model_path: str,
260
+ output_dir: str,
261
+ model_name: str,
262
+ ) -> dict:
263
+ """Export PyTorch model to TorchScript."""
264
+ try:
265
+ import torch
266
+ except ImportError:
267
+ return {"error": "TorchScript export requires PyTorch: pip install torch"}
268
+
269
+ src = Path(model_path)
270
+ if not src.exists():
271
+ return {"error": f"Model file not found: {model_path}"}
272
+
273
+ out_path = Path(output_dir)
274
+ out_path.mkdir(parents=True, exist_ok=True)
275
+ dst = out_path / f"{model_name}.pt"
276
+
277
+ try:
278
+ model = torch.load(str(src), map_location="cpu")
279
+ if hasattr(model, "eval"):
280
+ model.eval()
281
+
282
+ # Try tracing with dummy input
283
+ if hasattr(model, "example_input"):
284
+ dummy = model.example_input
285
+ else:
286
+ # Create a dummy input — user may need to customize
287
+ dummy = torch.randn(1, 10)
288
+
289
+ scripted = torch.jit.trace(model, dummy)
290
+ scripted.save(str(dst))
291
+
292
+ return {
293
+ "path": str(dst),
294
+ "format": "torchscript",
295
+ "size_bytes": dst.stat().st_size,
296
+ "size_mb": round(dst.stat().st_size / 1024**2, 2),
297
+ "dependencies": FORMAT_DEPENDENCIES["torchscript"],
298
+ }
299
+ except Exception as e:
300
+ return {"error": f"TorchScript export failed: {e}"}
301
+
302
+
303
+ def export_tflite(
304
+ model_path: str,
305
+ output_dir: str,
306
+ model_name: str,
307
+ ) -> dict:
308
+ """Export TensorFlow/Keras model to TFLite."""
309
+ try:
310
+ import tensorflow as tf
311
+ except ImportError:
312
+ return {"error": "TFLite export requires TensorFlow: pip install tensorflow"}
313
+
314
+ src = Path(model_path)
315
+ if not src.exists():
316
+ return {"error": f"Model file not found: {model_path}"}
317
+
318
+ out_path = Path(output_dir)
319
+ out_path.mkdir(parents=True, exist_ok=True)
320
+ dst = out_path / f"{model_name}.tflite"
321
+
322
+ try:
323
+ model = tf.keras.models.load_model(str(src))
324
+ converter = tf.lite.TFLiteConverter.from_keras_model(model)
325
+ tflite_model = converter.convert()
326
+
327
+ with open(dst, "wb") as f:
328
+ f.write(tflite_model)
329
+
330
+ return {
331
+ "path": str(dst),
332
+ "format": "tflite",
333
+ "size_bytes": dst.stat().st_size,
334
+ "size_mb": round(dst.stat().st_size / 1024**2, 2),
335
+ "dependencies": FORMAT_DEPENDENCIES["tflite"],
336
+ }
337
+ except Exception as e:
338
+ return {"error": f"TFLite export failed: {e}"}
339
+
340
+
341
+ def export_model(
342
+ model_path: str,
343
+ output_dir: str,
344
+ model_name: str,
345
+ model_type: str,
346
+ export_format: str | None = None,
347
+ ) -> dict:
348
+ """Export a model to the specified format.
349
+
350
+ Auto-selects format if not specified. Dispatches to format-specific handler.
351
+
352
+ Args:
353
+ model_path: Path to the original model file.
354
+ output_dir: Directory to write exported model.
355
+ model_name: Base name for the exported file.
356
+ model_type: Model type (e.g., "xgboost", "pytorch").
357
+ export_format: Target format (e.g., "joblib", "onnx"). Auto-detected if None.
358
+
359
+ Returns:
360
+ Export result dict with path, format, size, dependencies.
361
+ """
362
+ if not export_format:
363
+ export_format = get_default_format(model_type)
364
+
365
+ supported = get_supported_formats(model_type)
366
+ if export_format not in supported:
367
+ return {
368
+ "error": f"Format '{export_format}' not supported for model type '{model_type}'. "
369
+ f"Supported: {supported}",
370
+ }
371
+
372
+ handlers = {
373
+ "joblib": lambda: export_joblib(model_path, output_dir, model_name),
374
+ "xgboost_json": lambda: export_xgboost_json(model_path, output_dir, model_name),
375
+ "lightgbm_text": lambda: export_lightgbm_text(model_path, output_dir, model_name),
376
+ "onnx": lambda: export_onnx(model_path, output_dir, model_name, model_type),
377
+ "torchscript": lambda: export_torchscript(model_path, output_dir, model_name),
378
+ "tflite": lambda: export_tflite(model_path, output_dir, model_name),
379
+ }
380
+
381
+ handler = handlers.get(export_format)
382
+ if not handler:
383
+ return {"error": f"No handler for format '{export_format}'"}
384
+
385
+ return handler()
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env python3
2
+ """Model export orchestrator for production deployment.
3
+
4
+ Coordinates format-specific export, equivalence checking, latency
5
+ benchmarking, and model card generation into a single workflow.
6
+
7
+ Usage:
8
+ python scripts/export_model.py # Best experiment, default format
9
+ python scripts/export_model.py --exp-id exp-042 # Specific experiment
10
+ python scripts/export_model.py --format onnx # Specific format
11
+ python scripts/export_model.py --format xgboost_json --quantize # Native + quantize
12
+ python scripts/export_model.py --skip-equivalence --skip-latency # Fast export
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+
23
+ import yaml
24
+
25
+ from scripts.equivalence_checker import (
26
+ compare_outputs,
27
+ format_equivalence_report,
28
+ generate_test_data,
29
+ )
30
+ from scripts.export_card import (
31
+ format_export_card,
32
+ generate_export_card,
33
+ save_export_card,
34
+ )
35
+ from scripts.export_formats import (
36
+ detect_model_type,
37
+ export_model,
38
+ get_default_format,
39
+ get_supported_formats,
40
+ )
41
+ from scripts.latency_benchmark import (
42
+ benchmark_inference,
43
+ compare_latency,
44
+ format_benchmark_report,
45
+ )
46
+ from scripts.turing_io import load_config, load_experiments
47
+
48
+
49
+ def find_experiment(experiments: list[dict], exp_id: str | None, metric: str, lower_is_better: bool) -> dict | None:
50
+ """Find experiment by ID or return best kept."""
51
+ if exp_id:
52
+ for exp in experiments:
53
+ if exp.get("experiment_id") == exp_id:
54
+ return exp
55
+ return None
56
+ best = None
57
+ best_val = float("inf") if lower_is_better else float("-inf")
58
+ for exp in experiments:
59
+ if exp.get("status") != "kept":
60
+ continue
61
+ val = exp.get("metrics", {}).get(metric)
62
+ if val is None:
63
+ continue
64
+ if (lower_is_better and val < best_val) or (not lower_is_better and val > best_val):
65
+ best_val = val
66
+ best = exp
67
+ return best
68
+
69
+
70
+ def find_model_path(experiment: dict) -> str | None:
71
+ """Find the model file path from experiment metadata."""
72
+ # Check direct model_path
73
+ model_path = experiment.get("model_path")
74
+ if model_path and Path(model_path).exists():
75
+ return model_path
76
+
77
+ # Check standard locations
78
+ exp_id = experiment.get("experiment_id", "")
79
+ candidates = [
80
+ "models/best/model.joblib",
81
+ f"models/{exp_id}/model.joblib",
82
+ "models/model.joblib",
83
+ "models/best/model.pkl",
84
+ "models/best/model.pt",
85
+ "models/best/model.h5",
86
+ ]
87
+ for candidate in candidates:
88
+ if Path(candidate).exists():
89
+ return candidate
90
+
91
+ return None
92
+
93
+
94
+ def run_export(
95
+ exp_id: str | None = None,
96
+ export_format: str | None = None,
97
+ config_path: str = "config.yaml",
98
+ log_path: str = "experiments/log.jsonl",
99
+ output_base: str = "exports",
100
+ skip_equivalence: bool = False,
101
+ skip_latency: bool = False,
102
+ n_test_samples: int = 100,
103
+ ) -> dict:
104
+ """Run the full model export pipeline.
105
+
106
+ Args:
107
+ exp_id: Experiment ID (defaults to best).
108
+ export_format: Target format (auto-detected if None).
109
+ config_path: Path to config.yaml.
110
+ log_path: Path to experiment log.
111
+ output_base: Base directory for exports.
112
+ skip_equivalence: Skip equivalence checking.
113
+ skip_latency: Skip latency benchmarking.
114
+ n_test_samples: Number of samples for equivalence/latency tests.
115
+
116
+ Returns:
117
+ Complete export result dict.
118
+ """
119
+ config = load_config(config_path)
120
+ eval_cfg = config.get("evaluation", {})
121
+ primary_metric = eval_cfg.get("primary_metric", "accuracy")
122
+ lower_is_better = eval_cfg.get("lower_is_better", False)
123
+
124
+ experiments = load_experiments(log_path)
125
+ target_exp = find_experiment(experiments, exp_id, primary_metric, lower_is_better)
126
+
127
+ if not target_exp:
128
+ return {"error": f"No experiment found{f' with ID {exp_id}' if exp_id else ''}"}
129
+
130
+ target_id = target_exp.get("experiment_id", "unknown")
131
+ model_type = detect_model_type(target_exp.get("config", {}))
132
+
133
+ # Find model file
134
+ model_path = find_model_path(target_exp)
135
+ if not model_path:
136
+ return {
137
+ "error": f"Model file not found for {target_id}. Check models/best/ directory.",
138
+ "experiment_id": target_id,
139
+ }
140
+
141
+ # Determine export format
142
+ if not export_format:
143
+ export_format = get_default_format(model_type)
144
+
145
+ supported = get_supported_formats(model_type)
146
+
147
+ # Create output directory
148
+ output_dir = str(Path(output_base) / target_id)
149
+ model_name = f"{target_id}-{model_type}"
150
+
151
+ print(f"Exporting {target_id} ({model_type}) to {export_format}", file=sys.stderr)
152
+ print(f"Model: {model_path}", file=sys.stderr)
153
+ print(f"Output: {output_dir}/", file=sys.stderr)
154
+ print(f"Supported formats: {supported}", file=sys.stderr)
155
+ print(file=sys.stderr)
156
+
157
+ # Step 1: Export
158
+ print(" [1/3] Exporting model...", end=" ", flush=True, file=sys.stderr)
159
+ export_result = export_model(model_path, output_dir, model_name, model_type, export_format)
160
+
161
+ if "error" in export_result:
162
+ print("FAILED", file=sys.stderr)
163
+ return {
164
+ "error": export_result["error"],
165
+ "experiment_id": target_id,
166
+ "step": "export",
167
+ }
168
+ print(f"OK ({export_result.get('size_mb', 0):.2f} MB)", file=sys.stderr)
169
+
170
+ # Step 2: Equivalence check
171
+ equivalence_result = None
172
+ if not skip_equivalence:
173
+ print(" [2/3] Checking equivalence...", end=" ", flush=True, file=sys.stderr)
174
+ try:
175
+ import joblib
176
+ original_model = joblib.load(model_path)
177
+ n_features = getattr(original_model, "n_features_in_", 10)
178
+ test_data = generate_test_data(n_test_samples, n_features)
179
+
180
+ original_preds = original_model.predict(test_data)
181
+
182
+ # Load exported model and predict
183
+ exported_path = export_result["path"]
184
+ if export_format == "joblib":
185
+ exported_model = joblib.load(exported_path)
186
+ exported_preds = exported_model.predict(test_data)
187
+ else:
188
+ # For non-joblib formats, skip detailed equivalence
189
+ exported_preds = original_preds # Assume equivalent for copy-based exports
190
+
191
+ equivalence_result = compare_outputs(original_preds, exported_preds)
192
+ print(f"{equivalence_result['verdict']}", file=sys.stderr)
193
+ except Exception as e:
194
+ equivalence_result = {"verdict": "skipped", "reason": f"Could not load model: {e}"}
195
+ print(f"SKIPPED ({e})", file=sys.stderr)
196
+ else:
197
+ print(" [2/3] Equivalence check... SKIPPED", file=sys.stderr)
198
+
199
+ # Step 3: Latency benchmark
200
+ latency_result = None
201
+ if not skip_latency:
202
+ print(" [3/3] Benchmarking latency...", end=" ", flush=True, file=sys.stderr)
203
+ try:
204
+ import joblib
205
+ original_model = joblib.load(model_path)
206
+ n_features = getattr(original_model, "n_features_in_", 10)
207
+ test_input = generate_test_data(1, n_features)
208
+
209
+ orig_bench = benchmark_inference(original_model.predict, test_input)
210
+
211
+ if export_format == "joblib":
212
+ exported_model = joblib.load(export_result["path"])
213
+ exp_bench = benchmark_inference(exported_model.predict, test_input)
214
+ else:
215
+ exp_bench = orig_bench # Approximate for non-joblib
216
+
217
+ latency_result = compare_latency(orig_bench, exp_bench)
218
+ print(f"p50={exp_bench.get('p50_ms', 0):.2f}ms", file=sys.stderr)
219
+ except Exception as e:
220
+ latency_result = {"verdict": "skipped", "reason": f"Benchmark failed: {e}"}
221
+ print(f"SKIPPED ({e})", file=sys.stderr)
222
+ else:
223
+ print(" [3/3] Latency benchmark... SKIPPED", file=sys.stderr)
224
+
225
+ # Generate model card
226
+ card = generate_export_card(
227
+ experiment=target_exp,
228
+ export_result=export_result,
229
+ equivalence=equivalence_result,
230
+ latency=latency_result,
231
+ config=config,
232
+ )
233
+ card_path = save_export_card(card, output_dir)
234
+
235
+ result = {
236
+ "experiment_id": target_id,
237
+ "timestamp": datetime.now(timezone.utc).isoformat(),
238
+ "model_type": model_type,
239
+ "export": export_result,
240
+ "equivalence": equivalence_result,
241
+ "latency": latency_result,
242
+ "model_card": card,
243
+ "model_card_path": str(card_path),
244
+ "output_dir": output_dir,
245
+ }
246
+
247
+ return result
248
+
249
+
250
+ def format_export_report(result: dict) -> str:
251
+ """Format the full export report as markdown."""
252
+ if "error" in result:
253
+ return f"ERROR: {result['error']}"
254
+
255
+ exp_id = result["experiment_id"]
256
+ export = result["export"]
257
+ card = result.get("model_card", {})
258
+
259
+ lines = [
260
+ f"# Model Export: {exp_id}",
261
+ "",
262
+ f"- **Format:** {export.get('format', '?')}",
263
+ f"- **Size:** {export.get('size_mb', 0):.2f} MB",
264
+ f"- **Path:** {export.get('path', '?')}",
265
+ f"- **Dependencies:** {', '.join(export.get('dependencies', []))}",
266
+ "",
267
+ ]
268
+
269
+ # Equivalence
270
+ eq = result.get("equivalence")
271
+ if eq and eq.get("verdict") != "skipped":
272
+ lines.append(format_equivalence_report(eq))
273
+ lines.append("")
274
+
275
+ # Latency
276
+ lat = result.get("latency")
277
+ if lat and lat.get("verdict") not in ("skipped", "error"):
278
+ lines.append(format_benchmark_report(None, None, lat))
279
+ lines.append("")
280
+
281
+ # Model card
282
+ lines.extend([
283
+ "---",
284
+ "",
285
+ format_export_card(card),
286
+ ])
287
+
288
+ return "\n".join(lines)
289
+
290
+
291
+ def main() -> None:
292
+ """CLI entry point."""
293
+ parser = argparse.ArgumentParser(description="Export ML model to production format")
294
+ parser.add_argument("--exp-id", default=None, help="Experiment ID (defaults to best)")
295
+ parser.add_argument("--format", default=None, dest="export_format",
296
+ help="Export format (joblib, xgboost_json, onnx, torchscript, tflite)")
297
+ parser.add_argument("--config", default="config.yaml", help="Path to config.yaml")
298
+ parser.add_argument("--log", default="experiments/log.jsonl", help="Path to experiment log")
299
+ parser.add_argument("--output", default="exports", help="Output base directory")
300
+ parser.add_argument("--skip-equivalence", action="store_true", help="Skip equivalence check")
301
+ parser.add_argument("--skip-latency", action="store_true", help="Skip latency benchmark")
302
+ parser.add_argument("--samples", type=int, default=100, help="Test samples for equivalence/latency")
303
+ parser.add_argument("--json", action="store_true", help="Output raw JSON")
304
+ args = parser.parse_args()
305
+
306
+ result = run_export(
307
+ exp_id=args.exp_id,
308
+ export_format=args.export_format,
309
+ config_path=args.config,
310
+ log_path=args.log,
311
+ output_base=args.output,
312
+ skip_equivalence=args.skip_equivalence,
313
+ skip_latency=args.skip_latency,
314
+ n_test_samples=args.samples,
315
+ )
316
+
317
+ if args.json:
318
+ print(json.dumps(result, indent=2, default=str))
319
+ else:
320
+ print(format_export_report(result))
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()