adaptive-pricing/adaptive_pricing_core/pricing_models.py
codex c65a2f1ff9
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
feat: add typed tier assurance guardrails
2026-08-19 21:32:07 +02:00

417 lines
15 KiB
Python

from __future__ import annotations
import json
from dataclasses import dataclass, field
from decimal import Decimal
from pathlib import Path
from typing import Any, Literal
PricingModelStatus = Literal["active", "candidate", "retired"]
AssuranceClaimKind = Literal["isolation", "availability", "retention", "performance"]
ChargeComponentKind = Literal[
"access",
"setup",
"usage",
"support",
"discount",
"risk_adjustment",
]
ParameterClass = Literal[
"fixed",
"seller_controlled",
"customer_tunable",
"calculated",
"constrained",
"provider",
]
_ALLOWED_COMPONENT_KINDS = {
"access",
"setup",
"usage",
"support",
"discount",
"risk_adjustment",
}
_ALLOWED_PARAMETER_CLASSES = {
"fixed",
"seller_controlled",
"customer_tunable",
"calculated",
"constrained",
"provider",
}
_ALLOWED_ASSURANCE_KINDS = {"isolation", "availability", "retention", "performance"}
_AXIS_MAXIMUMS = {"I": 3, "A": 4, "E": 4, "P": 4, "R": 4, "V": 4}
_ALLOWED_ERASURE_MECHANISMS = {"row-deletion", "key-destruction"}
def _money(value: str | int | float | Decimal | None) -> Decimal | None:
if value in (None, ""):
return None
return Decimal(str(value))
def _tuple_dict(value: dict[str, Any] | None) -> dict[str, Any]:
return dict(value or {})
@dataclass(frozen=True)
class ChargeComponent:
id: str
kind: ChargeComponentKind | str
amount: Decimal | None = None
cadence: str | None = None
meter: str | None = None
unit: str | None = None
unit_price: Decimal | None = None
included_units: Decimal | None = None
label: str | None = None
billing_treatment: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class Commitment:
id: str
kind: str
value: str
unit: str | None = None
description: str = ""
@dataclass(frozen=True)
class TunableParameter:
key: str
parameter_class: ParameterClass | str
data_type: str
description: str = ""
default_value: str | None = None
min_value: Decimal | None = None
max_value: Decimal | None = None
options: tuple[str, ...] = ()
@dataclass(frozen=True)
class AssuranceClaim:
id: str
kind: AssuranceClaimKind | str
customer_wording: str
minimum_levels: dict[str, int]
delivering_service: str
evidence_ref: str
maximum_erasure_horizon_days: int | None = None
provider_contract_ref: str | None = None
resource_governor_ref: str | None = None
erasure_mechanism: str | None = None
@dataclass(frozen=True)
class PricingModel:
id: str
name: str
model_type: str
lifecycle_phase: str
currency: str
access_fee_amount: Decimal
access_fee_cadence: str
status: PricingModelStatus
description: str = ""
included_usage: str | None = None
overage_meter: str | None = None
charge_components: tuple[ChargeComponent, ...] = ()
commitments: tuple[Commitment, ...] = ()
tunable_parameters: tuple[TunableParameter, ...] = ()
assurance_claims: tuple[AssuranceClaim, ...] = ()
eligibility: tuple[str, ...] = ()
provider_hints: dict[str, Any] = field(default_factory=dict)
metadata: dict[str, Any] = field(default_factory=dict)
def _read_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def _parse_charge_component(raw: dict[str, Any]) -> ChargeComponent:
return ChargeComponent(
id=raw["id"],
kind=raw["kind"],
amount=_money(raw.get("amount")),
cadence=raw.get("cadence"),
meter=raw.get("meter"),
unit=raw.get("unit"),
unit_price=_money(raw.get("unit_price")),
included_units=_money(raw.get("included_units")),
label=raw.get("label"),
billing_treatment=raw.get("billing_treatment"),
metadata=_tuple_dict(raw.get("metadata")),
)
def _parse_commitment(raw: dict[str, Any]) -> Commitment:
return Commitment(
id=raw["id"],
kind=raw["kind"],
value=str(raw["value"]),
unit=raw.get("unit"),
description=raw.get("description", ""),
)
def _parse_tunable_parameter(raw: dict[str, Any]) -> TunableParameter:
return TunableParameter(
key=raw["key"],
parameter_class=raw["parameter_class"],
data_type=raw["data_type"],
description=raw.get("description", ""),
default_value=str(raw["default_value"]) if raw.get("default_value") is not None else None,
min_value=_money(raw.get("min_value")),
max_value=_money(raw.get("max_value")),
options=tuple(str(item) for item in raw.get("options", [])),
)
def _parse_assurance_claim(raw: dict[str, Any]) -> AssuranceClaim:
def integer_or_raw(value: Any) -> Any:
if isinstance(value, str):
try:
return int(value)
except ValueError:
return value
return value
return AssuranceClaim(
id=str(raw.get("id", "")),
kind=str(raw.get("kind", "")),
customer_wording=str(raw.get("customer_wording", "")),
minimum_levels={
str(axis): integer_or_raw(level)
for axis, level in raw.get("minimum_levels", {}).items()
},
delivering_service=str(raw.get("delivering_service", "")),
evidence_ref=str(raw.get("evidence_ref", "")),
maximum_erasure_horizon_days=(
integer_or_raw(raw["maximum_erasure_horizon_days"])
if raw.get("maximum_erasure_horizon_days") is not None
else None
),
provider_contract_ref=raw.get("provider_contract_ref"),
resource_governor_ref=raw.get("resource_governor_ref"),
erasure_mechanism=raw.get("erasure_mechanism"),
)
def _legacy_charge_components(raw: dict[str, Any]) -> list[dict[str, Any]]:
components: list[dict[str, Any]] = [
{
"id": f"{raw['id']}-access",
"kind": "access",
"amount": raw["access_fee_amount"],
"cadence": raw["access_fee_cadence"],
"label": "Recurring access fee",
"billing_treatment": "recurring",
"metadata": {"included_usage": raw.get("included_usage")},
}
]
if raw.get("model_type") == "hybrid_subscription_usage" or raw.get("overage_meter"):
components.append(
{
"id": f"{raw['id']}-usage",
"kind": "usage",
"meter": raw.get("overage_meter") or "usage",
"unit": raw.get("unit") or "usage_unit",
"included_units": raw.get("included_units") or raw.get("included_tokens"),
"unit_price": raw.get("unit_price") or raw.get("overage_unit_price"),
"label": "Variable usage component",
"billing_treatment": "metered",
"metadata": {"included_usage": raw.get("included_usage")},
}
)
return components
def _access_component(components: tuple[ChargeComponent, ...]) -> ChargeComponent | None:
return next((component for component in components if component.kind == "access"), None)
def _usage_component(components: tuple[ChargeComponent, ...]) -> ChargeComponent | None:
return next((component for component in components if component.kind == "usage"), None)
def _parse_pricing_model(raw: dict[str, Any]) -> PricingModel:
charge_components = tuple(
_parse_charge_component(item)
for item in (raw.get("charge_components") or _legacy_charge_components(raw))
)
access_component = _access_component(charge_components)
usage_component = _usage_component(charge_components)
access_fee_amount = _money(raw.get("access_fee_amount"))
if access_fee_amount is None:
access_fee_amount = access_component.amount if access_component else Decimal("0")
access_fee_cadence = raw.get("access_fee_cadence")
if access_fee_cadence is None:
access_fee_cadence = access_component.cadence if access_component else "monthly"
metadata = _tuple_dict(raw.get("metadata"))
included_usage = raw.get("included_usage") or metadata.get("included_usage")
if included_usage is None and access_component:
included_usage = access_component.metadata.get("included_usage")
if included_usage is None and usage_component:
included_usage = usage_component.metadata.get("included_usage")
overage_meter = raw.get("overage_meter") or (usage_component.meter if usage_component else None)
return PricingModel(
id=raw["id"],
name=raw["name"],
model_type=raw["model_type"],
lifecycle_phase=raw["lifecycle_phase"],
currency=raw["currency"],
access_fee_amount=access_fee_amount or Decimal("0"),
access_fee_cadence=access_fee_cadence or "monthly",
status=raw["status"],
description=raw.get("description", ""),
included_usage=included_usage,
overage_meter=overage_meter,
charge_components=charge_components,
commitments=tuple(
_parse_commitment(item) for item in raw.get("commitments", [])
),
tunable_parameters=tuple(
_parse_tunable_parameter(item) for item in raw.get("tunable_parameters", [])
),
assurance_claims=tuple(
_parse_assurance_claim(item) for item in raw.get("assurance_claims", [])
),
eligibility=tuple(str(item) for item in raw.get("eligibility", [])),
provider_hints=_tuple_dict(raw.get("provider_hints")),
metadata=metadata,
)
def load_pricing_models(path: str | Path) -> list[PricingModel]:
raw = _read_json(Path(path))
models = [_parse_pricing_model(item) for item in raw["models"]]
issues = validate_pricing_catalog(models)
if issues:
formatted = "; ".join(
f"{model_id}: {', '.join(model_issues)}"
for model_id, model_issues in sorted(issues.items())
)
raise ValueError(f"invalid pricing catalog: {formatted}")
return models
def validate_pricing_catalog(models: list[PricingModel]) -> dict[str, list[str]]:
issues: dict[str, list[str]] = {}
ids = [model.id for model in models]
if len(ids) != len(set(ids)):
issues.setdefault("__catalog__", []).append("duplicate model ids")
for model in models:
model_issues = validate_pricing_model(model)
if model_issues:
issues[model.id] = model_issues
return issues
def validate_pricing_model(model: PricingModel) -> list[str]:
issues: list[str] = []
if model.status not in {"active", "candidate", "retired"}:
issues.append(f"unsupported status '{model.status}'")
if model.access_fee_amount < Decimal("0"):
issues.append("access_fee_amount must be non-negative")
if not model.charge_components:
issues.append("at least one charge component is required")
component_ids = [component.id for component in model.charge_components]
if len(component_ids) != len(set(component_ids)):
issues.append("charge component ids must be unique")
access_components = [component for component in model.charge_components if component.kind == "access"]
if len(access_components) != 1:
issues.append("exactly one access charge component is required")
for component in model.charge_components:
if component.kind not in _ALLOWED_COMPONENT_KINDS:
issues.append(f"unsupported charge component kind '{component.kind}'")
if component.kind == "access":
if component.amount is None:
issues.append("access charge component must define amount")
if component.cadence is None:
issues.append("access charge component must define cadence")
if component.kind == "usage" and component.meter is None:
issues.append("usage charge component must define meter")
usage_components = [component for component in model.charge_components if component.kind == "usage"]
if model.model_type == "hybrid_subscription_usage" and not usage_components:
issues.append("hybrid_subscription_usage requires a usage charge component")
tunable_keys = [parameter.key for parameter in model.tunable_parameters]
if len(tunable_keys) != len(set(tunable_keys)):
issues.append("tunable parameter keys must be unique")
for parameter in model.tunable_parameters:
if parameter.parameter_class not in _ALLOWED_PARAMETER_CLASSES:
issues.append(f"unsupported parameter_class '{parameter.parameter_class}'")
if (
parameter.parameter_class == "customer_tunable"
and not parameter.options
and parameter.min_value is None
and parameter.max_value is None
):
issues.append(
f"customer_tunable parameter '{parameter.key}' must define bounds or options"
)
commitment_ids = [commitment.id for commitment in model.commitments]
if len(commitment_ids) != len(set(commitment_ids)):
issues.append("commitment ids must be unique")
claim_ids = [claim.id for claim in model.assurance_claims]
if len(claim_ids) != len(set(claim_ids)):
issues.append("assurance claim ids must be unique")
for claim in model.assurance_claims:
prefix = f"assurance claim '{claim.id or '<missing>'}'"
if not claim.id.strip():
issues.append("assurance claim id is required")
if claim.kind not in _ALLOWED_ASSURANCE_KINDS:
issues.append(f"{prefix} has unsupported kind '{claim.kind}'")
for field_name, value in (
("customer_wording", claim.customer_wording),
("delivering_service", claim.delivering_service),
("evidence_ref", claim.evidence_ref),
):
if not value.strip():
issues.append(f"{prefix} requires {field_name}")
if not claim.minimum_levels:
issues.append(f"{prefix} requires minimum_levels")
for axis, level in claim.minimum_levels.items():
if axis not in _AXIS_MAXIMUMS:
issues.append(f"{prefix} has unknown axis '{axis}'")
elif (
isinstance(level, bool)
or not isinstance(level, int)
or not 0 <= level <= _AXIS_MAXIMUMS[axis]
):
issues.append(f"{prefix} has invalid {axis} level {level!r}")
if (
claim.maximum_erasure_horizon_days is not None
and (
isinstance(claim.maximum_erasure_horizon_days, bool)
or not isinstance(claim.maximum_erasure_horizon_days, int)
or claim.maximum_erasure_horizon_days < 1
)
):
issues.append(f"{prefix} has an invalid maximum erasure horizon")
if (
claim.erasure_mechanism is not None
and claim.erasure_mechanism not in _ALLOWED_ERASURE_MECHANISMS
):
issues.append(f"{prefix} has unsupported erasure_mechanism")
return issues