diff --git a/adaptive_pricing_core/__init__.py b/adaptive_pricing_core/__init__.py new file mode 100644 index 0000000..bce172b --- /dev/null +++ b/adaptive_pricing_core/__init__.py @@ -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", +] diff --git a/adaptive_pricing_core/pricing_models.py b/adaptive_pricing_core/pricing_models.py new file mode 100644 index 0000000..f1acf6c --- /dev/null +++ b/adaptive_pricing_core/pricing_models.py @@ -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 diff --git a/docs/PricingModelSchema.md b/docs/PricingModelSchema.md new file mode 100644 index 0000000..4916ccd --- /dev/null +++ b/docs/PricingModelSchema.md @@ -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. diff --git a/projects/coulomb-pricing/data/pricing-models.json b/projects/coulomb-pricing/data/pricing-models.json index 5292c39..6ce0e70 100644 --- a/projects/coulomb-pricing/data/pricing-models.json +++ b/projects/coulomb-pricing/data/pricing-models.json @@ -7,10 +7,45 @@ "model_type": "flat_subscription", "lifecycle_phase": "growth", "currency": "EUR", + "description": "Current flat membership offer for Coulomb Social.", "access_fee_amount": "8.99", "access_fee_cadence": "monthly", "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", @@ -18,10 +53,71 @@ "model_type": "hybrid_subscription_usage", "lifecycle_phase": "exploration", "currency": "EUR", + "description": "Candidate model bundling recurring access with a monthly AI allowance.", "access_fee_amount": "8.99", "access_fee_cadence": "monthly", "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", @@ -29,11 +125,82 @@ "model_type": "hybrid_subscription_usage", "lifecycle_phase": "exploration", "currency": "EUR", + "description": "Candidate model pairing recurring access with included tokens and metered overage.", "access_fee_amount": "8.99", "access_fee_cadence": "monthly", "included_usage": "monthly_ai_credit_allowance", "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" + } } ] -} \ No newline at end of file +} diff --git a/projects/coulomb-pricing/observatory/_repo_root.py b/projects/coulomb-pricing/observatory/_repo_root.py new file mode 100644 index 0000000..a0cbf78 --- /dev/null +++ b/projects/coulomb-pricing/observatory/_repo_root.py @@ -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) diff --git a/projects/coulomb-pricing/observatory/load.py b/projects/coulomb-pricing/observatory/load.py index b8ec970..bc97e66 100644 --- a/projects/coulomb-pricing/observatory/load.py +++ b/projects/coulomb-pricing/observatory/load.py @@ -4,6 +4,10 @@ import json from decimal import Decimal 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 .models import ( 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]: - raw = _read_json((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"] - ] + return load_canonical_pricing_models((data_dir or default_data_dir()) / "pricing-models.json") def load_fx_rates(data_dir: Path | None = None) -> dict[str, Decimal]: @@ -150,4 +141,4 @@ def load_monthly_ledger(data_dir: Path | None = None) -> list[MonthlyPlatformCos def latest_period(monthly_costs: list[MonthlyPlatformCost]) -> str: - return max(item.period for item in monthly_costs) \ No newline at end of file + return max(item.period for item in monthly_costs) diff --git a/projects/coulomb-pricing/observatory/models.py b/projects/coulomb-pricing/observatory/models.py index b966d74..46a8c26 100644 --- a/projects/coulomb-pricing/observatory/models.py +++ b/projects/coulomb-pricing/observatory/models.py @@ -4,9 +4,20 @@ from dataclasses import dataclass from decimal import Decimal 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"] MemberStatus = Literal["active", "churned", "paused"] -PricingModelStatus = Literal["active", "candidate", "retired"] LiquidityStatus = Literal["burning", "neutral", "generating"] @@ -20,18 +31,6 @@ class Product: 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) class ExpenseRecord: id: str @@ -118,4 +117,4 @@ class LiquiditySummary: cumulative_net_liquidity: Decimal remaining_budget: Decimal liquidity_status: LiquidityStatus - months_tracked: int \ No newline at end of file + months_tracked: int diff --git a/projects/coulomb-pricing/tests/conftest.py b/projects/coulomb-pricing/tests/conftest.py index e6ac727..aaebfc4 100644 --- a/projects/coulomb-pricing/tests/conftest.py +++ b/projects/coulomb-pricing/tests/conftest.py @@ -3,4 +3,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent if str(ROOT) not in sys.path: - sys.path.insert(0, str(ROOT)) \ No newline at end of file + 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)) diff --git a/projects/coulomb-pricing/tests/test_pricing_model_schema.py b/projects/coulomb-pricing/tests/test_pricing_model_schema.py new file mode 100644 index 0000000..f499590 --- /dev/null +++ b/projects/coulomb-pricing/tests/test_pricing_model_schema.py @@ -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 diff --git a/workplans/ADAPTIVE-WP-0003-canonical-pricing-core-and-schema.md b/workplans/ADAPTIVE-WP-0003-canonical-pricing-core-and-schema.md index ba882cb..a565f61 100644 --- a/workplans/ADAPTIVE-WP-0003-canonical-pricing-core-and-schema.md +++ b/workplans/ADAPTIVE-WP-0003-canonical-pricing-core-and-schema.md @@ -4,7 +4,7 @@ type: workplan title: "Canonical pricing core and schema extraction" domain: financials repo: adaptive-pricing -status: ready +status: finished owner: codex topic_slug: helix-forge created: "2026-07-02" @@ -21,7 +21,7 @@ define the canonical pricing-model schema needed by the later milestones. ```task id: ADAPTIVE-WP-0003-T01 -status: todo +status: done priority: high state_hub_task_id: "c79647e3-f760-4a2f-ae45-a3532692a3d3" ``` @@ -34,7 +34,7 @@ eligibility, lifecycle metadata, and provider-mapping hooks. ```task id: ADAPTIVE-WP-0003-T02 -status: todo +status: done priority: high 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 id: ADAPTIVE-WP-0003-T03 -status: todo +status: done priority: high state_hub_task_id: "6fa1ee8f-27ae-45b3-bd98-ea2a4873c0c8" ``` @@ -61,7 +61,7 @@ commitment-backed models. ```task id: ADAPTIVE-WP-0003-T04 -status: todo +status: done priority: medium state_hub_task_id: "fed8cb44-4219-4b7f-b641-90d872ebc285" ``` @@ -74,7 +74,7 @@ existing dashboard behavior. ```task id: ADAPTIVE-WP-0003-T05 -status: todo +status: done priority: medium 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. Dependencies: `ADAPTIVE-WP-0002` completed baseline. -