asm-protocol 0.5.0__py3-none-any.whl
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.
- asm_cli.py +626 -0
- asm_protocol-0.5.0.dist-info/METADATA +395 -0
- asm_protocol-0.5.0.dist-info/RECORD +21 -0
- asm_protocol-0.5.0.dist-info/WHEEL +5 -0
- asm_protocol-0.5.0.dist-info/entry_points.txt +4 -0
- asm_protocol-0.5.0.dist-info/licenses/LICENSE +21 -0
- asm_protocol-0.5.0.dist-info/top_level.txt +7 -0
- asm_select_api.py +144 -0
- asm_selector_mcp.py +94 -0
- library_select.py +149 -0
- mcp_server_json_asm.py +177 -0
- openrouter_adapter.py +421 -0
- scorer/__init__.py +45 -0
- scorer/data/elo_snapshot.json +5027 -0
- scorer/scorer.py +883 -0
- scorer/test_langchain_adapter.py +54 -0
- scorer/test_library_select.py +104 -0
- scorer/test_manifests_schema.py +55 -0
- scorer/test_mcp_server_json_asm.py +84 -0
- scorer/test_openrouter_adapter.py +238 -0
- scorer/test_scorer.py +447 -0
scorer/scorer.py
ADDED
|
@@ -0,0 +1,883 @@
|
|
|
1
|
+
"""ASM Scorer — Service selection engine for Agent Service Manifest.
|
|
2
|
+
|
|
3
|
+
v0.2: Weighted average scoring (demo-ready)
|
|
4
|
+
v1.0: Filter (hard constraints) + TOPSIS (multi-criteria ranking)
|
|
5
|
+
v1.1: Trust delta scoring with exponential decay (Signed Receipts integration)
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ──────────────────────────────────────────────
|
|
18
|
+
# Data Structures
|
|
19
|
+
# ──────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class ServiceVector:
|
|
23
|
+
"""Extracted numeric vector from an ASM manifest for scoring."""
|
|
24
|
+
service_id: str
|
|
25
|
+
display_name: str
|
|
26
|
+
taxonomy: str
|
|
27
|
+
cost_per_unit: float # representative cost (primary billing dimension)
|
|
28
|
+
quality_score: float # primary quality metric, normalized to [0, 1]
|
|
29
|
+
latency_seconds: float # p50 latency in seconds
|
|
30
|
+
uptime: float # 0-1
|
|
31
|
+
raw_manifest: dict = field(default_factory=dict, repr=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Constraints:
|
|
36
|
+
"""Hard constraints — services that violate any constraint are filtered out."""
|
|
37
|
+
min_quality: float | None = None # e.g., 0.7
|
|
38
|
+
max_cost: float | None = None # e.g., 0.10
|
|
39
|
+
max_latency_s: float | None = None # e.g., 5.0
|
|
40
|
+
min_uptime: float | None = None # e.g., 0.99
|
|
41
|
+
required_taxonomy: str | None = None # e.g., "ai.llm.chat"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Preferences:
|
|
46
|
+
"""Soft preferences — weights for scoring dimensions. Must sum to 1.
|
|
47
|
+
|
|
48
|
+
io_ratio controls how input vs output token costs are blended:
|
|
49
|
+
cost_repr = io_ratio * input_cost + (1 - io_ratio) * output_cost
|
|
50
|
+
Typical values:
|
|
51
|
+
0.3 = chat scenario (short prompts, long responses) — default
|
|
52
|
+
0.8 = RAG scenario (long context, short answers)
|
|
53
|
+
0.1 = creative writing (short prompts, very long outputs)
|
|
54
|
+
0.5 = balanced / translation tasks
|
|
55
|
+
"""
|
|
56
|
+
cost: float = 0.4
|
|
57
|
+
quality: float = 0.35
|
|
58
|
+
speed: float = 0.15
|
|
59
|
+
reliability: float = 0.10
|
|
60
|
+
io_ratio: float = 0.3 # input token ratio, default 0.3 (typical chat scenario)
|
|
61
|
+
|
|
62
|
+
def __post_init__(self):
|
|
63
|
+
total = self.cost + self.quality + self.speed + self.reliability
|
|
64
|
+
if not math.isclose(total, 1.0, abs_tol=0.01):
|
|
65
|
+
raise ValueError(f"Preference weights must sum to 1.0, got {total:.3f}")
|
|
66
|
+
if not (0.0 <= self.io_ratio <= 1.0):
|
|
67
|
+
raise ValueError(f"io_ratio must be in [0, 1], got {self.io_ratio}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class ScoredService:
|
|
72
|
+
"""A service with its computed score and breakdown."""
|
|
73
|
+
service: ServiceVector
|
|
74
|
+
total_score: float
|
|
75
|
+
breakdown: dict[str, float]
|
|
76
|
+
rank: int = 0
|
|
77
|
+
reasoning: str = ""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ──────────────────────────────────────────────
|
|
81
|
+
# Manifest Parsing
|
|
82
|
+
# ──────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
def _parse_latency(s: str | None) -> float:
|
|
85
|
+
"""Parse latency string like '800ms', '3s', '~15s' into seconds."""
|
|
86
|
+
if not s:
|
|
87
|
+
return float("inf")
|
|
88
|
+
s = s.strip().lstrip("~").lstrip("<").lstrip(">")
|
|
89
|
+
if s.endswith("ms"):
|
|
90
|
+
return float(s[:-2]) / 1000
|
|
91
|
+
if s.endswith("s"):
|
|
92
|
+
return float(s[:-1])
|
|
93
|
+
if s.endswith("min"):
|
|
94
|
+
return float(s[:-3]) * 60
|
|
95
|
+
try:
|
|
96
|
+
return float(s)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return float("inf")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _extract_primary_cost(pricing: dict, io_ratio: float = 0.3) -> float:
|
|
102
|
+
"""Extract a representative cost from billing_dimensions.
|
|
103
|
+
|
|
104
|
+
For multi-dimension services (e.g., LLM with input+output tokens),
|
|
105
|
+
uses a weighted estimate:
|
|
106
|
+
cost = io_ratio * input_cost + (1 - io_ratio) * output_cost
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
pricing: The pricing dict from an ASM manifest.
|
|
110
|
+
io_ratio: Input token cost weight (0-1). Default 0.3 (chat scenario).
|
|
111
|
+
Higher values favor input-heavy workloads (e.g., RAG = 0.8).
|
|
112
|
+
"""
|
|
113
|
+
dims = pricing.get("billing_dimensions", [])
|
|
114
|
+
if not dims:
|
|
115
|
+
return 0.0
|
|
116
|
+
|
|
117
|
+
# Special handling for LLM input+output token pricing
|
|
118
|
+
input_cost = None
|
|
119
|
+
output_cost = None
|
|
120
|
+
for d in dims:
|
|
121
|
+
dim = d.get("dimension", "")
|
|
122
|
+
cost = d.get("cost_per_unit", 0)
|
|
123
|
+
unit = d.get("unit", "per_1")
|
|
124
|
+
|
|
125
|
+
# Normalize to per-unit cost
|
|
126
|
+
if unit == "per_1M":
|
|
127
|
+
cost = cost / 1_000_000
|
|
128
|
+
elif unit == "per_1K":
|
|
129
|
+
cost = cost / 1_000
|
|
130
|
+
|
|
131
|
+
if dim == "input_token":
|
|
132
|
+
input_cost = cost
|
|
133
|
+
elif dim == "output_token":
|
|
134
|
+
output_cost = cost
|
|
135
|
+
|
|
136
|
+
if input_cost is not None and output_cost is not None:
|
|
137
|
+
return io_ratio * input_cost + (1 - io_ratio) * output_cost
|
|
138
|
+
|
|
139
|
+
# Single dimension — use first
|
|
140
|
+
d = dims[0]
|
|
141
|
+
cost = d.get("cost_per_unit", 0)
|
|
142
|
+
unit = d.get("unit", "per_1")
|
|
143
|
+
if unit == "per_1M":
|
|
144
|
+
cost = cost / 1_000_000
|
|
145
|
+
elif unit == "per_1K":
|
|
146
|
+
cost = cost / 1_000
|
|
147
|
+
return cost
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _extract_primary_quality(quality: dict) -> float:
|
|
151
|
+
"""Extract primary quality score, normalized to [0, 1]."""
|
|
152
|
+
metrics = quality.get("metrics", [])
|
|
153
|
+
if not metrics:
|
|
154
|
+
return 0.5 # unknown quality → neutral
|
|
155
|
+
|
|
156
|
+
m = metrics[0]
|
|
157
|
+
score = m.get("score", 0)
|
|
158
|
+
scale = m.get("scale", "")
|
|
159
|
+
|
|
160
|
+
# Normalize based on scale
|
|
161
|
+
if scale == "Elo":
|
|
162
|
+
# LMSYS Elo: roughly 800-1400 range, map to [0, 1]
|
|
163
|
+
return min(max((score - 800) / 600, 0), 1)
|
|
164
|
+
elif scale == "0-100":
|
|
165
|
+
return score / 100
|
|
166
|
+
elif scale == "0-1":
|
|
167
|
+
return score
|
|
168
|
+
elif scale == "1-5":
|
|
169
|
+
return (score - 1) / 4
|
|
170
|
+
elif scale == "lower_is_better":
|
|
171
|
+
# FID-like metrics: lower is better, map 0→1, 50→0
|
|
172
|
+
return max(1 - score / 50, 0)
|
|
173
|
+
else:
|
|
174
|
+
# Best guess: if score > 1, assume 0-100 or similar
|
|
175
|
+
if score > 1:
|
|
176
|
+
return min(score / 100, 1)
|
|
177
|
+
return score
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def parse_manifest(manifest: dict, io_ratio: float = 0.3) -> ServiceVector:
|
|
181
|
+
"""Parse an ASM manifest JSON into a scoreable ServiceVector.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
manifest: ASM manifest dict.
|
|
185
|
+
io_ratio: Input token cost weight for multi-dimension pricing.
|
|
186
|
+
"""
|
|
187
|
+
pricing = manifest.get("pricing", {})
|
|
188
|
+
quality = manifest.get("quality", {})
|
|
189
|
+
sla = manifest.get("sla", {})
|
|
190
|
+
|
|
191
|
+
return ServiceVector(
|
|
192
|
+
service_id=manifest["service_id"],
|
|
193
|
+
display_name=manifest.get("display_name", manifest["service_id"]),
|
|
194
|
+
taxonomy=manifest["taxonomy"],
|
|
195
|
+
cost_per_unit=_extract_primary_cost(pricing, io_ratio=io_ratio),
|
|
196
|
+
quality_score=_extract_primary_quality(quality),
|
|
197
|
+
latency_seconds=_parse_latency(sla.get("latency_p50")),
|
|
198
|
+
uptime=sla.get("uptime", 0.5),
|
|
199
|
+
raw_manifest=manifest,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ──────────────────────────────────────────────
|
|
204
|
+
# Stage 1: Filter (hard constraints)
|
|
205
|
+
# ──────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def filter_services(
|
|
208
|
+
services: list[ServiceVector],
|
|
209
|
+
constraints: Constraints,
|
|
210
|
+
) -> list[ServiceVector]:
|
|
211
|
+
"""Filter out services that violate hard constraints."""
|
|
212
|
+
result = []
|
|
213
|
+
for s in services:
|
|
214
|
+
if constraints.required_taxonomy:
|
|
215
|
+
if not s.taxonomy.startswith(constraints.required_taxonomy):
|
|
216
|
+
continue
|
|
217
|
+
if constraints.min_quality is not None and s.quality_score < constraints.min_quality:
|
|
218
|
+
continue
|
|
219
|
+
if constraints.max_cost is not None and s.cost_per_unit > constraints.max_cost:
|
|
220
|
+
continue
|
|
221
|
+
if constraints.max_latency_s is not None and s.latency_seconds > constraints.max_latency_s:
|
|
222
|
+
continue
|
|
223
|
+
if constraints.min_uptime is not None and s.uptime < constraints.min_uptime:
|
|
224
|
+
continue
|
|
225
|
+
result.append(s)
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ──────────────────────────────────────────────
|
|
230
|
+
# Stage 2a: Weighted Average Scorer (v0.2)
|
|
231
|
+
# ──────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
def _min_max_normalize(values: list[float], invert: bool = False) -> list[float]:
|
|
234
|
+
"""Min-max normalize to [0, 1]. If invert, lower raw = higher normalized."""
|
|
235
|
+
if not values:
|
|
236
|
+
return []
|
|
237
|
+
finite_values = [v for v in values if math.isfinite(v)]
|
|
238
|
+
if not finite_values:
|
|
239
|
+
return [1.0] * len(values)
|
|
240
|
+
vmin, vmax = min(finite_values), max(finite_values)
|
|
241
|
+
if vmin == vmax:
|
|
242
|
+
return [1.0 if math.isfinite(v) else 0.0 for v in values]
|
|
243
|
+
if invert:
|
|
244
|
+
return [0.0 if not math.isfinite(v) else (vmax - v) / (vmax - vmin) for v in values]
|
|
245
|
+
return [0.0 if not math.isfinite(v) else (v - vmin) / (vmax - vmin) for v in values]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def score_weighted_average(
|
|
249
|
+
services: list[ServiceVector],
|
|
250
|
+
preferences: Preferences,
|
|
251
|
+
) -> list[ScoredService]:
|
|
252
|
+
"""Score services using weighted average (ASM v0.2 method)."""
|
|
253
|
+
if not services:
|
|
254
|
+
return []
|
|
255
|
+
|
|
256
|
+
costs = [s.cost_per_unit for s in services]
|
|
257
|
+
qualities = [s.quality_score for s in services]
|
|
258
|
+
latencies = [s.latency_seconds for s in services]
|
|
259
|
+
uptimes = [s.uptime for s in services]
|
|
260
|
+
|
|
261
|
+
norm_cost = _min_max_normalize(costs, invert=True) # lower cost → higher score
|
|
262
|
+
norm_quality = _min_max_normalize(qualities, invert=False) # higher quality → higher score
|
|
263
|
+
norm_speed = _min_max_normalize(latencies, invert=True) # lower latency → higher score
|
|
264
|
+
norm_uptime = _min_max_normalize(uptimes, invert=False) # higher uptime → higher score
|
|
265
|
+
|
|
266
|
+
results = []
|
|
267
|
+
for i, s in enumerate(services):
|
|
268
|
+
breakdown = {
|
|
269
|
+
"cost": norm_cost[i],
|
|
270
|
+
"quality": norm_quality[i],
|
|
271
|
+
"speed": norm_speed[i],
|
|
272
|
+
"reliability": norm_uptime[i],
|
|
273
|
+
}
|
|
274
|
+
total = (
|
|
275
|
+
preferences.cost * breakdown["cost"]
|
|
276
|
+
+ preferences.quality * breakdown["quality"]
|
|
277
|
+
+ preferences.speed * breakdown["speed"]
|
|
278
|
+
+ preferences.reliability * breakdown["reliability"]
|
|
279
|
+
)
|
|
280
|
+
results.append(ScoredService(
|
|
281
|
+
service=s,
|
|
282
|
+
total_score=round(total, 4),
|
|
283
|
+
breakdown={k: round(v, 4) for k, v in breakdown.items()},
|
|
284
|
+
))
|
|
285
|
+
|
|
286
|
+
results.sort(key=lambda x: x.total_score, reverse=True)
|
|
287
|
+
for i, r in enumerate(results):
|
|
288
|
+
r.rank = i + 1
|
|
289
|
+
r.reasoning = _generate_reasoning(r, preferences)
|
|
290
|
+
return results
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _generate_reasoning(scored: ScoredService, prefs: Preferences) -> str:
|
|
294
|
+
"""Generate a human-readable reasoning for the score."""
|
|
295
|
+
s = scored.service
|
|
296
|
+
top_dim = max(scored.breakdown, key=lambda k: getattr(prefs, k) * scored.breakdown[k])
|
|
297
|
+
return (
|
|
298
|
+
f"{s.display_name} scored {scored.total_score:.3f} "
|
|
299
|
+
f"(cost={scored.breakdown['cost']:.2f}, "
|
|
300
|
+
f"quality={scored.breakdown['quality']:.2f}, "
|
|
301
|
+
f"speed={scored.breakdown['speed']:.2f}, "
|
|
302
|
+
f"reliability={scored.breakdown['reliability']:.2f}). "
|
|
303
|
+
f"Strongest weighted dimension: {top_dim}."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# ──────────────────────────────────────────────
|
|
308
|
+
# Stage 2b: TOPSIS Scorer (v1.0)
|
|
309
|
+
# ──────────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def score_topsis(
|
|
312
|
+
services: list[ServiceVector],
|
|
313
|
+
preferences: Preferences,
|
|
314
|
+
) -> list[ScoredService]:
|
|
315
|
+
"""Score services using TOPSIS (Technique for Order Preference by
|
|
316
|
+
Similarity to Ideal Solution).
|
|
317
|
+
|
|
318
|
+
Steps:
|
|
319
|
+
1. Build decision matrix (m services × 4 criteria)
|
|
320
|
+
2. Normalize using vector normalization
|
|
321
|
+
3. Apply weights
|
|
322
|
+
4. Find positive ideal (A+) and negative ideal (A-) solutions
|
|
323
|
+
5. Calculate distance to A+ and A- for each service
|
|
324
|
+
6. Compute closeness coefficient: C = d- / (d+ + d-)
|
|
325
|
+
7. Rank by C (higher = better)
|
|
326
|
+
"""
|
|
327
|
+
if not services:
|
|
328
|
+
return []
|
|
329
|
+
|
|
330
|
+
n = len(services)
|
|
331
|
+
|
|
332
|
+
# Step 1: Decision matrix — columns: cost(minimize), quality(maximize), speed(minimize latency), reliability(maximize)
|
|
333
|
+
# For TOPSIS, we need to know which criteria are benefit (higher=better) vs cost (lower=better)
|
|
334
|
+
raw = []
|
|
335
|
+
for s in services:
|
|
336
|
+
raw.append([
|
|
337
|
+
s.cost_per_unit, # cost → minimize
|
|
338
|
+
s.quality_score, # quality → maximize
|
|
339
|
+
s.latency_seconds, # latency → minimize
|
|
340
|
+
s.uptime, # uptime → maximize
|
|
341
|
+
])
|
|
342
|
+
|
|
343
|
+
# Benefit criteria (True = higher is better, False = lower is better)
|
|
344
|
+
is_benefit = [False, True, False, True]
|
|
345
|
+
weights = [preferences.cost, preferences.quality, preferences.speed, preferences.reliability]
|
|
346
|
+
raw = _sanitize_decision_matrix(raw, is_benefit)
|
|
347
|
+
|
|
348
|
+
# Step 2: Vector normalization
|
|
349
|
+
num_criteria = 4
|
|
350
|
+
norm = [[0.0] * num_criteria for _ in range(n)]
|
|
351
|
+
for j in range(num_criteria):
|
|
352
|
+
col_sum_sq = math.sqrt(sum(raw[i][j] ** 2 for i in range(n)))
|
|
353
|
+
if col_sum_sq == 0:
|
|
354
|
+
for i in range(n):
|
|
355
|
+
norm[i][j] = 0
|
|
356
|
+
else:
|
|
357
|
+
for i in range(n):
|
|
358
|
+
norm[i][j] = raw[i][j] / col_sum_sq
|
|
359
|
+
|
|
360
|
+
# Step 3: Weighted normalized matrix
|
|
361
|
+
weighted = [[norm[i][j] * weights[j] for j in range(num_criteria)] for i in range(n)]
|
|
362
|
+
|
|
363
|
+
# Step 4: Ideal solutions
|
|
364
|
+
a_pos = []
|
|
365
|
+
a_neg = []
|
|
366
|
+
for j in range(num_criteria):
|
|
367
|
+
col = [weighted[i][j] for i in range(n)]
|
|
368
|
+
if is_benefit[j]:
|
|
369
|
+
a_pos.append(max(col))
|
|
370
|
+
a_neg.append(min(col))
|
|
371
|
+
else:
|
|
372
|
+
a_pos.append(min(col))
|
|
373
|
+
a_neg.append(max(col))
|
|
374
|
+
|
|
375
|
+
# Step 5: Distances
|
|
376
|
+
d_pos = []
|
|
377
|
+
d_neg = []
|
|
378
|
+
for i in range(n):
|
|
379
|
+
dp = math.sqrt(sum((weighted[i][j] - a_pos[j]) ** 2 for j in range(num_criteria)))
|
|
380
|
+
dn = math.sqrt(sum((weighted[i][j] - a_neg[j]) ** 2 for j in range(num_criteria)))
|
|
381
|
+
d_pos.append(dp)
|
|
382
|
+
d_neg.append(dn)
|
|
383
|
+
|
|
384
|
+
# Step 6: Closeness coefficient
|
|
385
|
+
results = []
|
|
386
|
+
for i, s in enumerate(services):
|
|
387
|
+
denom = d_pos[i] + d_neg[i]
|
|
388
|
+
c = d_neg[i] / denom if denom > 0 else 0.5
|
|
389
|
+
|
|
390
|
+
# Build breakdown from weighted normalized values
|
|
391
|
+
labels = ["cost", "quality", "speed", "reliability"]
|
|
392
|
+
breakdown = {}
|
|
393
|
+
for j, label in enumerate(labels):
|
|
394
|
+
# Convert to 0-1 benefit score for display
|
|
395
|
+
col = [weighted[k][j] for k in range(n)]
|
|
396
|
+
finite_col = [v for v in col if math.isfinite(v)]
|
|
397
|
+
if not finite_col:
|
|
398
|
+
breakdown[label] = 1.0
|
|
399
|
+
continue
|
|
400
|
+
vmin, vmax = min(finite_col), max(finite_col)
|
|
401
|
+
if not math.isfinite(weighted[i][j]):
|
|
402
|
+
breakdown[label] = 0.0
|
|
403
|
+
continue
|
|
404
|
+
if vmax == vmin:
|
|
405
|
+
breakdown[label] = 1.0
|
|
406
|
+
elif is_benefit[j]:
|
|
407
|
+
breakdown[label] = round((weighted[i][j] - vmin) / (vmax - vmin), 4)
|
|
408
|
+
else:
|
|
409
|
+
breakdown[label] = round((vmax - weighted[i][j]) / (vmax - vmin), 4)
|
|
410
|
+
|
|
411
|
+
results.append(ScoredService(
|
|
412
|
+
service=s,
|
|
413
|
+
total_score=round(c, 4),
|
|
414
|
+
breakdown=breakdown,
|
|
415
|
+
))
|
|
416
|
+
|
|
417
|
+
results.sort(key=lambda x: x.total_score, reverse=True)
|
|
418
|
+
for i, r in enumerate(results):
|
|
419
|
+
r.rank = i + 1
|
|
420
|
+
r.reasoning = _generate_reasoning(r, preferences)
|
|
421
|
+
return results
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _sanitize_decision_matrix(raw: list[list[float]], is_benefit: list[bool]) -> list[list[float]]:
|
|
425
|
+
"""Replace non-finite criteria before TOPSIS vector normalization.
|
|
426
|
+
|
|
427
|
+
Unknown values should not produce NaN scores. If an entire criterion is
|
|
428
|
+
unknown, it becomes neutral (all zeros). If only some services are unknown,
|
|
429
|
+
unknown cost criteria are treated as worst finite values and unknown benefit
|
|
430
|
+
criteria as worst finite values.
|
|
431
|
+
"""
|
|
432
|
+
if not raw:
|
|
433
|
+
return raw
|
|
434
|
+
cleaned = [row[:] for row in raw]
|
|
435
|
+
num_criteria = len(raw[0])
|
|
436
|
+
for j in range(num_criteria):
|
|
437
|
+
finite = [row[j] for row in raw if math.isfinite(row[j])]
|
|
438
|
+
if not finite:
|
|
439
|
+
replacement = 0.0
|
|
440
|
+
elif is_benefit[j]:
|
|
441
|
+
replacement = min(finite)
|
|
442
|
+
else:
|
|
443
|
+
replacement = max(finite)
|
|
444
|
+
for row in cleaned:
|
|
445
|
+
if not math.isfinite(row[j]):
|
|
446
|
+
row[j] = replacement
|
|
447
|
+
return cleaned
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ──────────────────────────────────────────────
|
|
451
|
+
# Stage 3: Trust Delta Scoring (v1.1)
|
|
452
|
+
# ──────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
@dataclass
|
|
455
|
+
class ReceiptRecord:
|
|
456
|
+
"""A single execution receipt recording actual service delivery metrics."""
|
|
457
|
+
service_id: str
|
|
458
|
+
timestamp: float # Unix timestamp of execution
|
|
459
|
+
actual_latency_seconds: float # Measured p50 latency
|
|
460
|
+
actual_quality_score: float # Measured quality (normalized 0-1)
|
|
461
|
+
actual_uptime: float # Observed availability (0-1)
|
|
462
|
+
actual_cost_per_unit: float # Actual billed cost per unit
|
|
463
|
+
|
|
464
|
+
@dataclass
|
|
465
|
+
class TrustScore:
|
|
466
|
+
"""Computed trust score for a service based on receipt history."""
|
|
467
|
+
service_id: str
|
|
468
|
+
trust_score: float # Overall trust score (0-1, higher = more trustworthy)
|
|
469
|
+
delta_breakdown: dict[str, float] # Per-dimension trust deltas
|
|
470
|
+
num_receipts: int # Number of receipts used
|
|
471
|
+
confidence: float # Confidence level (0-1, based on sample size)
|
|
472
|
+
reasoning: str
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def compute_trust_delta(
|
|
476
|
+
declared: float,
|
|
477
|
+
actual: float,
|
|
478
|
+
) -> float:
|
|
479
|
+
"""Compute trust delta between declared and actual values.
|
|
480
|
+
|
|
481
|
+
trust_delta = |declared - actual| / declared
|
|
482
|
+
|
|
483
|
+
Returns 0.0 for perfect match, >1.0 for severe deviation.
|
|
484
|
+
A delta of 0.5 means 50% deviation from declared value.
|
|
485
|
+
"""
|
|
486
|
+
if declared == 0:
|
|
487
|
+
return 0.0 if actual == 0 else 1.0
|
|
488
|
+
return abs(declared - actual) / abs(declared)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def cost_delta_from_receipt(
|
|
492
|
+
manifest: dict,
|
|
493
|
+
receipt: dict,
|
|
494
|
+
) -> dict:
|
|
495
|
+
"""Recompute the receipt's cost from the manifest's published pricing,
|
|
496
|
+
and surface the aggregate delta as a first-class Trust Delta signal.
|
|
497
|
+
|
|
498
|
+
Per RFC v0.1 (docs/rfcs/trust-delta-receipt-extension-v0.1.md) §2.4:
|
|
499
|
+
the trust signal is the AGGREGATE delta across all billing dimensions,
|
|
500
|
+
not per-dimension. Per-dimension breakdown is preserved as a diagnostic.
|
|
501
|
+
|
|
502
|
+
Args:
|
|
503
|
+
manifest: ASM manifest dict (must contain `pricing.billing_dimensions`).
|
|
504
|
+
receipt: Receipt body matching the envelope schema. Reads
|
|
505
|
+
`billing.cost`, `billing.observed_input_tokens`,
|
|
506
|
+
`billing.observed_output_tokens`, `billing.dimension`,
|
|
507
|
+
`billing.quantity` as available.
|
|
508
|
+
|
|
509
|
+
Returns:
|
|
510
|
+
dict with:
|
|
511
|
+
- `claimed_cost`: receipt.billing.cost
|
|
512
|
+
- `recomputed_cost`: sum across billing_dimensions of
|
|
513
|
+
(observed_quantity * manifest_unit_cost)
|
|
514
|
+
- `cost_delta`: claimed_cost - recomputed_cost (signed)
|
|
515
|
+
- `per_dimension`: diagnostic dict mapping dimension -> recomputed cost
|
|
516
|
+
- `note`: free-form string explaining any missing fields / fallbacks
|
|
517
|
+
|
|
518
|
+
The delta is signed (not absolute): a positive delta means the publisher
|
|
519
|
+
over-claims relative to the manifest rate; negative means under-claims.
|
|
520
|
+
Trust Delta's downstream scoring decides what to do with the sign.
|
|
521
|
+
"""
|
|
522
|
+
billing = (receipt or {}).get("billing") or {}
|
|
523
|
+
claimed_cost = float(billing.get("cost", 0) or 0)
|
|
524
|
+
|
|
525
|
+
pricing = (manifest or {}).get("pricing") or {}
|
|
526
|
+
dimensions = pricing.get("billing_dimensions") or []
|
|
527
|
+
if not dimensions:
|
|
528
|
+
return {
|
|
529
|
+
"claimed_cost": claimed_cost,
|
|
530
|
+
"recomputed_cost": 0.0,
|
|
531
|
+
"cost_delta": claimed_cost,
|
|
532
|
+
"per_dimension": {},
|
|
533
|
+
"note": "manifest pricing.billing_dimensions missing or empty",
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
# Map observed quantity for each manifest dimension. Receipt fields are:
|
|
537
|
+
# billing.observed_input_tokens / observed_output_tokens (LLM-shaped)
|
|
538
|
+
# billing.quantity (single-dimension shaped, e.g. pipeline_run)
|
|
539
|
+
obs_input = float(billing.get("observed_input_tokens", 0) or 0)
|
|
540
|
+
obs_output = float(billing.get("observed_output_tokens", 0) or 0)
|
|
541
|
+
obs_quantity = float(billing.get("quantity", 0) or 0)
|
|
542
|
+
primary_dim = billing.get("dimension")
|
|
543
|
+
|
|
544
|
+
per_dimension: dict[str, float] = {}
|
|
545
|
+
total = 0.0
|
|
546
|
+
note_parts: list[str] = []
|
|
547
|
+
|
|
548
|
+
for entry in dimensions:
|
|
549
|
+
dim_name = entry.get("dimension")
|
|
550
|
+
unit = (entry.get("unit") or "").lower()
|
|
551
|
+
cost_per_unit = float(entry.get("cost_per_unit", 0) or 0)
|
|
552
|
+
|
|
553
|
+
# Resolve observed quantity for this dimension.
|
|
554
|
+
if dim_name == "input_token":
|
|
555
|
+
qty = obs_input
|
|
556
|
+
multiplier = 1.0 / 1_000_000 if unit in ("per_1m", "per_1m_tokens") else 1.0
|
|
557
|
+
elif dim_name == "output_token":
|
|
558
|
+
qty = obs_output
|
|
559
|
+
multiplier = 1.0 / 1_000_000 if unit in ("per_1m", "per_1m_tokens") else 1.0
|
|
560
|
+
elif dim_name == primary_dim:
|
|
561
|
+
qty = obs_quantity
|
|
562
|
+
multiplier = 1.0
|
|
563
|
+
else:
|
|
564
|
+
note_parts.append(f"no observed quantity for dimension '{dim_name}'")
|
|
565
|
+
continue
|
|
566
|
+
|
|
567
|
+
contribution = qty * cost_per_unit * multiplier
|
|
568
|
+
per_dimension[dim_name] = contribution
|
|
569
|
+
total += contribution
|
|
570
|
+
|
|
571
|
+
cost_delta = claimed_cost - total
|
|
572
|
+
return {
|
|
573
|
+
"claimed_cost": claimed_cost,
|
|
574
|
+
"recomputed_cost": total,
|
|
575
|
+
"cost_delta": cost_delta,
|
|
576
|
+
"per_dimension": per_dimension,
|
|
577
|
+
"note": "; ".join(note_parts) if note_parts else "ok",
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def exponential_decay_weight(
|
|
582
|
+
timestamp: float,
|
|
583
|
+
now: float | None = None,
|
|
584
|
+
half_life_seconds: float = 7 * 24 * 3600, # 1 week default
|
|
585
|
+
) -> float:
|
|
586
|
+
"""Compute exponential decay weight for a receipt based on age.
|
|
587
|
+
|
|
588
|
+
More recent receipts have higher weight. The weight halves every
|
|
589
|
+
`half_life_seconds`.
|
|
590
|
+
|
|
591
|
+
w(t) = exp(-ln(2) * age / half_life)
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
timestamp: Unix timestamp of the receipt
|
|
595
|
+
now: Current time (defaults to time.time())
|
|
596
|
+
half_life_seconds: Time for weight to halve (default: 1 week)
|
|
597
|
+
|
|
598
|
+
Returns:
|
|
599
|
+
Weight in (0, 1], where 1.0 = just now, 0.5 = one half-life ago
|
|
600
|
+
"""
|
|
601
|
+
if now is None:
|
|
602
|
+
now = time.time()
|
|
603
|
+
age = max(now - timestamp, 0)
|
|
604
|
+
decay_constant = math.log(2) / half_life_seconds
|
|
605
|
+
return math.exp(-decay_constant * age)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def compute_trust_score(
|
|
609
|
+
service: ServiceVector,
|
|
610
|
+
receipts: list[ReceiptRecord],
|
|
611
|
+
half_life_seconds: float = 7 * 24 * 3600,
|
|
612
|
+
now: float | None = None,
|
|
613
|
+
) -> TrustScore:
|
|
614
|
+
"""Compute trust score for a service using receipt history with
|
|
615
|
+
exponential decay weighting.
|
|
616
|
+
|
|
617
|
+
For each dimension (cost, quality, latency, uptime), computes:
|
|
618
|
+
1. Trust delta per receipt: |declared - actual| / declared
|
|
619
|
+
2. Weighted average delta using exponential decay (recent receipts matter more)
|
|
620
|
+
3. Overall trust score: 1 - mean(dimension_deltas), clamped to [0, 1]
|
|
621
|
+
|
|
622
|
+
Confidence increases with more receipts (asymptotic to 1.0).
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
service: The service's declared values (from manifest)
|
|
626
|
+
receipts: Historical execution receipts for this service
|
|
627
|
+
half_life_seconds: Decay half-life (default: 1 week)
|
|
628
|
+
now: Current time for decay calculation
|
|
629
|
+
|
|
630
|
+
Returns:
|
|
631
|
+
TrustScore with overall score, per-dimension breakdown, and reasoning
|
|
632
|
+
"""
|
|
633
|
+
if not receipts:
|
|
634
|
+
return TrustScore(
|
|
635
|
+
service_id=service.service_id,
|
|
636
|
+
trust_score=0.5, # Neutral — no data
|
|
637
|
+
delta_breakdown={},
|
|
638
|
+
num_receipts=0,
|
|
639
|
+
confidence=0.0,
|
|
640
|
+
reasoning=f"{service.display_name} has no receipt history. Trust score is neutral (0.5).",
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
if now is None:
|
|
644
|
+
now = time.time()
|
|
645
|
+
|
|
646
|
+
# Compute per-dimension weighted deltas
|
|
647
|
+
dimensions = {
|
|
648
|
+
"cost": ("cost_per_unit", "actual_cost_per_unit"),
|
|
649
|
+
"quality": ("quality_score", "actual_quality_score"),
|
|
650
|
+
"latency": ("latency_seconds", "actual_latency_seconds"),
|
|
651
|
+
"uptime": ("uptime", "actual_uptime"),
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
delta_breakdown: dict[str, float] = {}
|
|
655
|
+
|
|
656
|
+
for dim_name, (declared_attr, actual_attr) in dimensions.items():
|
|
657
|
+
declared_val = getattr(service, declared_attr)
|
|
658
|
+
weighted_delta_sum = 0.0
|
|
659
|
+
weight_sum = 0.0
|
|
660
|
+
|
|
661
|
+
for receipt in receipts:
|
|
662
|
+
actual_val = getattr(receipt, actual_attr)
|
|
663
|
+
delta = compute_trust_delta(declared_val, actual_val)
|
|
664
|
+
weight = exponential_decay_weight(receipt.timestamp, now, half_life_seconds)
|
|
665
|
+
weighted_delta_sum += delta * weight
|
|
666
|
+
weight_sum += weight
|
|
667
|
+
|
|
668
|
+
avg_delta = weighted_delta_sum / weight_sum if weight_sum > 0 else 0.0
|
|
669
|
+
delta_breakdown[dim_name] = round(avg_delta, 4)
|
|
670
|
+
|
|
671
|
+
# Overall trust score: 1 - mean(deltas), clamped to [0, 1]
|
|
672
|
+
mean_delta = sum(delta_breakdown.values()) / len(delta_breakdown) if delta_breakdown else 0.0
|
|
673
|
+
trust_score = max(0.0, min(1.0, 1.0 - mean_delta))
|
|
674
|
+
|
|
675
|
+
# Confidence: asymptotic based on receipt count
|
|
676
|
+
# confidence = 1 - exp(-n / 5), reaches ~0.86 at 10 receipts, ~0.98 at 20
|
|
677
|
+
confidence = 1.0 - math.exp(-len(receipts) / 5.0)
|
|
678
|
+
|
|
679
|
+
# Generate reasoning
|
|
680
|
+
worst_dim = max(delta_breakdown, key=delta_breakdown.get) if delta_breakdown else "N/A"
|
|
681
|
+
best_dim = min(delta_breakdown, key=delta_breakdown.get) if delta_breakdown else "N/A"
|
|
682
|
+
reasoning = (
|
|
683
|
+
f"{service.display_name} trust={trust_score:.3f} "
|
|
684
|
+
f"(confidence={confidence:.2f}, {len(receipts)} receipts). "
|
|
685
|
+
f"Most accurate: {best_dim} (delta={delta_breakdown.get(best_dim, 0):.3f}). "
|
|
686
|
+
f"Least accurate: {worst_dim} (delta={delta_breakdown.get(worst_dim, 0):.3f})."
|
|
687
|
+
)
|
|
688
|
+
|
|
689
|
+
return TrustScore(
|
|
690
|
+
service_id=service.service_id,
|
|
691
|
+
trust_score=round(trust_score, 4),
|
|
692
|
+
delta_breakdown=delta_breakdown,
|
|
693
|
+
num_receipts=len(receipts),
|
|
694
|
+
confidence=round(confidence, 4),
|
|
695
|
+
reasoning=reasoning,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def adjust_scores_with_trust(
|
|
700
|
+
scored_services: list[ScoredService],
|
|
701
|
+
trust_scores: dict[str, TrustScore],
|
|
702
|
+
trust_weight: float = 0.2,
|
|
703
|
+
) -> list[ScoredService]:
|
|
704
|
+
"""Adjust service rankings by incorporating trust scores.
|
|
705
|
+
|
|
706
|
+
Final score = (1 - trust_weight) * original_score + trust_weight * trust_score * confidence
|
|
707
|
+
|
|
708
|
+
Services with high trust (accurate declarations) get a boost.
|
|
709
|
+
Services with low trust (inflated claims) get penalized.
|
|
710
|
+
Services with no receipt data are unaffected (trust=0.5, confidence=0).
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
scored_services: Original scored services from TOPSIS or weighted average
|
|
714
|
+
trust_scores: Map of service_id → TrustScore
|
|
715
|
+
trust_weight: How much trust affects the final score (0-1, default 0.2)
|
|
716
|
+
|
|
717
|
+
Returns:
|
|
718
|
+
Re-ranked list of ScoredService with trust-adjusted scores
|
|
719
|
+
"""
|
|
720
|
+
adjusted = []
|
|
721
|
+
for scored in scored_services:
|
|
722
|
+
sid = scored.service.service_id
|
|
723
|
+
ts = trust_scores.get(sid)
|
|
724
|
+
|
|
725
|
+
if ts and ts.confidence > 0:
|
|
726
|
+
trust_adjustment = ts.trust_score * ts.confidence
|
|
727
|
+
new_score = (1 - trust_weight) * scored.total_score + trust_weight * trust_adjustment
|
|
728
|
+
new_breakdown = dict(scored.breakdown)
|
|
729
|
+
new_breakdown["trust"] = round(trust_adjustment, 4)
|
|
730
|
+
adjusted.append(ScoredService(
|
|
731
|
+
service=scored.service,
|
|
732
|
+
total_score=round(new_score, 4),
|
|
733
|
+
breakdown=new_breakdown,
|
|
734
|
+
reasoning=f"{scored.reasoning} Trust: {ts.reasoning}",
|
|
735
|
+
))
|
|
736
|
+
else:
|
|
737
|
+
adjusted.append(scored)
|
|
738
|
+
|
|
739
|
+
adjusted.sort(key=lambda x: x.total_score, reverse=True)
|
|
740
|
+
for i, r in enumerate(adjusted):
|
|
741
|
+
r.rank = i + 1
|
|
742
|
+
return adjusted
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
# ──────────────────────────────────────────────
|
|
746
|
+
# High-level API
|
|
747
|
+
# ──────────────────────────────────────────────
|
|
748
|
+
|
|
749
|
+
def select_service(
|
|
750
|
+
manifests: list[dict],
|
|
751
|
+
constraints: Constraints | None = None,
|
|
752
|
+
preferences: Preferences | None = None,
|
|
753
|
+
method: str = "topsis",
|
|
754
|
+
) -> list[ScoredService]:
|
|
755
|
+
"""End-to-end service selection pipeline.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
manifests: List of ASM manifest dicts
|
|
759
|
+
constraints: Hard constraints for filtering (optional)
|
|
760
|
+
preferences: Soft preference weights (optional, defaults to balanced)
|
|
761
|
+
method: Scoring method — 'weighted_average' or 'topsis'
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
Ranked list of ScoredService objects
|
|
765
|
+
"""
|
|
766
|
+
if preferences is None:
|
|
767
|
+
preferences = Preferences()
|
|
768
|
+
if constraints is None:
|
|
769
|
+
constraints = Constraints()
|
|
770
|
+
|
|
771
|
+
# Parse manifests(pass io_ratio to support configurable cost normalization)
|
|
772
|
+
services = [parse_manifest(m, io_ratio=preferences.io_ratio) for m in manifests]
|
|
773
|
+
|
|
774
|
+
# Stage 1: Filter
|
|
775
|
+
filtered = filter_services(services, constraints)
|
|
776
|
+
|
|
777
|
+
if not filtered:
|
|
778
|
+
return []
|
|
779
|
+
|
|
780
|
+
# Stage 2: Score
|
|
781
|
+
if method == "topsis":
|
|
782
|
+
return score_topsis(filtered, preferences)
|
|
783
|
+
else:
|
|
784
|
+
return score_weighted_average(filtered, preferences)
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def load_manifests(directory: str | Path) -> list[dict]:
|
|
788
|
+
"""Load all .asm.json files from a directory."""
|
|
789
|
+
path = Path(directory)
|
|
790
|
+
manifests = []
|
|
791
|
+
for f in sorted(path.glob("*.asm.json")):
|
|
792
|
+
with open(f, encoding="utf-8") as fp:
|
|
793
|
+
manifests.append(json.load(fp))
|
|
794
|
+
return manifests
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
# ──────────────────────────────────────────────
|
|
798
|
+
# CLI Demo
|
|
799
|
+
# ──────────────────────────────────────────────
|
|
800
|
+
|
|
801
|
+
def _print_results(results: list[ScoredService], title: str):
|
|
802
|
+
"""Pretty-print scoring results."""
|
|
803
|
+
print(f"\n{'='*70}")
|
|
804
|
+
print(f" {title}")
|
|
805
|
+
print(f"{'='*70}")
|
|
806
|
+
for r in results:
|
|
807
|
+
marker = " ← BEST" if r.rank == 1 else ""
|
|
808
|
+
print(f"\n #{r.rank} {r.service.display_name} — score: {r.total_score:.4f}{marker}")
|
|
809
|
+
print(f" {r.reasoning}")
|
|
810
|
+
print(f" cost=${r.service.cost_per_unit:.6f}/unit | "
|
|
811
|
+
f"quality={r.service.quality_score:.3f} | "
|
|
812
|
+
f"latency={r.service.latency_seconds:.2f}s | "
|
|
813
|
+
f"uptime={r.service.uptime:.3f}")
|
|
814
|
+
print()
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def main():
|
|
818
|
+
"""Demo: load manifests and run through multiple preference scenarios."""
|
|
819
|
+
import sys
|
|
820
|
+
|
|
821
|
+
# Determine manifest directory
|
|
822
|
+
script_dir = Path(__file__).parent
|
|
823
|
+
manifest_dir = script_dir.parent / "manifests"
|
|
824
|
+
|
|
825
|
+
if not manifest_dir.exists():
|
|
826
|
+
print(f"Error: manifest directory not found at {manifest_dir}")
|
|
827
|
+
sys.exit(1)
|
|
828
|
+
|
|
829
|
+
manifests = load_manifests(manifest_dir)
|
|
830
|
+
if not manifests:
|
|
831
|
+
print(f"No .asm.json files found in {manifest_dir}")
|
|
832
|
+
sys.exit(1)
|
|
833
|
+
|
|
834
|
+
print(f"Loaded {len(manifests)} ASM manifests from {manifest_dir}")
|
|
835
|
+
for m in manifests:
|
|
836
|
+
print(f" • {m.get('display_name', m['service_id'])} ({m['taxonomy']})")
|
|
837
|
+
|
|
838
|
+
# ── Scenario 1: Cost-first (same taxonomy) ──
|
|
839
|
+
llm_manifests = [m for m in manifests if m["taxonomy"].startswith("ai.llm")]
|
|
840
|
+
if len(llm_manifests) >= 2:
|
|
841
|
+
results = select_service(
|
|
842
|
+
llm_manifests,
|
|
843
|
+
preferences=Preferences(cost=0.5, quality=0.3, speed=0.15, reliability=0.05),
|
|
844
|
+
method="topsis",
|
|
845
|
+
)
|
|
846
|
+
_print_results(results, "Scenario: LLM — Cost Priority (TOPSIS)")
|
|
847
|
+
|
|
848
|
+
# ── Scenario 2: Quality-first (same taxonomy) ──
|
|
849
|
+
if len(llm_manifests) >= 2:
|
|
850
|
+
results = select_service(
|
|
851
|
+
llm_manifests,
|
|
852
|
+
preferences=Preferences(cost=0.1, quality=0.7, speed=0.15, reliability=0.05),
|
|
853
|
+
method="topsis",
|
|
854
|
+
)
|
|
855
|
+
_print_results(results, "Scenario: LLM — Quality Priority (TOPSIS)")
|
|
856
|
+
|
|
857
|
+
# ── Scenario 3: Cross-category (all services, balanced) ──
|
|
858
|
+
results_wa = select_service(
|
|
859
|
+
manifests,
|
|
860
|
+
preferences=Preferences(cost=0.3, quality=0.3, speed=0.2, reliability=0.2),
|
|
861
|
+
method="weighted_average",
|
|
862
|
+
)
|
|
863
|
+
_print_results(results_wa, "Scenario: Cross-Category — Balanced (Weighted Average)")
|
|
864
|
+
|
|
865
|
+
results_topsis = select_service(
|
|
866
|
+
manifests,
|
|
867
|
+
preferences=Preferences(cost=0.3, quality=0.3, speed=0.2, reliability=0.2),
|
|
868
|
+
method="topsis",
|
|
869
|
+
)
|
|
870
|
+
_print_results(results_topsis, "Scenario: Cross-Category — Balanced (TOPSIS)")
|
|
871
|
+
|
|
872
|
+
# ── Scenario 4: With constraints ──
|
|
873
|
+
results_filtered = select_service(
|
|
874
|
+
manifests,
|
|
875
|
+
constraints=Constraints(max_latency_s=10),
|
|
876
|
+
preferences=Preferences(cost=0.4, quality=0.4, speed=0.1, reliability=0.1),
|
|
877
|
+
method="topsis",
|
|
878
|
+
)
|
|
879
|
+
_print_results(results_filtered, "Scenario: Filtered (latency ≤ 10s) — Cost+Quality (TOPSIS)")
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
if __name__ == "__main__":
|
|
883
|
+
main()
|