alexwengg commited on
Commit
8f6aaf0
·
verified ·
1 Parent(s): 3bfcc7d

Add validated embedding W8 classification option

Browse files
README.md CHANGED
@@ -41,4 +41,6 @@ The feature graph compute plan assigned 53.13% of operations to ANE and 46.87% t
41
 
42
  The original L128/K8 classification packages remain available, with a separate `runtime.py` entry point. Up to eight labels fit that bucket. 100/100 selected choices matched in FP16. See the classification report JSONs for the exact selected samples and limits. The full checkpoint's task and dataset scores have not been reproduced here.
43
 
 
 
44
  The Core ML deployment target is iOS 17/macOS 14. Conversion scripts, pinned dependencies, asset hashes, and selected verification reports are included. The source revision is a current pinned snapshot; identity with the historical Decision Index evaluation checkpoint has not been established.
 
41
 
42
  The original L128/K8 classification packages remain available, with a separate `runtime.py` entry point. Up to eight labels fit that bucket. 100/100 selected choices matched in FP16. See the classification report JSONs for the exact selected samples and limits. The full checkpoint's task and dataset scores have not been reproduced here.
43
 
44
+ An optional **embedding-only W8** classifier keeps the encoder and heads FP16. It reduces the package from **388,981,604 to 291,053,994 bytes** (25.2%). On an M5 Pro plugged into AC, it agreed with FP16 on all 14 bucket-fitting requests in a fixed 20-request manifest (largest probability difference 0.00992), and matched the native classifier on 100/100 selected eligible application requests (largest confidence difference 0.01370). The 14-request paired full-request p50 was 3.662 ms FP16 versus 3.632 ms W8 with automatic device selection; this is a size option, not a demonstrated speedup. Forced CPU+ANE was slower (9.168 versus 9.666 ms). The W8 compute plan put 496/516 executable ops on ANE under forced CPU+ANE, with 20 CPU boundary/constant ops. Use `runtime.py --precision embedding_w8` for this classification package; extraction continues to use its separate FP32 or FP16 packages.
45
+
46
  The Core ML deployment target is iOS 17/macOS 14. Conversion scripts, pinned dependencies, asset hashes, and selected verification reports are included. The source revision is a current pinned snapshot; identity with the historical Decision Index evaluation checkpoint has not been established.
build/gliner2_base_classification_embedding_w8_L128_K8.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:11c56d5d686afb3812181024125c8102756d616230606e849a0c97ebd9b34dd9
3
+ size 990593
build/gliner2_base_classification_embedding_w8_L128_K8.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:037f91cb176d31a38bc1795272d99c47531f3ac235b6f4533bab0df9ae4de327
3
+ size 290062784
build/gliner2_base_classification_embedding_w8_L128_K8.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "1D1E4BE6-7753-4709-9319-081055B47FD9": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Specification",
7
+ "name": "model.mlmodel",
8
+ "path": "com.apple.CoreML/model.mlmodel"
9
+ },
10
+ "8F904B6A-73EB-47FB-B055-2D2B3240F1A0": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Weights",
13
+ "name": "weights",
14
+ "path": "com.apple.CoreML/weights"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "1D1E4BE6-7753-4709-9319-081055B47FD9"
18
+ }
compress-coreml.py CHANGED
@@ -5,7 +5,14 @@ import time
5
  from pathlib import Path
6
 
7
  import coremltools as ct
8
- from coremltools.optimize.coreml import OpPalettizerConfig, OptimizationConfig, palettize_weights
 
 
 
 
 
 
 
9
 
10
 
11
  def package_bytes(path):
@@ -17,25 +24,58 @@ def main():
17
  parser.add_argument("--source", required=True)
18
  parser.add_argument("--output", required=True)
19
  parser.add_argument("--bits", type=int, choices=[4, 6, 8], default=8)
 
 
20
  parser.add_argument("--granularity", choices=["per_tensor", "per_grouped_channel"], default="per_tensor")
21
  parser.add_argument("--group-size", type=int, default=32)
22
  args = parser.parse_args()
23
  start = time.perf_counter()
24
  model = ct.models.MLModel(args.source, skip_model_load=True)
25
- config = OptimizationConfig(global_config=OpPalettizerConfig(
26
- mode="kmeans", nbits=args.bits, granularity=args.granularity,
27
- group_size=args.group_size,
28
- enable_per_channel_scale=args.granularity == "per_grouped_channel",
29
- num_kmeans_workers=4,
30
- ))
31
- compressed = palettize_weights(model, config=config)
32
- compressed.short_description = f"{model.short_description}; LUT{args.bits} weights"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  compressed.author = model.author
34
  compressed.license = model.license
35
  compressed.user_defined_metadata.update(model.user_defined_metadata)
36
- compressed.user_defined_metadata["weight_compression"] = f"LUT{args.bits} kmeans"
37
  compressed.save(args.output)
38
- report = {"source": args.source, "output": args.output, "granularity": args.granularity,
 
 
 
39
  "source_bytes": package_bytes(Path(args.source)),
40
  "output_bytes": package_bytes(Path(args.output)), "compression_seconds": time.perf_counter() - start}
41
  Path(args.output).with_suffix(".json").write_text(json.dumps(report, indent=2) + "\n")
 
5
  from pathlib import Path
6
 
7
  import coremltools as ct
8
+ from coremltools.optimize.coreml import (
9
+ OpLinearQuantizerConfig,
10
+ OpPalettizerConfig,
11
+ OptimizationConfig,
12
+ get_weights_metadata,
13
+ linear_quantize_weights,
14
+ palettize_weights,
15
+ )
16
 
17
 
18
  def package_bytes(path):
 
24
  parser.add_argument("--source", required=True)
25
  parser.add_argument("--output", required=True)
26
  parser.add_argument("--bits", type=int, choices=[4, 6, 8], default=8)
27
+ parser.add_argument("--method", choices=["lut", "linear", "embedding"], default="lut")
28
+ parser.add_argument("--linear-mode", choices=["linear_symmetric", "linear"], default="linear_symmetric")
29
  parser.add_argument("--granularity", choices=["per_tensor", "per_grouped_channel"], default="per_tensor")
30
  parser.add_argument("--group-size", type=int, default=32)
31
  args = parser.parse_args()
32
  start = time.perf_counter()
33
  model = ct.models.MLModel(args.source, skip_model_load=True)
34
+ selected_weights = None
35
+ if args.method in ("linear", "embedding"):
36
+ if args.bits != 8:
37
+ parser.error("linear quantization here supports only 8-bit weights")
38
+ quantizer = OpLinearQuantizerConfig(
39
+ mode=args.linear_mode, dtype="int8", granularity="per_channel", weight_threshold=2048,
40
+ )
41
+ if args.method == "embedding":
42
+ metadata = get_weights_metadata(model, weight_threshold=2048)
43
+ names = [
44
+ name for name, weight in metadata.items()
45
+ if "encoder_embeddings_word_embeddings" in name
46
+ and len(weight.val.shape) == 2
47
+ and any(op.op_type == "gather" for op in weight.child_ops)
48
+ ]
49
+ if len(names) != 1:
50
+ raise ValueError(f"expected one token embedding weight, found {names}")
51
+ config = OptimizationConfig(op_name_configs={names[0]: quantizer})
52
+ selected_weights = names
53
+ else:
54
+ config = OptimizationConfig(global_config=quantizer)
55
+ compressed = linear_quantize_weights(model, config=config)
56
+ variant = (
57
+ f"W8 {args.linear_mode} token embedding only"
58
+ if args.method == "embedding" else f"W8 {args.linear_mode} per-channel"
59
+ )
60
+ else:
61
+ config = OptimizationConfig(global_config=OpPalettizerConfig(
62
+ mode="kmeans", nbits=args.bits, granularity=args.granularity,
63
+ group_size=args.group_size,
64
+ enable_per_channel_scale=args.granularity == "per_grouped_channel",
65
+ num_kmeans_workers=4,
66
+ ))
67
+ compressed = palettize_weights(model, config=config)
68
+ variant = f"LUT{args.bits} {args.granularity}"
69
+ compressed.short_description = f"{model.short_description}; {variant} weights"
70
  compressed.author = model.author
71
  compressed.license = model.license
72
  compressed.user_defined_metadata.update(model.user_defined_metadata)
73
+ compressed.user_defined_metadata["weight_compression"] = variant
74
  compressed.save(args.output)
75
+ report = {"source": args.source, "output": args.output, "method": args.method,
76
+ "linear_mode": args.linear_mode if args.method in ("linear", "embedding") else None,
77
+ "granularity": "per_channel" if args.method in ("linear", "embedding") else args.granularity,
78
+ "selected_weights": selected_weights,
79
  "source_bytes": package_bytes(Path(args.source)),
80
  "output_bytes": package_bytes(Path(args.output)), "compression_seconds": time.perf_counter() - start}
81
  Path(args.output).with_suffix(".json").write_text(json.dumps(report, indent=2) + "\n")
reports/classification-embedding-w8.json ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "source_revision": "1a8bc24e00dc7300b9017c81d63e3dcdabb26596",
3
+ "shape": "L128/K8 classification",
4
+ "candidate_package": "gliner2_base_classification_embedding_w8_L128_K8.mlpackage",
5
+ "weight_sha256": "037f91cb176d31a38bc1795272d99c47531f3ac235b6f4533bab0df9ae4de327",
6
+ "compression": {
7
+ "source": "build/gliner2_base_classification_fp16_L128_K8.mlpackage",
8
+ "output": "build/gliner2_base_classification_embedding_w8_L128_K8.mlpackage",
9
+ "method": "embedding",
10
+ "granularity": "per_channel",
11
+ "selected_weights": [
12
+ "encoder_embeddings_word_embeddings_weight_to_fp16"
13
+ ],
14
+ "source_bytes": 388981604,
15
+ "output_bytes": 291053994,
16
+ "compression_seconds": 5.819930917001329
17
+ },
18
+ "paired_first_manifest_all": {
19
+ "reference": "build/gliner2_base_classification_fp16_L128_K8.mlpackage",
20
+ "candidate": "build/gliner2_base_classification_embedding_w8_L128_K8.mlpackage",
21
+ "manifest": "../../gliner2-small/coreml/fixtures/optimization-selected20.jsonl",
22
+ "compute_units": "all",
23
+ "processor": "arm",
24
+ "machine": "arm64",
25
+ "macos": "27.0",
26
+ "power_audit_after_run": "Now drawing from 'AC Power'\n -InternalBattery-0 (id=35651683)\t50%; charging; 1:25 remaining present: true",
27
+ "reference_load_ms": 2044.181875011418,
28
+ "candidate_load_ms": 4582.357916980982,
29
+ "warmup_calls_per_model": 1,
30
+ "timed_order": "alternating reference-first/candidate-first by request",
31
+ "checked": 14,
32
+ "choice_agreement": 14,
33
+ "max_probability_error": 0.00992196798324585,
34
+ "probability_error_limit": 0.02,
35
+ "reference_p50_ms": 3.334374981932342,
36
+ "candidate_p50_ms": 3.261812496930361,
37
+ "reference_p95_ms": 9.934750036336482,
38
+ "candidate_p95_ms": 5.232541996520013,
39
+ "reference_total_p50_ms": 3.6611664691008627,
40
+ "candidate_total_p50_ms": 3.6319584760349244
41
+ },
42
+ "paired_first_manifest_cpu_ane": {
43
+ "reference": "build/gliner2_base_classification_fp16_L128_K8.mlpackage",
44
+ "candidate": "build/gliner2_base_classification_embedding_w8_L128_K8.mlpackage",
45
+ "manifest": "../../gliner2-small/coreml/fixtures/optimization-selected20.jsonl",
46
+ "compute_units": "cpu-ane",
47
+ "processor": "arm",
48
+ "machine": "arm64",
49
+ "macos": "27.0",
50
+ "power_audit_after_run": "Now drawing from 'AC Power'\n -InternalBattery-0 (id=35651683)\t51%; charging; 1:33 remaining present: true",
51
+ "reference_load_ms": 3068.8863749965094,
52
+ "candidate_load_ms": 5926.175208995119,
53
+ "warmup_calls_per_model": 1,
54
+ "timed_order": "alternating reference-first/candidate-first by request",
55
+ "checked": 14,
56
+ "choice_agreement": 14,
57
+ "max_probability_error": 0.0068359375,
58
+ "probability_error_limit": 0.02,
59
+ "reference_p50_ms": 7.578062970424071,
60
+ "candidate_p50_ms": 7.721708010649309,
61
+ "reference_p95_ms": 8.475541020743549,
62
+ "candidate_p95_ms": 9.883625025395304,
63
+ "reference_total_p50_ms": 9.168104501441121,
64
+ "candidate_total_p50_ms": 9.666354482760653
65
+ },
66
+ "native_selected100": {
67
+ "model": "fastino/gliner2.5-base-v1",
68
+ "revision": "1a8bc24e00dc7300b9017c81d63e3dcdabb26596",
69
+ "package": "build/gliner2_base_classification_embedding_w8_L128_K8.mlpackage",
70
+ "selected_manifest": "first eligible rows in source suite order; no gold labels used",
71
+ "counts": {
72
+ "checked": 100,
73
+ "too_many_options": 300,
74
+ "too_long": 7,
75
+ "duplicate_labels": 0,
76
+ "mismatches": 0
77
+ },
78
+ "maximum_confidence_error": 0.013702988624572754,
79
+ "mean_confidence_error": 0.001015886664390564,
80
+ "model_call_p50_ms": 10.512874985579401,
81
+ "model_call_p95_ms": 14.00475000264123,
82
+ "failures": []
83
+ },
84
+ "compute_plan_forced_cpu_ane": {
85
+ "total_ops": 516,
86
+ "ane_ops": 496,
87
+ "cpu_ops": 20,
88
+ "ane_percent": 96.1,
89
+ "reasons": [
90
+ {
91
+ "reason": "Unsupported tensor data type: int32",
92
+ "count": 17,
93
+ "estimated_cpu_runtime_ms": 2.8975,
94
+ "op_types": {
95
+ "ios17.expand_dims": 5,
96
+ "ios17.cast": 5,
97
+ "ios17.add": 2,
98
+ "select": 2,
99
+ "ios17.gather": 1,
100
+ "ios17.mul": 1,
101
+ "tile": 1
102
+ },
103
+ "ops": [
104
+ "add_0",
105
+ "select_0",
106
+ "add_0_1",
107
+ "select_0_1",
108
+ "inputs_embeds_cast_fp16",
109
+ "mask_3",
110
+ "mask_3_to_fp16",
111
+ "var_86",
112
+ "extended_attention_mask",
113
+ "extended_attention_mask_to_uint16",
114
+ "var_88_cast_int16_to_int32",
115
+ "var_89",
116
+ "attention_mask_1",
117
+ "var_272",
118
+ "var_2537",
119
+ "indices",
120
+ "indices_to_int16"
121
+ ]
122
+ },
123
+ {
124
+ "reason": "Unable to resolve operation input \"y\".",
125
+ "count": 2,
126
+ "estimated_cpu_runtime_ms": 0.002,
127
+ "op_types": {
128
+ "ios17.greater_equal": 2
129
+ },
130
+ "ops": [
131
+ "greater_equal_0",
132
+ "greater_equal_0_1"
133
+ ]
134
+ },
135
+ {
136
+ "reason": "ANE not available for this op",
137
+ "count": 1,
138
+ "estimated_cpu_runtime_ms": 0,
139
+ "op_types": {
140
+ "ios16.constexpr_affine_dequantize": 1
141
+ },
142
+ "ops": [
143
+ "encoder_embeddings_word_embeddings_weight_to_fp16_quantized"
144
+ ]
145
+ }
146
+ ]
147
+ },
148
+ "scope": "Selected real classification requests only; no Decision Index or 2048 score; extraction packages are separate."
149
+ }
runtime.py CHANGED
@@ -9,9 +9,12 @@ import numpy as np
9
  from preprocessing import load_processor, prepare_with_processor
10
 
11
 
12
- def classify(model_dir: str, text: str, task: str, labels: list[str], length: int = 128, max_options: int = 8):
 
13
  model_dir = Path(model_dir)
14
- package = model_dir / f"gliner2_base_classification_fp16_L{length}_K{max_options}.mlpackage"
 
 
15
  processor = load_processor(str(model_dir / "tokenizer"))
16
  arrays = prepare_with_processor(processor, text, task, labels, length, max_options)
17
  model = ct.models.MLModel(str(package), compute_units=ct.ComputeUnit.ALL)
@@ -28,9 +31,10 @@ def main():
28
  parser.add_argument("--labels", required=True, help="JSON list of label strings")
29
  parser.add_argument("--length", type=int, default=128)
30
  parser.add_argument("--max-options", type=int, default=8)
 
31
  args = parser.parse_args()
32
  print(json.dumps(classify(args.model_dir, args.text, args.task, json.loads(args.labels),
33
- args.length, args.max_options), indent=2))
34
 
35
 
36
  if __name__ == "__main__":
 
9
  from preprocessing import load_processor, prepare_with_processor
10
 
11
 
12
+ def classify(model_dir: str, text: str, task: str, labels: list[str], length: int = 128, max_options: int = 8,
13
+ precision: str = "fp16"):
14
  model_dir = Path(model_dir)
15
+ if precision not in ("fp16", "embedding_w8"):
16
+ raise ValueError(f"unsupported classification precision: {precision}")
17
+ package = model_dir / f"gliner2_base_classification_{precision}_L{length}_K{max_options}.mlpackage"
18
  processor = load_processor(str(model_dir / "tokenizer"))
19
  arrays = prepare_with_processor(processor, text, task, labels, length, max_options)
20
  model = ct.models.MLModel(str(package), compute_units=ct.ComputeUnit.ALL)
 
31
  parser.add_argument("--labels", required=True, help="JSON list of label strings")
32
  parser.add_argument("--length", type=int, default=128)
33
  parser.add_argument("--max-options", type=int, default=8)
34
+ parser.add_argument("--precision", choices=["fp16", "embedding_w8"], default="fp16")
35
  args = parser.parse_args()
36
  print(json.dumps(classify(args.model_dir, args.text, args.task, json.loads(args.labels),
37
+ args.length, args.max_options, args.precision), indent=2))
38
 
39
 
40
  if __name__ == "__main__":
verify-compression.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compare two GLiNER2 Core ML packages on selected real classification requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import platform
9
+ import statistics
10
+ import subprocess
11
+ import time
12
+ from pathlib import Path
13
+
14
+ import coremltools as ct
15
+ import numpy as np
16
+
17
+ from preprocessing import load_processor, prepare_with_processor
18
+
19
+
20
+ def percentile(values: list[float], p: float) -> float | None:
21
+ if not values:
22
+ return None
23
+ return sorted(values)[max(0, min(len(values) - 1, math.ceil(p * len(values)) - 1))]
24
+
25
+
26
+ def median(cases: list[dict], field: str) -> float | None:
27
+ return statistics.median(row[field] for row in cases) if cases else None
28
+
29
+
30
+ def run() -> None:
31
+ parser = argparse.ArgumentParser(description=__doc__)
32
+ parser.add_argument("--reference", type=Path, required=True)
33
+ parser.add_argument("--candidate", type=Path, required=True)
34
+ parser.add_argument("--tokenizer", type=Path, required=True)
35
+ parser.add_argument("--manifest", type=Path, required=True)
36
+ parser.add_argument("--out", type=Path, required=True)
37
+ parser.add_argument("--compute-units", choices=["all", "cpu-ane"], default="all")
38
+ parser.add_argument("--length", type=int, default=128)
39
+ parser.add_argument("--max-options", type=int, default=8)
40
+ parser.add_argument("--limit", type=int, default=None, help="stop after this many eligible requests")
41
+ parser.add_argument("--max-probability-error", type=float, default=0.02)
42
+ args = parser.parse_args()
43
+
44
+ units = ct.ComputeUnit.ALL if args.compute_units == "all" else ct.ComputeUnit.CPU_AND_NE
45
+ processor = load_processor(str(args.tokenizer))
46
+ load_start = time.perf_counter()
47
+ reference = ct.models.MLModel(str(args.reference), compute_units=units)
48
+ reference_load_ms = (time.perf_counter() - load_start) * 1000
49
+ load_start = time.perf_counter()
50
+ candidate = ct.models.MLModel(str(args.candidate), compute_units=units)
51
+ candidate_load_ms = (time.perf_counter() - load_start) * 1000
52
+ rows = [json.loads(line) for line in args.manifest.read_text().splitlines() if line]
53
+ prepared = []
54
+ skipped = []
55
+ for row in rows:
56
+ if args.limit is not None and len(prepared) >= args.limit:
57
+ break
58
+ labels = [(description or key).strip() for key, description in row["options"]]
59
+ prepare_start = time.perf_counter()
60
+ try:
61
+ arrays = prepare_with_processor(processor, row["state"], "decision", labels, args.length, args.max_options)
62
+ except ValueError as error:
63
+ skipped.append({"suite": row["suite"], "index": row["index"], "reason": str(error)})
64
+ continue
65
+ prepare_ms = (time.perf_counter() - prepare_start) * 1000
66
+
67
+ prepared.append((row, labels, arrays, prepare_ms))
68
+
69
+ if prepared:
70
+ for model in (reference, candidate):
71
+ model.predict(prepared[0][2])
72
+
73
+ checked = []
74
+ for index, (row, labels, arrays, prepare_ms) in enumerate(prepared):
75
+ outputs = {}
76
+ order = (("reference", reference), ("candidate", candidate))
77
+ if index % 2:
78
+ order = tuple(reversed(order))
79
+ for name, model in order:
80
+ start = time.perf_counter()
81
+ probabilities = np.asarray(model.predict(arrays)["probabilities"])[0, : len(labels)]
82
+ outputs[name] = (probabilities, (time.perf_counter() - start) * 1000)
83
+ expected, reference_ms = outputs["reference"]
84
+ actual, candidate_ms = outputs["candidate"]
85
+ checked.append({
86
+ "suite": row["suite"],
87
+ "index": row["index"],
88
+ "choice_agrees": int(expected.argmax()) == int(actual.argmax()),
89
+ "max_probability_error": float(np.max(np.abs(expected - actual))),
90
+ "prepare_ms": prepare_ms,
91
+ "reference_ms": reference_ms,
92
+ "candidate_ms": candidate_ms,
93
+ "reference_total_ms": prepare_ms + reference_ms,
94
+ "candidate_total_ms": prepare_ms + candidate_ms,
95
+ })
96
+
97
+ report = {
98
+ "reference": str(args.reference),
99
+ "candidate": str(args.candidate),
100
+ "manifest": str(args.manifest),
101
+ "compute_units": args.compute_units,
102
+ "processor": platform.processor(),
103
+ "machine": platform.machine(),
104
+ "macos": platform.mac_ver()[0],
105
+ "power_audit_after_run": subprocess.run(["pmset", "-g", "batt"], capture_output=True, text=True).stdout.strip(),
106
+ "reference_load_ms": reference_load_ms,
107
+ "candidate_load_ms": candidate_load_ms,
108
+ "warmup_calls_per_model": 1 if prepared else 0,
109
+ "timed_order": "alternating reference-first/candidate-first by request",
110
+ "checked": len(checked),
111
+ "skipped": skipped,
112
+ "choice_agreement": sum(row["choice_agrees"] for row in checked),
113
+ "max_probability_error": max((row["max_probability_error"] for row in checked), default=None),
114
+ "probability_error_limit": args.max_probability_error,
115
+ "reference_p50_ms": median(checked, "reference_ms"),
116
+ "candidate_p50_ms": median(checked, "candidate_ms"),
117
+ "reference_p95_ms": percentile([row["reference_ms"] for row in checked], 0.95),
118
+ "candidate_p95_ms": percentile([row["candidate_ms"] for row in checked], 0.95),
119
+ "reference_total_p50_ms": median(checked, "reference_total_ms"),
120
+ "candidate_total_p50_ms": median(checked, "candidate_total_ms"),
121
+ "cases": checked,
122
+ }
123
+ args.out.parent.mkdir(parents=True, exist_ok=True)
124
+ args.out.write_text(json.dumps(report, indent=2) + "\n")
125
+ summary = {key: value for key, value in report.items() if key not in ("cases", "skipped")}
126
+ summary["skipped_count"] = len(skipped)
127
+ print(json.dumps(summary, indent=2))
128
+ if not checked or report["choice_agreement"] != len(checked):
129
+ raise SystemExit("candidate changed a checked decision")
130
+ if report["max_probability_error"] > args.max_probability_error:
131
+ raise SystemExit("candidate exceeded the selected-request probability error limit")
132
+
133
+
134
+ if __name__ == "__main__":
135
+ run()