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.
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()