cometric 0.1.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.
Files changed (83) hide show
  1. cometric/__init__.py +50 -0
  2. cometric/cli.py +210 -0
  3. cometric/core/__init__.py +0 -0
  4. cometric/core/api.py +835 -0
  5. cometric/core/engine.py +1386 -0
  6. cometric/core/enums.py +273 -0
  7. cometric/core/fingerprint.py +91 -0
  8. cometric/core/issues.py +68 -0
  9. cometric/core/ledger.py +165 -0
  10. cometric/core/models.py +450 -0
  11. cometric/core/money.py +110 -0
  12. cometric/core/packs.py +446 -0
  13. cometric/core/periods.py +102 -0
  14. cometric/core/predicates.py +139 -0
  15. cometric/core/quantities.py +138 -0
  16. cometric/core/schema.py +97 -0
  17. cometric/core/templates.py +338 -0
  18. cometric/core/tiers.py +78 -0
  19. cometric/core/validation.py +738 -0
  20. cometric/examples/case_a.json +293 -0
  21. cometric/examples/case_b.json +212 -0
  22. cometric/examples/case_c.json +233 -0
  23. cometric/examples/case_d.json +253 -0
  24. cometric/examples/case_e.json +258 -0
  25. cometric/examples/case_f.json +298 -0
  26. cometric/examples/case_g.json +243 -0
  27. cometric/examples/case_h.json +271 -0
  28. cometric/examples/spec_example.json +134 -0
  29. cometric/mcp/__init__.py +0 -0
  30. cometric/mcp/__main__.py +3 -0
  31. cometric/mcp/server.py +470 -0
  32. cometric/packs/alsp/concepts/provider_types.yaml +154 -0
  33. cometric/packs/alsp/concepts/service_categories.yaml +132 -0
  34. cometric/packs/alsp/concepts/unit_templates.yaml +132 -0
  35. cometric/packs/alsp/coverage.yaml +17 -0
  36. cometric/packs/alsp/evidence/claims.yaml +446 -0
  37. cometric/packs/alsp/evidence/sources.yaml +545 -0
  38. cometric/packs/alsp/overlays/levers.yaml +82 -0
  39. cometric/packs/alsp/overlays/mechanisms.yaml +19 -0
  40. cometric/packs/alsp/overlays/metrics.yaml +15 -0
  41. cometric/packs/alsp/overlays/pricing_models.yaml +186 -0
  42. cometric/packs/alsp/pack.yaml +9 -0
  43. cometric/packs/alsp/rules/rules.yaml +303 -0
  44. cometric/packs/base/concepts/automation_structures.yaml +207 -0
  45. cometric/packs/base/concepts/billing_bases.yaml +239 -0
  46. cometric/packs/base/concepts/cost_categories.yaml +494 -0
  47. cometric/packs/base/concepts/dimensions.yaml +404 -0
  48. cometric/packs/base/concepts/fee_components.yaml +105 -0
  49. cometric/packs/base/concepts/layers.yaml +146 -0
  50. cometric/packs/base/concepts/levers.yaml +1449 -0
  51. cometric/packs/base/concepts/mechanisms.yaml +295 -0
  52. cometric/packs/base/concepts/metrics.yaml +1149 -0
  53. cometric/packs/base/concepts/negotiation_exchanges.yaml +226 -0
  54. cometric/packs/base/concepts/offers.yaml +88 -0
  55. cometric/packs/base/concepts/pricing_models.yaml +821 -0
  56. cometric/packs/base/concepts/schema_map.yaml +1100 -0
  57. cometric/packs/base/concepts/selection_framework.yaml +215 -0
  58. cometric/packs/base/concepts/selection_heuristics.yaml +285 -0
  59. cometric/packs/base/coverage.yaml +33 -0
  60. cometric/packs/base/evidence/claims.yaml +442 -0
  61. cometric/packs/base/evidence/sources.yaml +293 -0
  62. cometric/packs/base/excluded.yaml +42 -0
  63. cometric/packs/base/pack.yaml +10 -0
  64. cometric/packs/base/rules/rules.yaml +521 -0
  65. cometric/packs/consulting/concepts/consulting_concepts.yaml +82 -0
  66. cometric/packs/consulting/concepts/provider_types.yaml +35 -0
  67. cometric/packs/consulting/concepts/service_categories.yaml +41 -0
  68. cometric/packs/consulting/concepts/unit_templates.yaml +36 -0
  69. cometric/packs/consulting/coverage.yaml +17 -0
  70. cometric/packs/consulting/overlays/pricing_models.yaml +29 -0
  71. cometric/packs/consulting/pack.yaml +11 -0
  72. cometric/packs/consulting/rules/rules.yaml +45 -0
  73. cometric/packs/consulting/templates/capped_tm.json +132 -0
  74. cometric/packs/consulting/templates/fixed_fee_plus_success.json +163 -0
  75. cometric/packs/consulting/templates/phased_fixed_fee.json +133 -0
  76. cometric/packs/consulting/templates/retainer_hours_drawdown.json +144 -0
  77. cometric/packs/consulting/templates/role_based_tm.json +123 -0
  78. cometric/py.typed +0 -0
  79. cometric-0.1.0.dist-info/METADATA +137 -0
  80. cometric-0.1.0.dist-info/RECORD +83 -0
  81. cometric-0.1.0.dist-info/WHEEL +4 -0
  82. cometric-0.1.0.dist-info/entry_points.txt +2 -0
  83. cometric-0.1.0.dist-info/licenses/LICENSE +202 -0
cometric/core/api.py ADDED
@@ -0,0 +1,835 @@
1
+ """Public operations (spec 8.3). Every operation is a pure function of its inputs (P-10)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import itertools
7
+ from decimal import Decimal
8
+ from typing import Any
9
+
10
+ from .engine import ENGINE_VERSION, CalculationResult, Calculator, effective_money_policy
11
+ from .enums import RiskBearer
12
+ from .fingerprint import fingerprint, to_jsonable
13
+ from .issues import CometricError, Issues
14
+ from .models import SCHEMA_VERSION, CostPolicy, Engagement, Scenario, TolerancePolicy
15
+ from .money import ZERO, safe_ratio, to_decimal
16
+ from .predicates import PredicateError, evaluate, leaves, resolve
17
+ from .validation import (
18
+ parse_cost_policy,
19
+ parse_engagement,
20
+ parse_scenario,
21
+ report,
22
+ status_for,
23
+ validate_engagement_model,
24
+ validate_scenario_model,
25
+ )
26
+
27
+ DETAILS = ("summary", "periods", "line_items")
28
+ COST_FIELDS = (
29
+ "direct_delivery_cost",
30
+ "engagement_avoidable_cost",
31
+ "portfolio_shared_cost",
32
+ "corporate_overhead_cost",
33
+ "unavoidable_direct_cost",
34
+ "gross_profit",
35
+ "contribution_profit",
36
+ "full_cost_profit",
37
+ "gross_margin",
38
+ "contribution_margin",
39
+ "cost_per_unit",
40
+ "unit_contribution",
41
+ "cumulative_contribution",
42
+ "cumulative_contribution_incurred",
43
+ "transition_payback_month",
44
+ )
45
+
46
+
47
+ def _prepare(
48
+ engagement: Any, scenario: Any, cost_policy: Any, issues: Issues
49
+ ) -> tuple[Engagement | None, Scenario | None, CostPolicy | None]:
50
+ e = parse_engagement(engagement, issues)
51
+ s = parse_scenario(scenario, issues)
52
+ cp = parse_cost_policy(cost_policy, issues)
53
+ if e is not None:
54
+ validate_engagement_model(e, issues)
55
+ if e.packs:
56
+ from .packs import check_engagement_vocabulary
57
+
58
+ check_engagement_vocabulary(e, issues)
59
+ if s is not None:
60
+ validate_scenario_model(e, s, issues)
61
+ return e, s, cp
62
+
63
+
64
+ def input_fingerprint(
65
+ operation: str,
66
+ e: Engagement,
67
+ s: Scenario | None,
68
+ cp: CostPolicy | None,
69
+ extra: dict | None = None,
70
+ ) -> str:
71
+ payload = {
72
+ "operation": operation,
73
+ "schema_version": SCHEMA_VERSION,
74
+ "engine_version": ENGINE_VERSION,
75
+ "engagement": e,
76
+ "scenario": s,
77
+ "cost_policy": cp,
78
+ "money_policy": effective_money_policy(e),
79
+ "packs": e.packs,
80
+ }
81
+ if extra:
82
+ payload.update(extra)
83
+ return fingerprint(payload)
84
+
85
+
86
+ def run_calculation(
87
+ engagement: Any, scenario: Any, cost_policy: Any = None
88
+ ) -> tuple[CalculationResult | None, Issues, Engagement | None, Scenario | None, CostPolicy | None]:
89
+ """Internal: validated calculation returning engine objects."""
90
+ issues = Issues()
91
+ e, s, cp = _prepare(engagement, scenario, cost_policy, issues)
92
+ if issues.has_errors or e is None or s is None:
93
+ return None, issues, e, s, cp
94
+ try:
95
+ result = Calculator(e, s, cp, issues).run()
96
+ except CometricError as exc:
97
+ return None, exc.issues, e, s, cp
98
+ return result, issues, e, s, cp
99
+
100
+
101
+ def _blank_costs(block: dict) -> None:
102
+ for k in COST_FIELDS:
103
+ if k in block:
104
+ block[k] = None
105
+ if "costs_by_category" in block:
106
+ block["costs_by_category"] = None
107
+
108
+
109
+ def calculate_periods(
110
+ engagement: Any, scenario: Any, cost_policy: Any = None, detail: str = "summary"
111
+ ) -> dict:
112
+ if detail not in DETAILS:
113
+ detail = "summary"
114
+ result, issues, e, s, cp = run_calculation(engagement, scenario, cost_policy)
115
+ if result is None:
116
+ out = {
117
+ "status": status_for(issues),
118
+ "feasible": None,
119
+ "engine_version": ENGINE_VERSION,
120
+ "schema_version": SCHEMA_VERSION,
121
+ }
122
+ out["issues"] = report(issues)
123
+ return to_jsonable(out)
124
+ assert e is not None and s is not None
125
+ data = copy.deepcopy(result.data)
126
+ costs_evaluated = (
127
+ cp is not None and e.delivery_plan is not None and bool(e.delivery_plan.cost_components)
128
+ )
129
+ if not costs_evaluated:
130
+ reason = (
131
+ "no cost_policy supplied"
132
+ if cp is None
133
+ else "the engagement has no delivery_plan cost section"
134
+ )
135
+ issues.info(
136
+ "COSTS_NOT_EVALUATED", f"margin and contribution metrics are unavailable: {reason}"
137
+ )
138
+ for block in [data["term"], *data["years"].values(), *data["periods"]]:
139
+ _blank_costs(block)
140
+ out: dict[str, Any] = {
141
+ "status": "ok" if result.feasible else "infeasible",
142
+ "feasible": result.feasible,
143
+ "engine_version": ENGINE_VERSION,
144
+ "schema_version": SCHEMA_VERSION,
145
+ "input_fingerprint": input_fingerprint("calculate_periods", e, s, cp),
146
+ "engagement_id": e.engagement_id,
147
+ "scenario_id": s.id,
148
+ "cost_policy": cp.id if cp else None,
149
+ "currency": e.currency,
150
+ "money_policy": effective_money_policy(e),
151
+ "packs": e.packs,
152
+ "costs_evaluated": costs_evaluated,
153
+ "horizon": {
154
+ "from": data["periods"][0]["period"] if data["periods"] else None,
155
+ "to": data["periods"][-1]["period"] if data["periods"] else None,
156
+ },
157
+ "term": data["term"],
158
+ "years": data["years"],
159
+ }
160
+ if detail in ("periods", "line_items"):
161
+ out["periods"] = data["periods"]
162
+ if detail == "line_items":
163
+ out["line_items"] = {
164
+ "fees": [ln.as_dict() for ln in result.fee_lines],
165
+ "costs": [ln.as_dict() for ln in result.cost_lines],
166
+ }
167
+ out["ledger"] = result.ledger
168
+ out["issues"] = report(issues)
169
+ return to_jsonable(out)
170
+
171
+
172
+ def explain_fee(
173
+ engagement: Any,
174
+ scenario: Any,
175
+ cost_policy: Any = None,
176
+ period: str | None = None,
177
+ component: str | None = None,
178
+ ) -> dict:
179
+ result, issues, e, s, cp = run_calculation(engagement, scenario, cost_policy)
180
+ if result is None:
181
+ return to_jsonable({"status": status_for(issues), "issues": report(issues)})
182
+ lines = [
183
+ ln
184
+ for ln in result.fee_lines
185
+ if (period is None or ln.period == period)
186
+ and (component is None or ln.component == component or ln.ref == component)
187
+ ]
188
+ total = sum((ln.rounded for ln in lines), ZERO)
189
+ ledger = [x for x in result.ledger if period is None or x["period"] == period]
190
+ explanation = [
191
+ f"step {ln.step} ({ln.as_dict()['stage']}): {ln.ref} {ln.kind} = {ln.rounded} [{ln.formula}]"
192
+ for ln in lines
193
+ ]
194
+ return to_jsonable(
195
+ {
196
+ "status": "ok",
197
+ "input_fingerprint": input_fingerprint("calculate_periods", e, s, cp),
198
+ "period": period,
199
+ "component": component,
200
+ "fee_total": total,
201
+ "line_items": [ln.as_dict() for ln in lines],
202
+ "ledger": ledger,
203
+ "explanation": explanation,
204
+ "labels": {
205
+ "contract_facts": "rates, allowances, minimums, and adjustment terms come from the engagement",
206
+ "assumptions": "quantities come from the scenario, each with its origin",
207
+ "calculated": "every amount above is computed by the engine",
208
+ },
209
+ }
210
+ )
211
+
212
+
213
+ # --- break-even and price floor ---------------------------------------------------------
214
+
215
+
216
+ def _with_driver(s: Scenario, driver: str, value: Decimal) -> Scenario:
217
+ base = next((a for a in s.assumptions if a.name == driver), None)
218
+ if base is None:
219
+ raise KeyError(driver)
220
+ others = [a for a in s.assumptions if a.name != driver]
221
+ new = base.model_copy(
222
+ update={
223
+ "value": value,
224
+ "time_period": "TERM",
225
+ "profile": [],
226
+ "distribution": base.distribution.FLAT,
227
+ }
228
+ )
229
+ return s.model_copy(update={"assumptions": [*others, new]})
230
+
231
+
232
+ def break_even(
233
+ engagement: Any, scenario: Any, cost_policy: Any, driver: str, max_value: int = 10**9
234
+ ) -> dict:
235
+ """Driver volume at which term contribution is zero; piecewise, by bracketing (spec 5.4)."""
236
+ issues = Issues()
237
+ e, s, cp = _prepare(engagement, scenario, cost_policy, issues)
238
+ if issues.has_errors or e is None or s is None or cp is None:
239
+ if cp is None:
240
+ issues.error("COST_POLICY_REQUIRED", "break-even needs a cost policy", "/cost_policy")
241
+ return to_jsonable({"status": status_for(issues), "issues": report(issues)})
242
+
243
+ def f(x: int) -> Decimal:
244
+ r = Calculator(e, _with_driver(s, driver, Decimal(x)), cp).run()
245
+ return r.data["term"]["contribution_profit"]
246
+
247
+ try:
248
+ f0 = f(0)
249
+ except KeyError:
250
+ issues.error("UNKNOWN_DRIVER", f"scenario has no assumption named {driver!r}", "/driver")
251
+ return to_jsonable({"status": "invalid", "issues": report(issues)})
252
+ if f0 == 0:
253
+ return to_jsonable(
254
+ {"status": "ok", "driver": driver, "exact": ZERO, "whole": 0, "direction": "flat"}
255
+ )
256
+ hi = 1
257
+ fhi = f(hi)
258
+ while (fhi > 0) == (f0 > 0) and fhi != 0 and hi < max_value:
259
+ hi *= 2
260
+ fhi = f(hi)
261
+ if (fhi > 0) == (f0 > 0) and fhi != 0:
262
+ return to_jsonable(
263
+ {
264
+ "status": "ok",
265
+ "driver": driver,
266
+ "exact": None,
267
+ "whole": None,
268
+ "reason": "contribution does not change sign within the searched range",
269
+ }
270
+ )
271
+ lo, flo = 0, f0
272
+ while hi - lo > 1:
273
+ mid = (lo + hi) // 2
274
+ fm = f(mid)
275
+ if (fm > 0) == (flo > 0) and fm != 0:
276
+ lo, flo = mid, fm
277
+ else:
278
+ hi, fhi = mid, fm
279
+ slope = fhi - flo
280
+ exact = Decimal(lo) + (-flo) / slope if slope != 0 else Decimal(hi)
281
+ increasing = f0 < 0
282
+ whole = hi if increasing else (hi if fhi >= 0 else lo)
283
+ return to_jsonable(
284
+ {
285
+ "status": "ok",
286
+ "driver": driver,
287
+ "exact": exact,
288
+ "whole": whole,
289
+ "direction": "increasing" if increasing else "decreasing",
290
+ "note": "Exact value assumes contribution is linear between the bracketing whole units.",
291
+ }
292
+ )
293
+
294
+
295
+ def price_floor(
296
+ engagement: Any,
297
+ scenarios: list[Any],
298
+ cost_policy: Any,
299
+ target_margin: Any,
300
+ risk_premium: Any = "0",
301
+ ) -> dict:
302
+ """P_min(q) = (C(q) + RiskPremium) / (1 - m_t), per scenario volume (spec 5.6)."""
303
+ issues = Issues()
304
+ try:
305
+ m_t = to_decimal(target_margin)
306
+ rp = to_decimal(risk_premium)
307
+ except ValueError as exc:
308
+ issues.error("SCHEMA", str(exc), "/target_margin")
309
+ return to_jsonable({"status": "invalid", "issues": report(issues)})
310
+ if m_t >= 1:
311
+ issues.error("TARGET_MARGIN_RANGE", "target_margin must be less than 1", "/target_margin")
312
+ if rp < 0:
313
+ issues.error("RISK_PREMIUM_RANGE", "risk_premium cannot be negative", "/risk_premium")
314
+ if cost_policy is None:
315
+ issues.error("COST_POLICY_REQUIRED", "price floor needs a cost policy", "/cost_policy")
316
+ if issues.has_errors:
317
+ return to_jsonable({"status": "invalid", "issues": report(issues)})
318
+ rows = []
319
+ for sc in scenarios:
320
+ r = calculate_periods(engagement, sc, cost_policy)
321
+ if r["status"] not in ("ok", "infeasible"):
322
+ return r
323
+ t = r["term"]
324
+ cost = Decimal(t["direct_delivery_cost"]) + Decimal(t["engagement_avoidable_cost"])
325
+ floor = (cost + rp) / (1 - m_t)
326
+ q = Decimal(t["client_consumed_quantity"]) if t.get("client_consumed_quantity") else None
327
+ fee = Decimal(t["contractual_fee"])
328
+ rows.append(
329
+ {
330
+ "scenario_id": r["scenario_id"],
331
+ "volume": q,
332
+ "volume_quantity": t.get("client_value_quantity"),
333
+ "costs_in_floor": cost,
334
+ "price_floor": floor,
335
+ "unit_floor": safe_ratio(floor, q) if q else None,
336
+ "current_fee": fee,
337
+ "headroom": fee - floor,
338
+ }
339
+ )
340
+ return to_jsonable(
341
+ {
342
+ "status": "ok",
343
+ "target_margin": m_t,
344
+ "risk_premium": rp,
345
+ "origin": "INTERNAL_POLICY",
346
+ "formula": "(DIRECT_DELIVERY + ENGAGEMENT_AVOIDABLE costs + risk premium) / (1 - target margin)",
347
+ "note": "A decision formula under the caller's targets, not a market benchmark.",
348
+ "floors": rows,
349
+ }
350
+ )
351
+
352
+
353
+ # --- risk exposure, comparison, search --------------------------------------------------
354
+
355
+
356
+ def _d(v: Any) -> Decimal | None:
357
+ if v is None:
358
+ return None
359
+ return v if isinstance(v, Decimal) else Decimal(str(v))
360
+
361
+
362
+ def _client_price(term: dict) -> Decimal | None:
363
+ v = term.get("effective_client_unit_price")
364
+ if v is None:
365
+ v = term.get("effective_client_price_per_used_hour")
366
+ return _d(v)
367
+
368
+
369
+ def derive_risk_exposure(
370
+ results: list[dict],
371
+ base_scenario_id: str | None = None,
372
+ tolerance_policy: Any = None,
373
+ declared: list[dict] | None = None,
374
+ ) -> dict:
375
+ """Who absorbs each deviation from the base case (spec 5.5)."""
376
+ tol = (
377
+ TolerancePolicy.model_validate(tolerance_policy)
378
+ if tolerance_policy is not None and not isinstance(tolerance_policy, TolerancePolicy)
379
+ else tolerance_policy
380
+ )
381
+ by_id = {r["scenario_id"]: r for r in results if r.get("status") in ("ok", "infeasible")}
382
+ if not by_id:
383
+ return {
384
+ "status": "invalid",
385
+ "issues": {
386
+ "errors": [
387
+ {
388
+ "code": "NO_RESULTS",
389
+ "message": "no successful calculation results supplied",
390
+ "path": "/results",
391
+ }
392
+ ]
393
+ },
394
+ }
395
+ base_id = base_scenario_id or ("base" if "base" in by_id else next(iter(by_id)))
396
+ base = by_id[base_id]["term"]
397
+ base_cp = _d(base["contribution_profit"])
398
+ base_price = _client_price(base)
399
+ rows = []
400
+ warnings = []
401
+ declared = declared or []
402
+ for sid, r in by_id.items():
403
+ if sid == base_id:
404
+ continue
405
+ t = r["term"]
406
+ cp = _d(t["contribution_profit"])
407
+ price = _client_price(t)
408
+ pdown = (base_cp - cp) if base_cp is not None and cp is not None else None
409
+ cdown = (price - base_price) if price is not None and base_price is not None else None
410
+ label = None
411
+ if tol is not None and pdown is not None and cdown is not None:
412
+ p_adv = pdown > tol.provider_relative * abs(base_cp)
413
+ c_adv = cdown > tol.client_relative * abs(base_price)
414
+ label = (
415
+ RiskBearer.SHARED
416
+ if p_adv and c_adv
417
+ else RiskBearer.PROVIDER
418
+ if p_adv
419
+ else RiskBearer.CLIENT
420
+ if c_adv
421
+ else RiskBearer.NONE_ADVERSE
422
+ ).value
423
+ row = {
424
+ "scenario_id": sid,
425
+ "provider_downside": pdown,
426
+ "client_downside": cdown,
427
+ "client_price": price,
428
+ "fee_change": _d(t["contractual_fee"]) - _d(base["contractual_fee"]),
429
+ "derived_bearer": label,
430
+ }
431
+ rows.append(row)
432
+ for d in declared:
433
+ if (
434
+ label
435
+ and label != RiskBearer.NONE_ADVERSE.value
436
+ and d.get("declared_bearer") not in (label, RiskBearer.SHARED.value)
437
+ ):
438
+ warnings.append(
439
+ {
440
+ "code": "RISK_MISMATCH",
441
+ "message": f"{d.get('risk_type')} risk is declared as borne by {d.get('declared_bearer')} but scenario {sid} shows {label}",
442
+ "path": "",
443
+ }
444
+ )
445
+ return to_jsonable(
446
+ {
447
+ "status": "ok",
448
+ "base_scenario_id": base_id,
449
+ "base_contribution": base_cp,
450
+ "base_client_price": base_price,
451
+ "tolerance": tol,
452
+ "deviations": rows,
453
+ "warnings": warnings,
454
+ }
455
+ )
456
+
457
+
458
+ def _term_summary(r: dict) -> dict:
459
+ t = r["term"]
460
+ return {
461
+ k: t.get(k)
462
+ for k in (
463
+ "contractual_fee",
464
+ "direct_delivery_cost",
465
+ "engagement_avoidable_cost",
466
+ "gross_profit",
467
+ "contribution_profit",
468
+ "gross_margin",
469
+ "contribution_margin",
470
+ "effective_client_unit_price",
471
+ "effective_client_price_per_used_hour",
472
+ "commitment_utilization",
473
+ "overage_fee",
474
+ "commitment_shortfall",
475
+ "capacity",
476
+ )
477
+ }
478
+
479
+
480
+ def _dominates(a: list[tuple[Decimal | None, str]], b: list[tuple[Decimal | None, str]]) -> bool:
481
+ better = False
482
+ for (va, sense), (vb, _) in zip(a, b, strict=True):
483
+ if va is None:
484
+ return False
485
+ if vb is None:
486
+ better = True
487
+ continue
488
+ if sense == "max":
489
+ if va < vb:
490
+ return False
491
+ if va > vb:
492
+ better = True
493
+ else:
494
+ if va > vb:
495
+ return False
496
+ if va < vb:
497
+ better = True
498
+ return better
499
+
500
+
501
+ def dominance(vectors: dict[str, list[tuple[Decimal | None, str]]]) -> dict[str, list[str]]:
502
+ return {
503
+ i: [j for j in vectors if j != i and _dominates(vectors[j], vectors[i])] for i in vectors
504
+ }
505
+
506
+
507
+ def _evaluate_candidate(engagement: Any, scenarios: list[Any], cost_policy: Any) -> dict:
508
+ results = [calculate_periods(engagement, sc, cost_policy) for sc in scenarios]
509
+ ok = all(r["status"] in ("ok", "infeasible") for r in results)
510
+ feasible = ok and all(r.get("feasible") for r in results)
511
+ return {"results": results, "valid": ok, "feasible": feasible}
512
+
513
+
514
+ def _objective_value(per_scenario: dict[str, dict], objective: dict) -> Decimal | None:
515
+ metric = objective["metric"]
516
+ agg = objective.get("aggregation", "BASE")
517
+ values = {sid: _d(m.get(metric)) for sid, m in per_scenario.items()}
518
+ if agg == "BASE":
519
+ return values.get(objective.get("base_scenario", "base"), next(iter(values.values()), None))
520
+ vals = [v for v in values.values() if v is not None]
521
+ if not vals or len(vals) != len(values):
522
+ return None
523
+ if agg == "WORST":
524
+ return min(vals) if objective.get("sense", "max") == "max" else max(vals)
525
+ return sum(vals, ZERO) / len(vals)
526
+
527
+
528
+ def compare_arrangements(
529
+ candidates: list[dict],
530
+ scenario_set: list[Any],
531
+ cost_policy: Any,
532
+ objective: dict | None = None,
533
+ tolerance_policy: Any = None,
534
+ ) -> dict:
535
+ """Evaluate candidates on the same scenarios; report dominance, never a ranking (spec 6.2)."""
536
+ rows = {}
537
+ for cand in candidates:
538
+ ev = _evaluate_candidate(cand["engagement"], scenario_set, cost_policy)
539
+ per = {r["scenario_id"]: _term_summary(r) for r in ev["results"] if r.get("scenario_id")}
540
+ exposure = (
541
+ derive_risk_exposure(ev["results"], tolerance_policy=tolerance_policy)
542
+ if ev["valid"]
543
+ else None
544
+ )
545
+ rows[cand["id"]] = {
546
+ "id": cand["id"],
547
+ "valid": ev["valid"],
548
+ "feasible": ev["feasible"],
549
+ "scenarios": per,
550
+ "risk_exposure": exposure,
551
+ "issues": [r["issues"] for r in ev["results"] if r["status"] not in ("ok",)],
552
+ }
553
+ feasible_ids = [i for i, r in rows.items() if r["feasible"]]
554
+ vectors = {}
555
+ for i in feasible_ids:
556
+ vec = []
557
+ for sid in rows[i]["scenarios"]:
558
+ m = rows[i]["scenarios"][sid]
559
+ vec.append((_d(m["contribution_profit"]), "max"))
560
+ vec.append((_client_price(m), "min"))
561
+ vectors[i] = vec
562
+ dom = dominance(vectors)
563
+ for i in rows:
564
+ rows[i]["dominated_by"] = dom.get(i, []) if i in feasible_ids else None
565
+ out = {
566
+ "status": "ok",
567
+ "metrics": [
568
+ "contribution_profit (max) in every scenario",
569
+ "effective client unit price (min) in every scenario",
570
+ ],
571
+ "candidates": list(rows.values()),
572
+ "non_dominated": [i for i in feasible_ids if not dom[i]],
573
+ "infeasible": [i for i, r in rows.items() if r["valid"] and not r["feasible"]],
574
+ "ranking": None,
575
+ }
576
+ if objective:
577
+ scored = {i: _objective_value(rows[i]["scenarios"], objective) for i in feasible_ids}
578
+ scored = {i: v for i, v in scored.items() if v is not None}
579
+ if scored:
580
+ best_v = (
581
+ max(scored.values())
582
+ if objective.get("sense", "max") == "max"
583
+ else min(scored.values())
584
+ )
585
+ out["objective"] = objective
586
+ out["best_under_objective"] = [i for i, v in scored.items() if v == best_v]
587
+ return to_jsonable(out)
588
+
589
+
590
+ def _set_pointer(doc: Any, ptr: str, value: Any) -> None:
591
+ parts = [p.replace("~1", "/").replace("~0", "~") for p in ptr.lstrip("/").split("/")]
592
+ cur = doc
593
+ for p in parts[:-1]:
594
+ cur = cur[int(p)] if isinstance(cur, list) else cur[p]
595
+ last = parts[-1]
596
+ if isinstance(cur, list):
597
+ cur[int(last)] = value
598
+ else:
599
+ cur[last] = value
600
+
601
+
602
+ def _param_values(p: dict) -> list[str]:
603
+ if "values" in p:
604
+ return [str(v) for v in p["values"]]
605
+ r = p["range"]
606
+ start, stop, step = to_decimal(r["from"]), to_decimal(r["to"]), to_decimal(r["step"])
607
+ if step <= 0:
608
+ raise ValueError("range step must be positive")
609
+ out = []
610
+ v = start
611
+ while v <= stop:
612
+ out.append(str(v))
613
+ v += step
614
+ return out
615
+
616
+
617
+ def search_structures(search_spec: dict) -> dict:
618
+ """Deterministic grid search returning the trade-off frontier (spec 6.4)."""
619
+ spec = search_spec
620
+ base_engagement = spec["engagement"]
621
+ scenarios = spec["scenarios"]
622
+ cost_policy = spec.get("cost_policy")
623
+ params = spec.get("parameters", [])
624
+ max_candidates = int(spec.get("max_candidates", 10000))
625
+ max_candidates = min(max_candidates, 10000)
626
+ max_returned = int(spec.get("max_returned", 50))
627
+ try:
628
+ grids = [_param_values(p) for p in params]
629
+ except (KeyError, ValueError) as exc:
630
+ return {
631
+ "status": "invalid",
632
+ "issues": {
633
+ "errors": [{"code": "PARAMETER_SPEC", "message": str(exc), "path": "/parameters"}]
634
+ },
635
+ }
636
+ size = 1
637
+ for g in grids:
638
+ size *= len(g)
639
+ if size > max_candidates:
640
+ return {
641
+ "status": "rejected",
642
+ "reason": f"grid has {size} candidates, above max_candidates {max_candidates}",
643
+ "grid_size": size,
644
+ }
645
+ constraints = spec.get("constraints")
646
+ objective = spec.get("objective")
647
+ frontier_metrics = spec.get("frontier_metrics")
648
+ counts = {
649
+ "enumerated": 0,
650
+ "invalid": 0,
651
+ "infeasible": 0,
652
+ "failing_constraints": 0,
653
+ "evaluated": 0,
654
+ }
655
+ invalid_reasons: dict[str, int] = {}
656
+ evaluated = []
657
+ for combo in itertools.product(*grids):
658
+ counts["enumerated"] += 1
659
+ eng = copy.deepcopy(base_engagement)
660
+ values = {}
661
+ for p, v in zip(params, combo, strict=True):
662
+ try:
663
+ _set_pointer(eng, p["pointer"], v)
664
+ except (KeyError, IndexError, ValueError, TypeError):
665
+ return {
666
+ "status": "invalid",
667
+ "issues": {
668
+ "errors": [
669
+ {
670
+ "code": "BAD_POINTER",
671
+ "message": f"pointer {p['pointer']} does not resolve",
672
+ "path": "/parameters",
673
+ }
674
+ ]
675
+ },
676
+ }
677
+ values[p["name"]] = v
678
+ ev = _evaluate_candidate(eng, scenarios, cost_policy)
679
+ if not ev["valid"]:
680
+ counts["invalid"] += 1
681
+ for r in ev["results"]:
682
+ for err in r.get("issues", {}).get("errors", []):
683
+ invalid_reasons[err["code"]] = invalid_reasons.get(err["code"], 0) + 1
684
+ continue
685
+ if not ev["feasible"]:
686
+ counts["infeasible"] += 1
687
+ continue
688
+ per = {r["scenario_id"]: _term_summary(r) for r in ev["results"]}
689
+ exposure = derive_risk_exposure(ev["results"])
690
+ doc = {"scenarios": per, "parameters": values}
691
+ if constraints is not None:
692
+ try:
693
+ if not evaluate(constraints, doc):
694
+ counts["failing_constraints"] += 1
695
+ continue
696
+ except PredicateError as exc:
697
+ return {
698
+ "status": "invalid",
699
+ "issues": {
700
+ "errors": [
701
+ {"code": "BAD_CONSTRAINT", "message": str(exc), "path": "/constraints"}
702
+ ]
703
+ },
704
+ }
705
+ counts["evaluated"] += 1
706
+ evaluated.append(
707
+ {
708
+ "parameters": values,
709
+ "scenarios": per,
710
+ "risk_exposure": exposure,
711
+ "fingerprints": [r["input_fingerprint"] for r in ev["results"]],
712
+ "_doc": doc,
713
+ }
714
+ )
715
+ vectors = {}
716
+ for i, c in enumerate(evaluated):
717
+ vectors[str(i)] = _frontier_vector(c, frontier_metrics)
718
+ dom = dominance(vectors)
719
+ frontier = [evaluated[int(i)] for i in vectors if not dom[i]]
720
+ for c in frontier:
721
+ c["binding_constraints"] = _binding(constraints, c["_doc"]) if constraints else []
722
+ best = None
723
+ if objective:
724
+ scored = [(c, _objective_value(c["scenarios"], objective)) for c in evaluated]
725
+ scored = [(c, v) for c, v in scored if v is not None]
726
+ if scored:
727
+ bv = (
728
+ max(v for _, v in scored)
729
+ if objective.get("sense", "max") == "max"
730
+ else min(v for _, v in scored)
731
+ )
732
+ best = [c["parameters"] for c, v in scored if v == bv]
733
+ truncated = len(frontier) > max_returned
734
+ for c in evaluated:
735
+ c.pop("_doc", None)
736
+ out = {
737
+ "status": "ok",
738
+ "approval_state": "PENDING_HUMAN_REVIEW",
739
+ "record_type": "PricingRecommendation",
740
+ "counts": {**counts, "on_frontier": len(frontier)},
741
+ "invalid_reasons": invalid_reasons,
742
+ "frontier_metrics": frontier_metrics
743
+ or [
744
+ "contribution_profit in base (max)",
745
+ "effective client unit price in base (min)",
746
+ "largest provider downside (min)",
747
+ "largest client downside (min)",
748
+ ],
749
+ "frontier": frontier[:max_returned],
750
+ "frontier_truncated": truncated,
751
+ "objective": objective,
752
+ "best_under_objective": best,
753
+ "note": "Frontier candidates are not ranked. A recommendation needs human review before adoption.",
754
+ }
755
+ return to_jsonable(out)
756
+
757
+
758
+ def _frontier_vector(c: dict, metrics: list[dict] | None) -> list[tuple[Decimal | None, str]]:
759
+ per = c["scenarios"]
760
+ base_id = "base" if "base" in per else next(iter(per))
761
+ if metrics:
762
+ vec = []
763
+ for m in metrics:
764
+ vals = resolve(c["_doc"], m["path"])
765
+ v = _d(vals[0]) if vals else None
766
+ vec.append((v, m.get("sense", "max")))
767
+ return vec
768
+ devs = (
769
+ c["risk_exposure"].get("deviations", []) if c["risk_exposure"].get("status") == "ok" else []
770
+ )
771
+ pd = [_d(d["provider_downside"]) for d in devs if d["provider_downside"] is not None]
772
+ cd = [_d(d["client_downside"]) for d in devs if d["client_downside"] is not None]
773
+ return [
774
+ (_d(per[base_id]["contribution_profit"]), "max"),
775
+ (_client_price(per[base_id]), "min"),
776
+ (max(pd) if pd else ZERO, "min"),
777
+ (max(cd) if cd else ZERO, "min"),
778
+ ]
779
+
780
+
781
+ def _binding(constraints: dict, doc: dict) -> list[dict]:
782
+ out = []
783
+ for leaf in leaves(constraints):
784
+ op = next(k for k in leaf if k != "path")
785
+ if op not in ("gt", "lt", "gte", "lte"):
786
+ continue
787
+ vals = [_d(v) for v in resolve(doc, leaf["path"]) if v is not None]
788
+ if not vals:
789
+ continue
790
+ target = _d(leaf[op])
791
+ slack = min(abs(v - target) for v in vals)
792
+ out.append(
793
+ {"path": leaf["path"], "op": op, "threshold": target, "closest_value_slack": slack}
794
+ )
795
+ out.sort(key=lambda x: x["closest_value_slack"])
796
+ return out
797
+
798
+
799
+ def check_commercial_constraints(engagement: Any, policy: dict | None = None) -> dict:
800
+ """Core checks plus rules from declared packs; policy thresholds are caller-supplied (spec 7.3)."""
801
+ issues = Issues()
802
+ e = parse_engagement(engagement, issues)
803
+ if e is None:
804
+ return to_jsonable({"status": status_for(issues), **report(issues)})
805
+ validate_engagement_model(e, issues)
806
+ rules_fired: list[dict] = []
807
+ if e.packs:
808
+ from .packs import check_engagement_vocabulary, evaluate_rules
809
+
810
+ check_engagement_vocabulary(e, issues)
811
+ rules_fired = evaluate_rules(e, issues)
812
+ measured = {}
813
+ if policy and policy.get("contribution_margin_floor") is not None and policy.get("scenarios"):
814
+ floor = to_decimal(policy["contribution_margin_floor"])
815
+ for sc in policy["scenarios"]:
816
+ r = calculate_periods(e, sc, policy.get("cost_policy"))
817
+ cm = (
818
+ r.get("term", {}).get("contribution_margin")
819
+ if r.get("status") in ("ok", "infeasible")
820
+ else None
821
+ )
822
+ measured[r.get("scenario_id", "?")] = cm
823
+ if cm is not None and Decimal(cm) < floor:
824
+ issues.approval(
825
+ "CONTRIBUTION_BELOW_FLOOR",
826
+ f"contribution margin {cm} in scenario {r['scenario_id']} is below the policy floor {floor}",
827
+ )
828
+ return to_jsonable(
829
+ {
830
+ "status": status_for(issues),
831
+ "rules_fired": rules_fired,
832
+ "measured": measured,
833
+ **report(issues),
834
+ }
835
+ )