generated from coulomb/repo-seed
Implement canonical pricing core and close WP-0003
This commit is contained in:
21
adaptive_pricing_core/__init__.py
Normal file
21
adaptive_pricing_core/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from .pricing_models import (
|
||||||
|
ChargeComponent,
|
||||||
|
Commitment,
|
||||||
|
PricingModel,
|
||||||
|
PricingModelStatus,
|
||||||
|
TunableParameter,
|
||||||
|
load_pricing_models,
|
||||||
|
validate_pricing_catalog,
|
||||||
|
validate_pricing_model,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChargeComponent",
|
||||||
|
"Commitment",
|
||||||
|
"PricingModel",
|
||||||
|
"PricingModelStatus",
|
||||||
|
"TunableParameter",
|
||||||
|
"load_pricing_models",
|
||||||
|
"validate_pricing_catalog",
|
||||||
|
"validate_pricing_model",
|
||||||
|
]
|
||||||
323
adaptive_pricing_core/pricing_models.py
Normal file
323
adaptive_pricing_core/pricing_models.py
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
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"]
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 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, ...] = ()
|
||||||
|
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 _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", [])
|
||||||
|
),
|
||||||
|
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")
|
||||||
|
|
||||||
|
return issues
|
||||||
113
docs/PricingModelSchema.md
Normal file
113
docs/PricingModelSchema.md
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
# Pricing Model Schema
|
||||||
|
|
||||||
|
Status: draft, implementation-facing.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines the canonical pricing-model schema now used by the
|
||||||
|
repository runtime. It is the implementation companion to the conceptual
|
||||||
|
vocabulary in `research/PricingOntology.md`.
|
||||||
|
|
||||||
|
The schema is designed to:
|
||||||
|
|
||||||
|
- preserve compatibility with the Coulomb observatory MVP
|
||||||
|
- represent richer pricing structures than a single subscription amount
|
||||||
|
- support later validation, solver, and provider-publication milestones
|
||||||
|
|
||||||
|
## Model Shape
|
||||||
|
|
||||||
|
Each pricing model contains:
|
||||||
|
|
||||||
|
- identity and lifecycle metadata
|
||||||
|
- normalized recurring access-fee fields for compatibility
|
||||||
|
- explicit charge components
|
||||||
|
- commitments
|
||||||
|
- tunable parameters
|
||||||
|
- eligibility and provider hints
|
||||||
|
- free-form metadata for deployment-specific details
|
||||||
|
|
||||||
|
## Canonical Fields
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
model_type: flat_subscription | hybrid_subscription_usage | ...
|
||||||
|
lifecycle_phase: exploration | introduction | growth | maturity | saturation | decline
|
||||||
|
currency: EUR | USD | ...
|
||||||
|
status: active | candidate | retired
|
||||||
|
description: string
|
||||||
|
|
||||||
|
# Compatibility fields derived from the access component when omitted
|
||||||
|
access_fee_amount: decimal
|
||||||
|
access_fee_cadence: monthly | annual | one_time | ...
|
||||||
|
included_usage: string | null
|
||||||
|
overage_meter: string | null
|
||||||
|
|
||||||
|
charge_components:
|
||||||
|
- id: string
|
||||||
|
kind: access | setup | usage | support | discount | risk_adjustment
|
||||||
|
amount: decimal | null
|
||||||
|
cadence: string | null
|
||||||
|
meter: string | null
|
||||||
|
unit: string | null
|
||||||
|
unit_price: decimal | null
|
||||||
|
included_units: decimal | null
|
||||||
|
label: string | null
|
||||||
|
billing_treatment: recurring | metered | included | one_time | ...
|
||||||
|
metadata: {}
|
||||||
|
|
||||||
|
commitments:
|
||||||
|
- id: string
|
||||||
|
kind: minimum_turnover | contract_duration | prepayment | committed_usage | ...
|
||||||
|
value: string
|
||||||
|
unit: string | null
|
||||||
|
description: string
|
||||||
|
|
||||||
|
tunable_parameters:
|
||||||
|
- key: string
|
||||||
|
parameter_class: fixed | seller_controlled | customer_tunable | calculated | constrained | provider
|
||||||
|
data_type: string
|
||||||
|
description: string
|
||||||
|
default_value: string | null
|
||||||
|
min_value: decimal | null
|
||||||
|
max_value: decimal | null
|
||||||
|
options: []
|
||||||
|
|
||||||
|
eligibility:
|
||||||
|
- string
|
||||||
|
|
||||||
|
provider_hints: {}
|
||||||
|
metadata: {}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameter Classes
|
||||||
|
|
||||||
|
- `fixed`: immutable in the selected model
|
||||||
|
- `seller_controlled`: adjustable only by the seller or internal workflow
|
||||||
|
- `customer_tunable`: intended to become solver-visible customer choice
|
||||||
|
- `calculated`: derived from other fields or economics
|
||||||
|
- `constrained`: externally set but bounded by validation rules
|
||||||
|
- `provider`: implementation-only parameter for execution backends
|
||||||
|
|
||||||
|
## Validation Rules
|
||||||
|
|
||||||
|
Current runtime validation enforces:
|
||||||
|
|
||||||
|
- model ids are unique
|
||||||
|
- charge component ids are unique within a model
|
||||||
|
- exactly one `access` charge component exists
|
||||||
|
- access components define amount and cadence
|
||||||
|
- usage components define a meter
|
||||||
|
- `hybrid_subscription_usage` models include a usage charge component
|
||||||
|
- tunable parameter keys are unique
|
||||||
|
- `customer_tunable` parameters declare bounds or enumerated options
|
||||||
|
- commitment ids are unique
|
||||||
|
|
||||||
|
## Transitional Compatibility
|
||||||
|
|
||||||
|
The Coulomb observatory still consumes `access_fee_amount`, `access_fee_cadence`,
|
||||||
|
`included_usage`, and `overage_meter`. The canonical loader back-fills these
|
||||||
|
from `charge_components` when the explicit top-level fields are omitted.
|
||||||
|
|
||||||
|
This keeps the current observatory stable while later milestones replace
|
||||||
|
hard-coded observatory assumptions with generic pricing-core behavior.
|
||||||
@@ -7,10 +7,45 @@
|
|||||||
"model_type": "flat_subscription",
|
"model_type": "flat_subscription",
|
||||||
"lifecycle_phase": "growth",
|
"lifecycle_phase": "growth",
|
||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
|
"description": "Current flat membership offer for Coulomb Social.",
|
||||||
"access_fee_amount": "8.99",
|
"access_fee_amount": "8.99",
|
||||||
"access_fee_cadence": "monthly",
|
"access_fee_cadence": "monthly",
|
||||||
"included_usage": "unlimited_repository_access",
|
"included_usage": "unlimited_repository_access",
|
||||||
"status": "active"
|
"status": "active",
|
||||||
|
"charge_components": [
|
||||||
|
{
|
||||||
|
"id": "membership-access",
|
||||||
|
"kind": "access",
|
||||||
|
"amount": "8.99",
|
||||||
|
"cadence": "monthly",
|
||||||
|
"label": "Standard membership access fee",
|
||||||
|
"billing_treatment": "recurring",
|
||||||
|
"metadata": {
|
||||||
|
"included_usage": "unlimited_repository_access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"commitments": [
|
||||||
|
{
|
||||||
|
"id": "baseline-term",
|
||||||
|
"kind": "contract_duration",
|
||||||
|
"value": "1",
|
||||||
|
"unit": "month",
|
||||||
|
"description": "Baseline self-serve monthly term."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tunable_parameters": [],
|
||||||
|
"eligibility": [
|
||||||
|
"coulomb-social-members"
|
||||||
|
],
|
||||||
|
"provider_hints": {
|
||||||
|
"stripe": {
|
||||||
|
"collection_method": "charge_automatically"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"catalog_version": "canonical-v1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "membership-plus-credits",
|
"id": "membership-plus-credits",
|
||||||
@@ -18,10 +53,71 @@
|
|||||||
"model_type": "hybrid_subscription_usage",
|
"model_type": "hybrid_subscription_usage",
|
||||||
"lifecycle_phase": "exploration",
|
"lifecycle_phase": "exploration",
|
||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
|
"description": "Candidate model bundling recurring access with a monthly AI allowance.",
|
||||||
"access_fee_amount": "8.99",
|
"access_fee_amount": "8.99",
|
||||||
"access_fee_cadence": "monthly",
|
"access_fee_cadence": "monthly",
|
||||||
"included_usage": "monthly_ai_credit_allowance",
|
"included_usage": "monthly_ai_credit_allowance",
|
||||||
"status": "candidate"
|
"status": "candidate",
|
||||||
|
"charge_components": [
|
||||||
|
{
|
||||||
|
"id": "membership-access",
|
||||||
|
"kind": "access",
|
||||||
|
"amount": "8.99",
|
||||||
|
"cadence": "monthly",
|
||||||
|
"label": "Membership base fee",
|
||||||
|
"billing_treatment": "recurring"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ai-credit-allowance",
|
||||||
|
"kind": "usage",
|
||||||
|
"meter": "openrouter_tokens",
|
||||||
|
"unit": "tokens",
|
||||||
|
"included_units": "100000",
|
||||||
|
"label": "Included monthly AI token allowance",
|
||||||
|
"billing_treatment": "included",
|
||||||
|
"metadata": {
|
||||||
|
"included_usage": "monthly_ai_credit_allowance"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"commitments": [
|
||||||
|
{
|
||||||
|
"id": "credit-prepay-window",
|
||||||
|
"kind": "prepayment",
|
||||||
|
"value": "1",
|
||||||
|
"unit": "month",
|
||||||
|
"description": "Allowance resets monthly in the observatory prototype."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tunable_parameters": [
|
||||||
|
{
|
||||||
|
"key": "included_tokens",
|
||||||
|
"parameter_class": "seller_controlled",
|
||||||
|
"data_type": "integer",
|
||||||
|
"description": "Included OpenRouter token allowance for the monthly bundle.",
|
||||||
|
"default_value": "100000",
|
||||||
|
"min_value": "50000",
|
||||||
|
"max_value": "500000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "monthly_allowance_eur",
|
||||||
|
"parameter_class": "calculated",
|
||||||
|
"data_type": "decimal",
|
||||||
|
"description": "Observatory-only euro allowance derived from provider usage cost.",
|
||||||
|
"default_value": "2.00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"eligibility": [
|
||||||
|
"coulomb-social-members"
|
||||||
|
],
|
||||||
|
"provider_hints": {
|
||||||
|
"stripe": {
|
||||||
|
"metered_usage_strategy": "future_adapter"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"catalog_version": "canonical-v1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "membership-plus-overage",
|
"id": "membership-plus-overage",
|
||||||
@@ -29,11 +125,82 @@
|
|||||||
"model_type": "hybrid_subscription_usage",
|
"model_type": "hybrid_subscription_usage",
|
||||||
"lifecycle_phase": "exploration",
|
"lifecycle_phase": "exploration",
|
||||||
"currency": "EUR",
|
"currency": "EUR",
|
||||||
|
"description": "Candidate model pairing recurring access with included tokens and metered overage.",
|
||||||
"access_fee_amount": "8.99",
|
"access_fee_amount": "8.99",
|
||||||
"access_fee_cadence": "monthly",
|
"access_fee_cadence": "monthly",
|
||||||
"included_usage": "monthly_ai_credit_allowance",
|
"included_usage": "monthly_ai_credit_allowance",
|
||||||
"overage_meter": "openrouter_tokens",
|
"overage_meter": "openrouter_tokens",
|
||||||
"status": "candidate"
|
"status": "candidate",
|
||||||
|
"charge_components": [
|
||||||
|
{
|
||||||
|
"id": "membership-access",
|
||||||
|
"kind": "access",
|
||||||
|
"amount": "8.99",
|
||||||
|
"cadence": "monthly",
|
||||||
|
"label": "Membership base fee",
|
||||||
|
"billing_treatment": "recurring"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ai-overage-usage",
|
||||||
|
"kind": "usage",
|
||||||
|
"meter": "openrouter_tokens",
|
||||||
|
"unit": "tokens",
|
||||||
|
"included_units": "100000",
|
||||||
|
"unit_price": "0.002",
|
||||||
|
"label": "OpenRouter token overage",
|
||||||
|
"billing_treatment": "metered",
|
||||||
|
"metadata": {
|
||||||
|
"included_usage": "monthly_ai_credit_allowance"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"commitments": [
|
||||||
|
{
|
||||||
|
"id": "baseline-term",
|
||||||
|
"kind": "contract_duration",
|
||||||
|
"value": "1",
|
||||||
|
"unit": "month",
|
||||||
|
"description": "Baseline monthly term; solver can later trade this against usage economics."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tunable_parameters": [
|
||||||
|
{
|
||||||
|
"key": "included_tokens",
|
||||||
|
"parameter_class": "customer_tunable",
|
||||||
|
"data_type": "integer",
|
||||||
|
"description": "Customer-selectable included token allowance within seller-approved bounds.",
|
||||||
|
"default_value": "100000",
|
||||||
|
"min_value": "50000",
|
||||||
|
"max_value": "300000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "contract_duration_months",
|
||||||
|
"parameter_class": "customer_tunable",
|
||||||
|
"data_type": "integer",
|
||||||
|
"description": "Longer term can support improved usage pricing in later solver milestones.",
|
||||||
|
"default_value": "1",
|
||||||
|
"min_value": "1",
|
||||||
|
"max_value": "12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "overage_unit_price",
|
||||||
|
"parameter_class": "calculated",
|
||||||
|
"data_type": "decimal",
|
||||||
|
"description": "Current observatory overage rate derived from scenario assumptions.",
|
||||||
|
"default_value": "0.002"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"eligibility": [
|
||||||
|
"coulomb-social-members"
|
||||||
|
],
|
||||||
|
"provider_hints": {
|
||||||
|
"stripe": {
|
||||||
|
"meter_name": "openrouter_tokens"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"catalog_version": "canonical-v1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
12
projects/coulomb-pricing/observatory/_repo_root.py
Normal file
12
projects/coulomb-pricing/observatory/_repo_root.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_repo_root_on_syspath() -> None:
|
||||||
|
root = str(REPO_ROOT)
|
||||||
|
if root not in sys.path:
|
||||||
|
sys.path.insert(0, root)
|
||||||
@@ -4,6 +4,10 @@ import json
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ._repo_root import ensure_repo_root_on_syspath
|
||||||
|
ensure_repo_root_on_syspath()
|
||||||
|
|
||||||
|
from adaptive_pricing_core.pricing_models import load_pricing_models as load_canonical_pricing_models
|
||||||
from .ledger import build_monthly_ledger
|
from .ledger import build_monthly_ledger
|
||||||
from .models import (
|
from .models import (
|
||||||
Budget,
|
Budget,
|
||||||
@@ -50,20 +54,7 @@ def load_budget(data_dir: Path | None = None) -> Budget:
|
|||||||
|
|
||||||
|
|
||||||
def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]:
|
def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]:
|
||||||
raw = _read_json((data_dir or default_data_dir()) / "pricing-models.json")
|
return load_canonical_pricing_models((data_dir or default_data_dir()) / "pricing-models.json")
|
||||||
return [
|
|
||||||
PricingModel(
|
|
||||||
id=item["id"],
|
|
||||||
name=item["name"],
|
|
||||||
model_type=item["model_type"],
|
|
||||||
lifecycle_phase=item["lifecycle_phase"],
|
|
||||||
currency=item["currency"],
|
|
||||||
access_fee_amount=_money(item["access_fee_amount"]),
|
|
||||||
access_fee_cadence=item["access_fee_cadence"],
|
|
||||||
status=item["status"],
|
|
||||||
)
|
|
||||||
for item in raw["models"]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_fx_rates(data_dir: Path | None = None) -> dict[str, Decimal]:
|
def load_fx_rates(data_dir: Path | None = None) -> dict[str, Decimal]:
|
||||||
|
|||||||
@@ -4,9 +4,20 @@ from dataclasses import dataclass
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
|
from ._repo_root import ensure_repo_root_on_syspath
|
||||||
|
|
||||||
|
ensure_repo_root_on_syspath()
|
||||||
|
|
||||||
|
from adaptive_pricing_core.pricing_models import ( # noqa: E402
|
||||||
|
ChargeComponent,
|
||||||
|
Commitment,
|
||||||
|
PricingModel,
|
||||||
|
PricingModelStatus,
|
||||||
|
TunableParameter,
|
||||||
|
)
|
||||||
|
|
||||||
ExpenseClass = Literal["infrastructure", "payment_processing"]
|
ExpenseClass = Literal["infrastructure", "payment_processing"]
|
||||||
MemberStatus = Literal["active", "churned", "paused"]
|
MemberStatus = Literal["active", "churned", "paused"]
|
||||||
PricingModelStatus = Literal["active", "candidate", "retired"]
|
|
||||||
LiquidityStatus = Literal["burning", "neutral", "generating"]
|
LiquidityStatus = Literal["burning", "neutral", "generating"]
|
||||||
|
|
||||||
|
|
||||||
@@ -20,18 +31,6 @@ class Product:
|
|||||||
active_pricing_model_id: str
|
active_pricing_model_id: str
|
||||||
|
|
||||||
|
|
||||||
@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
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ExpenseRecord:
|
class ExpenseRecord:
|
||||||
id: str
|
id: str
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ from pathlib import Path
|
|||||||
ROOT = Path(__file__).resolve().parent.parent
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
if str(ROOT) not in sys.path:
|
if str(ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
REPO_ROOT = ROOT.parent.parent
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|||||||
35
projects/coulomb-pricing/tests/test_pricing_model_schema.py
Normal file
35
projects/coulomb-pricing/tests/test_pricing_model_schema.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from adaptive_pricing_core.pricing_models import validate_pricing_catalog
|
||||||
|
from observatory.load import load_pricing_models
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||||
|
|
||||||
|
|
||||||
|
def test_coulomb_pricing_catalog_validates() -> None:
|
||||||
|
models = load_pricing_models(DATA_DIR)
|
||||||
|
assert validate_pricing_catalog(models) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_hybrid_model_preserves_usage_component_and_tuning_metadata() -> None:
|
||||||
|
models = load_pricing_models(DATA_DIR)
|
||||||
|
model = next(item for item in models if item.id == "membership-plus-overage")
|
||||||
|
|
||||||
|
usage_component = next(component for component in model.charge_components if component.kind == "usage")
|
||||||
|
|
||||||
|
assert usage_component.meter == "openrouter_tokens"
|
||||||
|
assert usage_component.included_units == Decimal("100000")
|
||||||
|
assert usage_component.unit_price == Decimal("0.002")
|
||||||
|
assert any(parameter.parameter_class == "customer_tunable" for parameter in model.tunable_parameters)
|
||||||
|
|
||||||
|
|
||||||
|
def test_flat_model_still_exposes_access_fee_compatibility_fields() -> None:
|
||||||
|
models = load_pricing_models(DATA_DIR)
|
||||||
|
model = next(item for item in models if item.id == "flat-899-eur-monthly")
|
||||||
|
|
||||||
|
assert model.access_fee_amount == Decimal("8.99")
|
||||||
|
assert model.access_fee_cadence == "monthly"
|
||||||
|
assert len(model.charge_components) == 1
|
||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Canonical pricing core and schema extraction"
|
title: "Canonical pricing core and schema extraction"
|
||||||
domain: financials
|
domain: financials
|
||||||
repo: adaptive-pricing
|
repo: adaptive-pricing
|
||||||
status: ready
|
status: finished
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: helix-forge
|
topic_slug: helix-forge
|
||||||
created: "2026-07-02"
|
created: "2026-07-02"
|
||||||
@@ -21,7 +21,7 @@ define the canonical pricing-model schema needed by the later milestones.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: ADAPTIVE-WP-0003-T01
|
id: ADAPTIVE-WP-0003-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c79647e3-f760-4a2f-ae45-a3532692a3d3"
|
state_hub_task_id: "c79647e3-f760-4a2f-ae45-a3532692a3d3"
|
||||||
```
|
```
|
||||||
@@ -34,7 +34,7 @@ eligibility, lifecycle metadata, and provider-mapping hooks.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: ADAPTIVE-WP-0003-T02
|
id: ADAPTIVE-WP-0003-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "2d27616b-ea0c-4600-b7a2-eb5a22932b58"
|
state_hub_task_id: "2d27616b-ea0c-4600-b7a2-eb5a22932b58"
|
||||||
```
|
```
|
||||||
@@ -48,7 +48,7 @@ first adopter of the extracted core rather than leaving the core trapped inside
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: ADAPTIVE-WP-0003-T03
|
id: ADAPTIVE-WP-0003-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "6fa1ee8f-27ae-45b3-bd98-ea2a4873c0c8"
|
state_hub_task_id: "6fa1ee8f-27ae-45b3-bd98-ea2a4873c0c8"
|
||||||
```
|
```
|
||||||
@@ -61,7 +61,7 @@ commitment-backed models.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: ADAPTIVE-WP-0003-T04
|
id: ADAPTIVE-WP-0003-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "fed8cb44-4219-4b7f-b641-90d872ebc285"
|
state_hub_task_id: "fed8cb44-4219-4b7f-b641-90d872ebc285"
|
||||||
```
|
```
|
||||||
@@ -74,7 +74,7 @@ existing dashboard behavior.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: ADAPTIVE-WP-0003-T05
|
id: ADAPTIVE-WP-0003-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "77305962-6f56-44aa-a8e4-32cfc5de262b"
|
state_hub_task_id: "77305962-6f56-44aa-a8e4-32cfc5de262b"
|
||||||
```
|
```
|
||||||
@@ -83,4 +83,3 @@ Exit when the repo has a documented canonical schema, a validator with tests,
|
|||||||
and a working Coulomb observatory instance backed by the new internal model.
|
and a working Coulomb observatory instance backed by the new internal model.
|
||||||
|
|
||||||
Dependencies: `ADAPTIVE-WP-0002` completed baseline.
|
Dependencies: `ADAPTIVE-WP-0002` completed baseline.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user