Implement ADAPTIVE-WP-0002 Sprint 1 economic foundations

Add Coulomb observatory package with JSON registries (product, pricing
models, costs, revenue, membership), economics snapshot engine, Economics
Dashboard v1 CLI, sample report, and pytest coverage. Complete T01 and
queue Sprint 2 Bubble.io integration.
This commit is contained in:
2026-06-22 01:32:48 +02:00
parent d648a3263d
commit a1a4aa972f
17 changed files with 709 additions and 19 deletions

View File

@@ -13,24 +13,24 @@
## Dev Workflow
The repository is in an **early framework phase**: Markdown documentation, research
notes, and capability registry YAML. No application runtime, package manifest, or
automated test suite exists yet. Executable implementation begins under
`workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`.
Framework docs live at the repo root. The Coulomb MVP implementation lives in
`projects/coulomb-pricing/observatory/` (stdlib Python 3.11+, no runtime deps;
`pytest` for tests). Run MVP commands from `projects/coulomb-pricing/`.
| Need | Command |
|------|---------|
| Install | none — no runtime dependencies |
| Test | none configured yet |
| Lint / format | none configured — match surrounding Markdown style |
| Build | none — documentation-only repo |
| Run | none |
| Python | `python3` (3.11+) |
| Install | none — stdlib only; for tests: `pip install pytest` |
| Test (MVP) | `cd projects/coulomb-pricing && python3 -m pytest -q` |
| Test (repo) | same as MVP until other packages exist |
| Lint / format | none configured — match surrounding style |
| Build | none |
| Run: economics dashboard | `cd projects/coulomb-pricing && python3 -m observatory --period YYYY-MM` |
| Workplan / hub sync | `cd ~/state-hub && make fix-consistency REPO=adaptive-pricing REPO_PATH=~/adaptive-pricing` |
| Registry sanity | `grep -q '^version:' registry/indexes/capabilities.yaml && echo OK` |
**Verify a change before declaring it done:** run `make fix-consistency` (expect
PASS), and confirm edited docs stay aligned with `INTENT.md` and
`docs/ProductRequirementsDocument.md`.
**Verify a change before declaring it done:** run `python3 -m pytest` under
`projects/coulomb-pricing`, then `make fix-consistency` (expect PASS).
---

View File

@@ -2,11 +2,31 @@
Project-specific material for the Coulomb Social Economic Observatory MVP.
This directory holds implementation artifacts, integrations, and documentation that
apply to the Coulomb deployment only. Generic adaptive-pricing framework concepts
belong in the repository root (`INTENT.md`, `docs/`, `research/`, `registry/`).
Generic adaptive-pricing framework concepts belong in the repository root
(`INTENT.md`, `docs/`, `research/`, `registry/`). Execution tracking:
`workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`.
**Execution tracking:** `workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`
## Sprint 1 — Economic Foundations
**Strategic positioning:** Adaptive Pricing is the pricing capability layer for
Coulomb offerings and related product ecosystems.
The `observatory/` package loads JSON registries and produces **Economics
Dashboard v1** metrics:
| Registry | File |
|----------|------|
| Product model | `data/product.json` |
| Pricing models | `data/pricing-models.json` |
| Costs | `data/costs.json` |
| Revenue | `data/revenue.json` |
| Membership | `data/membership.json` |
### Commands
```bash
cd projects/coulomb-pricing
python3 -m pytest -q
python3 -m observatory --period 2026-06
python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md
```
Seed data is used until Bubble (Sprint 2) and Stripe (Sprint 3) importers
replace manual entries.

View File

@@ -0,0 +1,64 @@
{
"version": 1,
"period": "2026-06",
"entries": [
{
"id": "bubble-subscription",
"name": "Bubble.io platform",
"category": "fixed",
"amount": "35.00",
"currency": "USD",
"cadence": "monthly",
"allocation": "flat"
},
{
"id": "domains",
"name": "Domains",
"category": "fixed",
"amount": "15.00",
"currency": "EUR",
"cadence": "monthly",
"allocation": "flat"
},
{
"id": "operational-overhead",
"name": "Operational overhead",
"category": "fixed",
"amount": "25.00",
"currency": "EUR",
"cadence": "monthly",
"allocation": "flat"
},
{
"id": "stripe-percentage",
"name": "Stripe percentage fee",
"category": "variable",
"amount": "0.015",
"currency": "ratio",
"cadence": "per_transaction",
"allocation": "percent_of_gross_revenue"
},
{
"id": "stripe-fixed",
"name": "Stripe fixed fee",
"category": "variable",
"amount": "0.25",
"currency": "EUR",
"cadence": "per_transaction",
"allocation": "per_active_member"
},
{
"id": "openrouter-ai",
"name": "OpenRouter AI consumption",
"category": "variable",
"amount": "0.00",
"currency": "EUR",
"cadence": "monthly",
"allocation": "flat",
"note": "Populated in Sprint 4 after usage import"
}
],
"fx_rates": {
"USD/EUR": "0.92"
}
}

View File

@@ -0,0 +1,40 @@
{
"version": 1,
"snapshot_date": "2026-06-21",
"members": [
{
"id": "member-001",
"external_id": null,
"status": "active",
"joined_at": "2025-11-03",
"plan_id": "flat-899-eur-monthly",
"source": "seed"
},
{
"id": "member-002",
"external_id": null,
"status": "active",
"joined_at": "2026-01-15",
"plan_id": "flat-899-eur-monthly",
"source": "seed"
},
{
"id": "member-003",
"external_id": null,
"status": "active",
"joined_at": "2026-03-28",
"plan_id": "flat-899-eur-monthly",
"source": "seed"
},
{
"id": "member-004",
"external_id": null,
"status": "churned",
"joined_at": "2025-08-10",
"churned_at": "2026-02-01",
"plan_id": "flat-899-eur-monthly",
"source": "seed"
}
],
"note": "Seed data for Sprint 1; replaced by Bubble importer in Sprint 2"
}

View File

@@ -0,0 +1,39 @@
{
"version": 1,
"models": [
{
"id": "flat-899-eur-monthly",
"name": "Standard Membership",
"model_type": "flat_subscription",
"lifecycle_phase": "growth",
"currency": "EUR",
"access_fee_amount": "8.99",
"access_fee_cadence": "monthly",
"included_usage": "unlimited_repository_access",
"status": "active"
},
{
"id": "membership-plus-credits",
"name": "Membership + AI Credits",
"model_type": "hybrid_subscription_usage",
"lifecycle_phase": "exploration",
"currency": "EUR",
"access_fee_amount": "8.99",
"access_fee_cadence": "monthly",
"included_usage": "monthly_ai_credit_allowance",
"status": "candidate"
},
{
"id": "membership-plus-overage",
"name": "Membership + Overage",
"model_type": "hybrid_subscription_usage",
"lifecycle_phase": "exploration",
"currency": "EUR",
"access_fee_amount": "8.99",
"access_fee_cadence": "monthly",
"included_usage": "monthly_ai_credit_allowance",
"overage_meter": "openrouter_tokens",
"status": "candidate"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"id": "coulomb-social-membership",
"name": "Coulomb Social Membership",
"lifecycle_phase": "growth",
"currency": "EUR",
"description": "Repository and community access via monthly subscription.",
"active_pricing_model_id": "flat-899-eur-monthly",
"channels": {
"membership_platform": "bubble.io",
"payments": "stripe"
}
}

View File

@@ -0,0 +1,16 @@
{
"version": 1,
"entries": [
{
"id": "rev-2026-06-estimate",
"period": "2026-06",
"gross_amount": "80.91",
"fees_amount": "2.46",
"refunds_amount": "0.00",
"net_amount": "78.45",
"currency": "EUR",
"source": "manual_estimate",
"note": "Seed estimate for Sprint 1; replaced by Stripe sync in Sprint 3"
}
]
}

View File

@@ -0,0 +1,3 @@
"""Coulomb Social Economic Observatory — Sprint 1 foundations."""
__version__ = "0.1.0"

View File

@@ -0,0 +1,3 @@
from .dashboard import main
raise SystemExit(main())

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
import argparse
from pathlib import Path
from .economics import build_snapshot
from .load import (
default_data_dir,
load_costs,
load_membership,
load_pricing_models,
load_product,
load_revenue,
)
from .models import EconomicsSnapshot, PricingModel, Product
def render_dashboard(
product: Product,
models: list[PricingModel],
snapshot: EconomicsSnapshot,
) -> str:
active = next(m for m in models if m.id == product.active_pricing_model_id)
registry_lines = "\n".join(
f"| {model.id} | {model.name} | {model.model_type} | {model.status} |"
for model in models
)
return f"""# Economics Dashboard v1 — {product.name}
**Period:** {snapshot.period}
**Lifecycle phase:** {product.lifecycle_phase}
**Active pricing model:** {active.name} ({active.access_fee_amount} {active.currency}/{active.access_fee_cadence})
## Key Metrics
| Metric | Value |
|--------|------:|
| Active members | {snapshot.active_members} |
| Monthly revenue | {snapshot.monthly_revenue} {snapshot.currency} |
| Monthly cost | {snapshot.monthly_cost} {snapshot.currency} |
| Cost per member | {snapshot.cost_per_member} {snapshot.currency} |
| Gross margin | {snapshot.gross_margin} {snapshot.currency} |
| Gross margin % | {snapshot.gross_margin_pct}% |
_Revenue source: {snapshot.revenue_source}_
## Pricing Model Registry
| ID | Name | Type | Status |
|----|------|------|--------|
{registry_lines}
## Registries Loaded
- Product model (`data/product.json`)
- Pricing model registry (`data/pricing-models.json`)
- Cost registry (`data/costs.json`)
- Revenue registry (`data/revenue.json`)
- Membership registry (`data/membership.json`)
"""
def generate_dashboard(data_dir: Path | None = None, period: str | None = None) -> str:
root = data_dir or default_data_dir()
product = load_product(root)
models = load_pricing_models(root)
members = load_membership(root)
revenue = load_revenue(root)
cost_period, costs, fx_rates = load_costs(root)
target_period = period or cost_period
snapshot = build_snapshot(
target_period,
product,
models,
members,
revenue,
costs,
fx_rates,
)
return render_dashboard(product, models, snapshot)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Coulomb Social Economics Dashboard v1")
parser.add_argument("--data-dir", type=Path, default=None, help="Registry data directory")
parser.add_argument("--period", default=None, help="Reporting period (YYYY-MM)")
parser.add_argument(
"--output",
type=Path,
default=None,
help="Write Markdown report to this path (default: stdout only)",
)
args = parser.parse_args(argv)
report = generate_dashboard(args.data_dir, args.period)
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(report, encoding="utf-8")
print(f"Wrote {args.output}")
else:
print(report)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from .models import (
CostEntry,
EconomicsSnapshot,
MembershipRecord,
PricingModel,
Product,
RevenueEntry,
)
TWOPLACES = Decimal("0.01")
PCTPLACES = Decimal("0.1")
def _quantize(value: Decimal, exp: Decimal = TWOPLACES) -> Decimal:
return value.quantize(exp, rounding=ROUND_HALF_UP)
def _convert_to_eur(amount: Decimal, currency: str, fx_rates: dict[str, Decimal]) -> Decimal:
if currency == "EUR":
return amount
if currency == "USD":
return amount * fx_rates.get("USD/EUR", Decimal("1"))
if currency == "ratio":
return amount
raise ValueError(f"unsupported currency for conversion: {currency}")
def active_members(members: list[MembershipRecord]) -> int:
return sum(1 for member in members if member.status == "active")
def active_pricing_model(models: list[PricingModel], product: Product) -> PricingModel:
for model in models:
if model.id == product.active_pricing_model_id:
return model
raise ValueError(f"active pricing model not found: {product.active_pricing_model_id}")
def estimate_monthly_revenue(
period: str,
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
revenue_entries: list[RevenueEntry],
) -> tuple[Decimal, str]:
for entry in revenue_entries:
if entry.period == period and entry.currency == product.currency:
return entry.gross_amount, entry.source
model = active_pricing_model(models, product)
count = active_members(members)
return model.access_fee_amount * count, "derived_from_membership"
def monthly_cost_total(
cost_entries: list[CostEntry],
fx_rates: dict[str, Decimal],
gross_revenue: Decimal,
member_count: int,
) -> Decimal:
total = Decimal("0")
for entry in cost_entries:
if entry.allocation == "flat":
total += _convert_to_eur(entry.amount, entry.currency, fx_rates)
elif entry.allocation == "percent_of_gross_revenue":
total += gross_revenue * entry.amount
elif entry.allocation == "per_active_member":
total += _convert_to_eur(entry.amount, entry.currency, fx_rates) * member_count
return _quantize(total)
def build_snapshot(
period: str,
product: Product,
models: list[PricingModel],
members: list[MembershipRecord],
revenue_entries: list[RevenueEntry],
cost_entries: list[CostEntry],
fx_rates: dict[str, Decimal],
) -> EconomicsSnapshot:
count = active_members(members)
gross_revenue, revenue_source = estimate_monthly_revenue(
period, product, models, members, revenue_entries
)
monthly_cost = monthly_cost_total(cost_entries, fx_rates, gross_revenue, count)
cost_per_member = _quantize(monthly_cost / count) if count else Decimal("0.00")
gross_margin = _quantize(gross_revenue - monthly_cost)
margin_pct = (
_quantize((gross_margin / gross_revenue) * Decimal("100"), PCTPLACES)
if gross_revenue
else Decimal("0.0")
)
return EconomicsSnapshot(
period=period,
currency=product.currency,
active_members=count,
monthly_revenue=_quantize(gross_revenue),
monthly_cost=monthly_cost,
cost_per_member=cost_per_member,
gross_margin=gross_margin,
gross_margin_pct=margin_pct,
pricing_model_count=len(models),
revenue_source=revenue_source,
)

View File

@@ -0,0 +1,103 @@
from __future__ import annotations
import json
from decimal import Decimal
from pathlib import Path
from .models import (
CostEntry,
MembershipRecord,
PricingModel,
Product,
RevenueEntry,
)
def _money(value: str | int | float) -> Decimal:
return Decimal(str(value))
def _read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def default_data_dir() -> Path:
return Path(__file__).resolve().parent.parent / "data"
def load_product(data_dir: Path | None = None) -> Product:
raw = _read_json((data_dir or default_data_dir()) / "product.json")
return Product(
id=raw["id"],
name=raw["name"],
lifecycle_phase=raw["lifecycle_phase"],
currency=raw["currency"],
description=raw["description"],
active_pricing_model_id=raw["active_pricing_model_id"],
)
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"]
]
def load_costs(data_dir: Path | None = None) -> tuple[str, list[CostEntry], dict[str, Decimal]]:
raw = _read_json((data_dir or default_data_dir()) / "costs.json")
entries = [
CostEntry(
id=item["id"],
name=item["name"],
category=item["category"],
amount=_money(item["amount"]),
currency=item["currency"],
cadence=item["cadence"],
allocation=item["allocation"],
)
for item in raw["entries"]
]
fx = {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()}
return raw.get("period", ""), entries, fx
def load_revenue(data_dir: Path | None = None) -> list[RevenueEntry]:
raw = _read_json((data_dir or default_data_dir()) / "revenue.json")
return [
RevenueEntry(
id=item["id"],
period=item["period"],
gross_amount=_money(item["gross_amount"]),
fees_amount=_money(item["fees_amount"]),
refunds_amount=_money(item.get("refunds_amount", "0")),
net_amount=_money(item["net_amount"]),
currency=item["currency"],
source=item["source"],
)
for item in raw["entries"]
]
def load_membership(data_dir: Path | None = None) -> list[MembershipRecord]:
raw = _read_json((data_dir or default_data_dir()) / "membership.json")
return [
MembershipRecord(
id=item["id"],
status=item["status"],
joined_at=item["joined_at"],
plan_id=item["plan_id"],
churned_at=item.get("churned_at"),
)
for item in raw["members"]
]

View File

@@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Literal
CostCategory = Literal["fixed", "variable"]
MemberStatus = Literal["active", "churned", "paused"]
PricingModelStatus = Literal["active", "candidate", "retired"]
@dataclass(frozen=True)
class Product:
id: str
name: str
lifecycle_phase: str
currency: str
description: 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)
class CostEntry:
id: str
name: str
category: CostCategory
amount: Decimal
currency: str
cadence: str
allocation: str
@dataclass(frozen=True)
class RevenueEntry:
id: str
period: str
gross_amount: Decimal
fees_amount: Decimal
refunds_amount: Decimal
net_amount: Decimal
currency: str
source: str
@dataclass(frozen=True)
class MembershipRecord:
id: str
status: MemberStatus
joined_at: str
plan_id: str
churned_at: str | None = None
@dataclass(frozen=True)
class EconomicsSnapshot:
period: str
currency: str
active_members: int
monthly_revenue: Decimal
monthly_cost: Decimal
cost_per_member: Decimal
gross_margin: Decimal
gross_margin_pct: Decimal
pricing_model_count: int
revenue_source: str

View File

@@ -0,0 +1,34 @@
# Economics Dashboard v1 — Coulomb Social Membership
**Period:** 2026-06
**Lifecycle phase:** growth
**Active pricing model:** Standard Membership (8.99 EUR/monthly)
## Key Metrics
| Metric | Value |
|--------|------:|
| Active members | 3 |
| Monthly revenue | 80.91 EUR |
| Monthly cost | 74.16 EUR |
| Cost per member | 24.72 EUR |
| Gross margin | 6.75 EUR |
| Gross margin % | 8.3% |
_Revenue source: manual_estimate_
## Pricing Model Registry
| ID | Name | Type | Status |
|----|------|------|--------|
| flat-899-eur-monthly | Standard Membership | flat_subscription | active |
| membership-plus-credits | Membership + AI Credits | hybrid_subscription_usage | candidate |
| membership-plus-overage | Membership + Overage | hybrid_subscription_usage | candidate |
## Registries Loaded
- Product model (`data/product.json`)
- Pricing model registry (`data/pricing-models.json`)
- Cost registry (`data/costs.json`)
- Revenue registry (`data/revenue.json`)
- Membership registry (`data/membership.json`)

View File

@@ -0,0 +1,6 @@
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
from decimal import Decimal
from pathlib import Path
from observatory.economics import active_members, build_snapshot, monthly_cost_total
from observatory.load import (
load_costs,
load_membership,
load_pricing_models,
load_product,
load_revenue,
)
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
def test_active_members_counts_only_active_status() -> None:
members = load_membership(DATA_DIR)
assert active_members(members) == 3
def test_build_snapshot_uses_seed_revenue_for_period() -> None:
product = load_product(DATA_DIR)
models = load_pricing_models(DATA_DIR)
members = load_membership(DATA_DIR)
revenue = load_revenue(DATA_DIR)
_, costs, fx_rates = load_costs(DATA_DIR)
snapshot = build_snapshot("2026-06", product, models, members, revenue, costs, fx_rates)
assert snapshot.active_members == 3
assert snapshot.monthly_revenue == Decimal("80.91")
assert snapshot.revenue_source == "manual_estimate"
assert snapshot.pricing_model_count == 3
assert snapshot.monthly_cost > Decimal("0")
assert snapshot.cost_per_member == (snapshot.monthly_cost / 3).quantize(Decimal("0.01"))
assert snapshot.gross_margin == (snapshot.monthly_revenue - snapshot.monthly_cost).quantize(
Decimal("0.01")
)
def test_monthly_cost_includes_fixed_and_variable_components() -> None:
_, costs, fx_rates = load_costs(DATA_DIR)
total = monthly_cost_total(costs, fx_rates, Decimal("80.91"), 3)
# fixed: 35 USD -> 32.20 EUR + 15 EUR + 25 EUR = 72.20
# variable: 1.5% of 80.91 + 0.25 * 3 = 1.21 + 0.75 = 1.96
assert total == Decimal("74.16")
def test_dashboard_module_renders_markdown() -> None:
from observatory.dashboard import generate_dashboard
report = generate_dashboard(DATA_DIR, "2026-06")
assert "# Economics Dashboard v1" in report
assert "Pricing Model Registry" in report
assert "flat-899-eur-monthly" in report

View File

@@ -133,7 +133,7 @@ observatory`), sample report `reports/economics-2026-06.md`, and pytest suite.
```task
id: ADAPTIVE-WP-0002-T02
status: wait
status: todo
priority: high
state_hub_task_id: "42c181f9-9f4e-414e-aa94-b08c763abdef"
```