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.
- cometric/__init__.py +50 -0
- cometric/cli.py +210 -0
- cometric/core/__init__.py +0 -0
- cometric/core/api.py +835 -0
- cometric/core/engine.py +1386 -0
- cometric/core/enums.py +273 -0
- cometric/core/fingerprint.py +91 -0
- cometric/core/issues.py +68 -0
- cometric/core/ledger.py +165 -0
- cometric/core/models.py +450 -0
- cometric/core/money.py +110 -0
- cometric/core/packs.py +446 -0
- cometric/core/periods.py +102 -0
- cometric/core/predicates.py +139 -0
- cometric/core/quantities.py +138 -0
- cometric/core/schema.py +97 -0
- cometric/core/templates.py +338 -0
- cometric/core/tiers.py +78 -0
- cometric/core/validation.py +738 -0
- cometric/examples/case_a.json +293 -0
- cometric/examples/case_b.json +212 -0
- cometric/examples/case_c.json +233 -0
- cometric/examples/case_d.json +253 -0
- cometric/examples/case_e.json +258 -0
- cometric/examples/case_f.json +298 -0
- cometric/examples/case_g.json +243 -0
- cometric/examples/case_h.json +271 -0
- cometric/examples/spec_example.json +134 -0
- cometric/mcp/__init__.py +0 -0
- cometric/mcp/__main__.py +3 -0
- cometric/mcp/server.py +470 -0
- cometric/packs/alsp/concepts/provider_types.yaml +154 -0
- cometric/packs/alsp/concepts/service_categories.yaml +132 -0
- cometric/packs/alsp/concepts/unit_templates.yaml +132 -0
- cometric/packs/alsp/coverage.yaml +17 -0
- cometric/packs/alsp/evidence/claims.yaml +446 -0
- cometric/packs/alsp/evidence/sources.yaml +545 -0
- cometric/packs/alsp/overlays/levers.yaml +82 -0
- cometric/packs/alsp/overlays/mechanisms.yaml +19 -0
- cometric/packs/alsp/overlays/metrics.yaml +15 -0
- cometric/packs/alsp/overlays/pricing_models.yaml +186 -0
- cometric/packs/alsp/pack.yaml +9 -0
- cometric/packs/alsp/rules/rules.yaml +303 -0
- cometric/packs/base/concepts/automation_structures.yaml +207 -0
- cometric/packs/base/concepts/billing_bases.yaml +239 -0
- cometric/packs/base/concepts/cost_categories.yaml +494 -0
- cometric/packs/base/concepts/dimensions.yaml +404 -0
- cometric/packs/base/concepts/fee_components.yaml +105 -0
- cometric/packs/base/concepts/layers.yaml +146 -0
- cometric/packs/base/concepts/levers.yaml +1449 -0
- cometric/packs/base/concepts/mechanisms.yaml +295 -0
- cometric/packs/base/concepts/metrics.yaml +1149 -0
- cometric/packs/base/concepts/negotiation_exchanges.yaml +226 -0
- cometric/packs/base/concepts/offers.yaml +88 -0
- cometric/packs/base/concepts/pricing_models.yaml +821 -0
- cometric/packs/base/concepts/schema_map.yaml +1100 -0
- cometric/packs/base/concepts/selection_framework.yaml +215 -0
- cometric/packs/base/concepts/selection_heuristics.yaml +285 -0
- cometric/packs/base/coverage.yaml +33 -0
- cometric/packs/base/evidence/claims.yaml +442 -0
- cometric/packs/base/evidence/sources.yaml +293 -0
- cometric/packs/base/excluded.yaml +42 -0
- cometric/packs/base/pack.yaml +10 -0
- cometric/packs/base/rules/rules.yaml +521 -0
- cometric/packs/consulting/concepts/consulting_concepts.yaml +82 -0
- cometric/packs/consulting/concepts/provider_types.yaml +35 -0
- cometric/packs/consulting/concepts/service_categories.yaml +41 -0
- cometric/packs/consulting/concepts/unit_templates.yaml +36 -0
- cometric/packs/consulting/coverage.yaml +17 -0
- cometric/packs/consulting/overlays/pricing_models.yaml +29 -0
- cometric/packs/consulting/pack.yaml +11 -0
- cometric/packs/consulting/rules/rules.yaml +45 -0
- cometric/packs/consulting/templates/capped_tm.json +132 -0
- cometric/packs/consulting/templates/fixed_fee_plus_success.json +163 -0
- cometric/packs/consulting/templates/phased_fixed_fee.json +133 -0
- cometric/packs/consulting/templates/retainer_hours_drawdown.json +144 -0
- cometric/packs/consulting/templates/role_based_tm.json +123 -0
- cometric/py.typed +0 -0
- cometric-0.1.0.dist-info/METADATA +137 -0
- cometric-0.1.0.dist-info/RECORD +83 -0
- cometric-0.1.0.dist-info/WHEEL +4 -0
- cometric-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|