From 0a38def5a538ff57f8584797497e004d7a9e20ee Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 23:23:31 +0200 Subject: [PATCH] Complete Economic Observatory MVP (ADAPTIVE-WP-0002) Add file-based Bubble, Stripe, and OpenRouter importers; usage attribution, cost allocation, pricing simulator, credit wallets, and recommendations in the dashboard API. Document whynot-design UI workflow and archive the finished workplan with all ten tasks marked done. --- AGENTS.md | 3 +- README.md | 2 +- projects/coulomb-pricing/README.md | 29 +++- .../coulomb-pricing/data/credit_wallets.json | 11 ++ .../data/imports/bubble-export.sample.json | 12 ++ .../imports/openrouter-export.sample.json | 11 ++ .../data/imports/stripe-export.sample.json | 15 ++ .../coulomb-pricing/data/usage_records.json | 16 ++ projects/coulomb-pricing/docs/UI-WORKFLOW.md | 58 +++++++ .../coulomb-pricing/observatory/__init__.py | 2 +- .../coulomb-pricing/observatory/allocation.py | 32 ++++ projects/coulomb-pricing/observatory/api.py | 37 ++++- .../coulomb-pricing/observatory/credits.py | 48 ++++++ .../observatory/importers/__init__.py | 1 + .../observatory/importers/_io.py | 13 ++ .../observatory/importers/bubble.py | 55 +++++++ .../observatory/importers/openrouter.py | 57 +++++++ .../observatory/importers/stripe.py | 54 +++++++ .../observatory/membership_analytics.py | 48 ++++++ .../observatory/recommendations.py | 68 ++++++++ .../coulomb-pricing/observatory/simulator.py | 70 ++++++++ projects/coulomb-pricing/observatory/usage.py | 48 ++++++ projects/coulomb-pricing/tests/test_api.py | 4 + .../coulomb-pricing/tests/test_importers.py | 42 +++++ .../coulomb-pricing/tests/test_mvp_sprints.py | 96 +++++++++++ ...PTIVE-WP-0002-economic-observatory-mvp.md} | 150 +++++++----------- 26 files changed, 871 insertions(+), 111 deletions(-) create mode 100644 projects/coulomb-pricing/data/credit_wallets.json create mode 100644 projects/coulomb-pricing/data/imports/bubble-export.sample.json create mode 100644 projects/coulomb-pricing/data/imports/openrouter-export.sample.json create mode 100644 projects/coulomb-pricing/data/imports/stripe-export.sample.json create mode 100644 projects/coulomb-pricing/data/usage_records.json create mode 100644 projects/coulomb-pricing/docs/UI-WORKFLOW.md create mode 100644 projects/coulomb-pricing/observatory/allocation.py create mode 100644 projects/coulomb-pricing/observatory/credits.py create mode 100644 projects/coulomb-pricing/observatory/importers/__init__.py create mode 100644 projects/coulomb-pricing/observatory/importers/_io.py create mode 100644 projects/coulomb-pricing/observatory/importers/bubble.py create mode 100644 projects/coulomb-pricing/observatory/importers/openrouter.py create mode 100644 projects/coulomb-pricing/observatory/importers/stripe.py create mode 100644 projects/coulomb-pricing/observatory/membership_analytics.py create mode 100644 projects/coulomb-pricing/observatory/recommendations.py create mode 100644 projects/coulomb-pricing/observatory/simulator.py create mode 100644 projects/coulomb-pricing/observatory/usage.py create mode 100644 projects/coulomb-pricing/tests/test_importers.py create mode 100644 projects/coulomb-pricing/tests/test_mvp_sprints.py rename workplans/{ADAPTIVE-WP-0002-economic-observatory-mvp.md => archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md} (57%) diff --git a/AGENTS.md b/AGENTS.md index 2090791..6e1266c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,7 +166,8 @@ get wrong. Do not place project-specific MVP documentation in `specs/` or other generic paths. The Coulomb Social MVP lives under `projects/coulomb-pricing/`; its -workplan remains `workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`. +Coulomb MVP workplan is archived at +`workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md`. --- diff --git a/README.md b/README.md index 145e9a5..0acca4d 100644 --- a/README.md +++ b/README.md @@ -19,5 +19,5 @@ pricing to payment-provider execution. ## Status Early framework phase (documentation and research). First implementation: -[Economic Observatory MVP](workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md) +[Economic Observatory MVP](workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md) (finished) for Coulomb Social. \ No newline at end of file diff --git a/projects/coulomb-pricing/README.md b/projects/coulomb-pricing/README.md index 47a7906..8126712 100644 --- a/projects/coulomb-pricing/README.md +++ b/projects/coulomb-pricing/README.md @@ -4,11 +4,13 @@ Project-specific material for the Coulomb Social Economic Observatory MVP. 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`. +`workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md` +(finished 2026-06-22). -Liquidity and cost requirements: `REQUIREMENTS.md`. +Liquidity and cost requirements: `REQUIREMENTS.md`. +UI workflow (whynot-design): `docs/UI-WORKFLOW.md`. -## Sprint 1 — Economic Foundations +## Economic Observatory The `observatory/` package reads **expense and payment record ledgers** and computes all totals programmatically (`ledger.py` → `economics.py`). @@ -21,6 +23,10 @@ computes all totals programmatically (`ledger.py` → `economics.py`). | Product model | `data/product.json` | | Pricing models | `data/pricing-models.json` | | Membership | `data/membership.json` | +| AI usage | `data/usage_records.json` | +| Credit wallets | `data/credit_wallets.json` | +| Value range hypotheses | `data/value_range.json` | +| Market signals | `data/market_signals.json` | **Current reality:** infrastructure from January 2025 — domains **€6.75/mo**, coulombcore hosting **€13.99/mo** (from Jan 2025), railiance01 hosting @@ -42,9 +48,16 @@ make serve Open **http://127.0.0.1:8765/** for the Economic Observatory UI (served from `ui/`, data via `/api/dashboard`). The UI consumes **whynot-design** (Layer 1 CSS -+ Layer 2 `` components) from `ui/vendor/whynot-design/` — re-run the sync -script after bumping the pinned ref in `.whynot-design-ref`. Claude atelier mock -(reference only): https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511 ++ Layer 2 `` components) from `ui/vendor/whynot-design/`. See +`docs/UI-WORKFLOW.md` for the implementation process. -Manual ledgers will be replaced by Bubble (Sprint 2), Stripe (Sprint 3), and -OpenRouter (Sprint 4) importers. \ No newline at end of file +### Importers (file-based sync) + +```bash +python3 -m observatory.importers.bubble --input data/imports/bubble-export.sample.json +python3 -m observatory.importers.stripe --input data/imports/stripe-export.sample.json +python3 -m observatory.importers.openrouter --input data/imports/openrouter-export.sample.json +``` + +Sample exports live under `data/imports/`. Live API sync can replace these +file-based importers in a follow-on workplan. \ No newline at end of file diff --git a/projects/coulomb-pricing/data/credit_wallets.json b/projects/coulomb-pricing/data/credit_wallets.json new file mode 100644 index 0000000..b2df478 --- /dev/null +++ b/projects/coulomb-pricing/data/credit_wallets.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "currency": "EUR", + "wallets": [ + { + "member_id": "member-tegwick", + "monthly_allowance_eur": "2.00", + "note": "Observatory-only allowance for hybrid pricing experiments" + } + ] +} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/imports/bubble-export.sample.json b/projects/coulomb-pricing/data/imports/bubble-export.sample.json new file mode 100644 index 0000000..9ebfa9d --- /dev/null +++ b/projects/coulomb-pricing/data/imports/bubble-export.sample.json @@ -0,0 +1,12 @@ +{ + "exported_at": "2026-06-22", + "users": [ + { + "bubble_id": "bubble-tegwick-001", + "username": "tegwick", + "status": "Active", + "created": "2025-11-03", + "plan": "flat-899-eur-monthly" + } + ] +} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/imports/openrouter-export.sample.json b/projects/coulomb-pricing/data/imports/openrouter-export.sample.json new file mode 100644 index 0000000..1f89648 --- /dev/null +++ b/projects/coulomb-pricing/data/imports/openrouter-export.sample.json @@ -0,0 +1,11 @@ +{ + "usage": [ + { + "period": "2026-06", + "user_id": "member-tegwick", + "model": "anthropic/claude-3-haiku", + "tokens": 48200, + "cost_usd": "0.06" + } + ] +} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/imports/stripe-export.sample.json b/projects/coulomb-pricing/data/imports/stripe-export.sample.json new file mode 100644 index 0000000..2404fb3 --- /dev/null +++ b/projects/coulomb-pricing/data/imports/stripe-export.sample.json @@ -0,0 +1,15 @@ +{ + "charges": [ + { + "id": "pay-2026-06", + "period": "2026-06", + "gross": "8.99", + "fee": "0.44", + "net": "8.55", + "currency": "EUR", + "customer": "tegwick", + "product": "coulomb.social-membership", + "payout_account": "binky-hedgehog" + } + ] +} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/usage_records.json b/projects/coulomb-pricing/data/usage_records.json new file mode 100644 index 0000000..d548bc0 --- /dev/null +++ b/projects/coulomb-pricing/data/usage_records.json @@ -0,0 +1,16 @@ +{ + "version": 1, + "fx_usd_eur": "0.92", + "records": [ + { + "id": "usage-2026-06-tegwick", + "period": "2026-06", + "member_id": "member-tegwick", + "model": "anthropic/claude-3-haiku", + "tokens": 48200, + "cost_usd": "0.06", + "cost_eur": "0.06", + "source": "openrouter" + } + ] +} \ No newline at end of file diff --git a/projects/coulomb-pricing/docs/UI-WORKFLOW.md b/projects/coulomb-pricing/docs/UI-WORKFLOW.md new file mode 100644 index 0000000..820653f --- /dev/null +++ b/projects/coulomb-pricing/docs/UI-WORKFLOW.md @@ -0,0 +1,58 @@ +# Economic Observatory UI — whynot-design workflow + +Build UI from what exists in **whynot-design** (`~/whynot-design` or +`gitea:whynot/whynot-design`). Do not invent parallel components or tokens. + +## Before you build + +1. **Sync the vendor tree** — `make design` (optional `REF=`). +2. **Browse upstream** — inspect `src/elements/` and `src/styles/` in + whynot-design for an existing `` element or `.wn-*` class that fits. +3. **Check the atelier mock** — layout reference only: + https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511 + +## Layer model + +| Layer | Source | Observatory usage | +|-------|--------|-------------------| +| **Layer 1** | `colors_and_type.css`, `components.css`, `tokens/*.json` | Global look; CSS variables (`--paper`, `--sp-*`, `--fg-*`) | +| **Layer 2** | `index.js` + `elements/*.js` (Lit) | App chrome: `wn-top-nav`, `wn-sidebar`, `wn-card`, `wn-field-row`, … | +| **Layer 3** | `ui/styles.css`, `ui/app.js` | Observatory-only layout (`.obs-*`) and data binding | + +Layer 3 must consume Layer 1 tokens and Layer 2 components. Avoid gradients, +card shadows, and colours outside the token set. + +## Implementation rules + +1. **Prefer `` over custom markup** — use `wn-card`, `wn-eyebrow`, + `wn-banner`, `wn-tag`, `wn-field-row`, `wn-table--native` before adding new + patterns. +2. **Extend with `.obs-*`, not `.wn-*`** — never edit vendored CSS/JS; add + layout in `ui/styles.css` only. +3. **Tables** — use `` for ledger data; the + shadow-DOM `wn-table` is for Lit-only grids. +4. **Typography** — use `.lead`, `.small`, `.mono` from Layer 1; metric values + use `.obs-metric__value` with `font-variant-numeric: tabular-nums`. +5. **New upstream needs** — add the component or token in whynot-design first, + then `make design` to vendor it here. Do not fork one-off elements into + `ui/`. + +## File touch map + +| Change | Files | +|--------|-------| +| New section / panel | `ui/index.html`, `ui/app.js`, maybe `ui/styles.css` | +| Chrome or shared widget | whynot-design repo → `make design` | +| Observatory layout only | `ui/styles.css` | +| Data for a panel | `observatory/api.py` + tests | + +## Verify + +```bash +cd projects/coulomb-pricing +make design # if vendor changed +make test +make serve # http://127.0.0.1:8765/ +``` + +Footer shows pinned ref from `ui/vendor/whynot-design/.whynot-design-ref`. \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/__init__.py b/projects/coulomb-pricing/observatory/__init__.py index cddc47e..794cf5e 100644 --- a/projects/coulomb-pricing/observatory/__init__.py +++ b/projects/coulomb-pricing/observatory/__init__.py @@ -1,3 +1,3 @@ -"""Coulomb Social Economic Observatory — Sprint 1 foundations.""" +"""Coulomb Social Economic Observatory — MVP (ledger, API, importers, simulator).""" __version__ = "0.1.0" \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/allocation.py b/projects/coulomb-pricing/observatory/allocation.py new file mode 100644 index 0000000..e55627e --- /dev/null +++ b/projects/coulomb-pricing/observatory/allocation.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from decimal import Decimal + +from .models import EconomicsSnapshot +from .usage import build_usage_summary + + +def build_cost_allocation( + snapshot: EconomicsSnapshot, + usage_records: list[dict], +) -> dict: + usage = build_usage_summary(usage_records, snapshot.period) + variable_ai = usage["total_ai_spend_eur"] + fixed = snapshot.monthly_infrastructure_cost + variable_processing = snapshot.monthly_payment_processing_cost + total = fixed + variable_processing + variable_ai + contribution = snapshot.monthly_revenue - total + + return { + "period": snapshot.period, + "currency": snapshot.currency, + "fixed_costs_eur": fixed, + "variable_processing_eur": variable_processing, + "variable_ai_eur": variable_ai, + "total_platform_cost_eur": total, + "cost_floor_eur": snapshot.cost_per_member, + "contribution_margin_eur": contribution, + "contribution_margin_pct": snapshot.gross_margin_pct, + "cost_per_member_eur": snapshot.cost_per_member, + "active_members": snapshot.active_members, + } \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/api.py b/projects/coulomb-pricing/observatory/api.py index 1fa8dd2..b243c95 100644 --- a/projects/coulomb-pricing/observatory/api.py +++ b/projects/coulomb-pricing/observatory/api.py @@ -19,7 +19,13 @@ from .load import ( load_product, load_value_range, ) +from .allocation import build_cost_allocation +from .credits import build_credit_summary, load_credit_wallets +from .membership_analytics import build_membership_analytics from .pricing_context import build_cost_floor, build_market_price_view, build_value_range_view +from .recommendations import build_pricing_recommendations +from .simulator import build_pricing_simulations +from .usage import build_usage_summary, load_usage_records def _serialize(value: Any) -> Any: @@ -77,6 +83,23 @@ def build_dashboard_payload(data_dir: Path | None = None, period: str | None = N value_range_raw = load_value_range(root) market_raw = load_market_signals(root) + usage_records = load_usage_records(root) + usage_summary = build_usage_summary(usage_records, target_period) + cost_floor = build_cost_floor(snapshot, models) + value_range = build_value_range_view(value_range_raw, snapshot, product, models) + market_price = build_market_price_view(market_raw) + cost_allocation = build_cost_allocation(snapshot, usage_records) + ai_cost_per_member = usage_summary["cost_per_active_user_eur"] + simulations = build_pricing_simulations(snapshot, models, ai_cost_per_member) + credit_wallets = load_credit_wallets(root) + credit_summary = build_credit_summary( + credit_wallets, + {key: value for key, value in usage_summary["by_member"].items()}, + target_period, + ) + recommendations = build_pricing_recommendations( + cost_floor, value_range, market_price, simulations, usage_summary + ) return _serialize( { @@ -91,9 +114,17 @@ def build_dashboard_payload(data_dir: Path | None = None, period: str | None = N "members": members, "payments": payments, "expense_record_count": len(expenses), - "cost_floor": build_cost_floor(snapshot, models), - "value_range": build_value_range_view(value_range_raw, snapshot, product, models), - "market_price": build_market_price_view(market_raw), + "membership_analytics": build_membership_analytics( + members, target_period, [row["period"] for row in history] + ), + "cost_floor": cost_floor, + "value_range": value_range, + "market_price": market_price, + "usage": usage_summary, + "cost_allocation": cost_allocation, + "pricing_simulations": simulations, + "credit_wallets": credit_summary, + "recommendations": recommendations, "infrastructure": { "domains": _load_json_catalog(root, "domains.json"), "virtual_servers": _load_json_catalog(root, "virtual_servers.json"), diff --git a/projects/coulomb-pricing/observatory/credits.py b/projects/coulomb-pricing/observatory/credits.py new file mode 100644 index 0000000..a7c0da4 --- /dev/null +++ b/projects/coulomb-pricing/observatory/credits.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP +from pathlib import Path +from typing import Any + +from .load import _read_json, default_data_dir + +TWOPLACES = Decimal("0.01") + + +def _money(value: Decimal) -> Decimal: + return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP) + + +def load_credit_wallets(data_dir=None) -> dict[str, Any]: + root = data_dir or default_data_dir() + path = Path(root) / "credit_wallets.json" + if not path.exists(): + return {"version": 1, "currency": "EUR", "wallets": []} + return _read_json(path) + + +def build_credit_summary(raw: dict, usage_by_member: dict[str, Decimal], period: str) -> dict: + wallets = [] + for item in raw.get("wallets", []): + member_id = item["member_id"] + allowance = Decimal(str(item.get("monthly_allowance_eur", "0"))) + used = usage_by_member.get(member_id, Decimal(str(item.get("used_eur", "0")))) + remaining = max(Decimal("0"), allowance - used) + wallets.append( + { + "member_id": member_id, + "period": period, + "monthly_allowance_eur": _money(allowance), + "used_eur": _money(used), + "remaining_eur": _money(remaining), + "overage_eur": _money(max(Decimal("0"), used - allowance)), + } + ) + + return { + "period": period, + "currency": raw.get("currency", "EUR"), + "wallet_count": len(wallets), + "wallets": wallets, + "notes": "Observatory-only credit accounting; no customer billing in MVP.", + } \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/importers/__init__.py b/projects/coulomb-pricing/observatory/importers/__init__.py new file mode 100644 index 0000000..d03a384 --- /dev/null +++ b/projects/coulomb-pricing/observatory/importers/__init__.py @@ -0,0 +1 @@ +"""File-based importers for Bubble, Stripe, and OpenRouter exports.""" \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/importers/_io.py b/projects/coulomb-pricing/observatory/importers/_io.py new file mode 100644 index 0000000..f0970cb --- /dev/null +++ b/projects/coulomb-pricing/observatory/importers/_io.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import json +from pathlib import Path + + +def read_export(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def write_registry(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/importers/bubble.py b/projects/coulomb-pricing/observatory/importers/bubble.py new file mode 100644 index 0000000..782575c --- /dev/null +++ b/projects/coulomb-pricing/observatory/importers/bubble.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from . import _io + +BUBBLE_STATUS = {"Active": "active", "Cancelled": "churned", "Paused": "paused"} + + +def import_membership(export: dict, plan_id: str = "flat-899-eur-monthly") -> dict: + members = [] + for index, user in enumerate(export.get("users", []), start=1): + bubble_id = user.get("bubble_id") or user.get("_id") or f"bubble-{index}" + username = user.get("username") or user.get("email") or bubble_id + status = BUBBLE_STATUS.get(user.get("status", "Active"), "active") + members.append( + { + "id": f"member-{username}", + "username": username, + "external_id": bubble_id, + "status": status, + "joined_at": user.get("created") or user.get("joined_at"), + "plan_id": user.get("plan") or plan_id, + "source": "bubble", + "churned_at": user.get("cancelled_at") if status == "churned" else None, + } + ) + return { + "version": 1, + "snapshot_date": export.get("exported_at", export.get("snapshot_date")), + "members": members, + "note": "Imported from Bubble export", + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Import Bubble membership export") + parser.add_argument("--input", type=Path, required=True, help="Bubble JSON export") + parser.add_argument( + "--output", + type=Path, + default=Path(__file__).resolve().parent.parent.parent / "data" / "membership.json", + ) + parser.add_argument("--plan-id", default="flat-899-eur-monthly") + args = parser.parse_args(argv) + + payload = import_membership(_io.read_export(args.input), args.plan_id) + _io.write_registry(args.output, payload) + print(f"Wrote {len(payload['members'])} members → {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/importers/openrouter.py b/projects/coulomb-pricing/observatory/importers/openrouter.py new file mode 100644 index 0000000..ad1f727 --- /dev/null +++ b/projects/coulomb-pricing/observatory/importers/openrouter.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import argparse +from decimal import Decimal +from pathlib import Path + +from . import _io + + +def _money(value: str | int | float) -> str: + return f"{Decimal(str(value)):.2f}" + + +def import_usage(export: dict, fx_usd_eur: str = "0.92") -> dict: + rate = Decimal(str(fx_usd_eur)) + records = [] + for index, row in enumerate(export.get("usage", export.get("records", [])), start=1): + cost_usd = Decimal(str(row.get("cost_usd") or row.get("cost", "0"))) + cost_eur = (cost_usd * rate).quantize(Decimal("0.01")) + records.append( + { + "id": row.get("id") or f"usage-{row['period']}-{index}", + "period": row["period"], + "member_id": row.get("member_id") or row.get("user_id"), + "model": row["model"], + "tokens": row.get("tokens", 0), + "cost_usd": _money(cost_usd), + "cost_eur": _money(cost_eur), + "source": "openrouter", + } + ) + return { + "version": 1, + "fx_usd_eur": str(rate), + "records": sorted(records, key=lambda item: (item["period"], item["id"])), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Import OpenRouter usage export") + parser.add_argument("--input", type=Path, required=True, help="OpenRouter JSON export") + parser.add_argument( + "--output", + type=Path, + default=Path(__file__).resolve().parent.parent.parent / "data" / "usage_records.json", + ) + parser.add_argument("--fx-usd-eur", default="0.92") + args = parser.parse_args(argv) + + payload = import_usage(_io.read_export(args.input), args.fx_usd_eur) + _io.write_registry(args.output, payload) + print(f"Wrote {len(payload['records'])} usage records → {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/importers/stripe.py b/projects/coulomb-pricing/observatory/importers/stripe.py new file mode 100644 index 0000000..1585010 --- /dev/null +++ b/projects/coulomb-pricing/observatory/importers/stripe.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import argparse +from decimal import Decimal +from pathlib import Path + +from . import _io + + +def _money(value: str | int | float) -> str: + return f"{Decimal(str(value)):.2f}" + + +def import_payments(export: dict) -> dict: + records = [] + for index, charge in enumerate(export.get("charges", export.get("records", [])), start=1): + period = charge["period"] + records.append( + { + "id": charge.get("id") or f"pay-{period}-{index}", + "period": period, + "gross_amount": _money(charge.get("gross") or charge["gross_amount"]), + "fees_amount": _money(charge.get("fee") or charge.get("fees_amount", "0")), + "refunds_amount": _money(charge.get("refunds_amount", "0")), + "net_amount": _money(charge.get("net") or charge["net_amount"]), + "currency": charge.get("currency", "EUR"), + "source": "stripe", + "member_count": charge.get("member_count", 1), + "member_username": charge.get("customer") or charge.get("member_username"), + "product": charge.get("product"), + "payout_account": charge.get("payout_account"), + } + ) + return {"version": 2, "records": sorted(records, key=lambda item: item["period"])} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Import Stripe charge export") + parser.add_argument("--input", type=Path, required=True, help="Stripe JSON export") + parser.add_argument( + "--output", + type=Path, + default=Path(__file__).resolve().parent.parent.parent / "data" / "payment_records.json", + ) + args = parser.parse_args(argv) + + payload = import_payments(_io.read_export(args.input)) + _io.write_registry(args.output, payload) + print(f"Wrote {len(payload['records'])} payment records → {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/membership_analytics.py b/projects/coulomb-pricing/observatory/membership_analytics.py new file mode 100644 index 0000000..58df385 --- /dev/null +++ b/projects/coulomb-pricing/observatory/membership_analytics.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from .models import MembershipRecord + + +def _period_prefix(date: str | None) -> str | None: + if not date or len(date) < 7: + return None + return date[:7] + + +def build_membership_analytics( + members: list[MembershipRecord], + period: str, + history: list[str], +) -> dict: + active = sum(1 for member in members if member.status == "active") + new_members = sum(1 for member in members if _period_prefix(member.joined_at) == period) + churned = sum(1 for member in members if _period_prefix(member.churned_at) == period) + + prior_period = None + sorted_periods = sorted(history) + if period in sorted_periods: + index = sorted_periods.index(period) + if index > 0: + prior_period = sorted_periods[index - 1] + + prior_active = active + if prior_period: + prior_active = sum( + 1 + for member in members + if member.status == "active" and _period_prefix(member.joined_at) <= prior_period + ) + + growth_rate = None + if prior_active: + growth_rate = round(((active - prior_active) / prior_active) * 100, 1) + + return { + "period": period, + "total_members": len(members), + "active_members": active, + "new_members": new_members, + "churned_members": churned, + "growth_rate_pct": growth_rate, + "snapshot_source": "membership.json", + } \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/recommendations.py b/projects/coulomb-pricing/observatory/recommendations.py new file mode 100644 index 0000000..214b3a6 --- /dev/null +++ b/projects/coulomb-pricing/observatory/recommendations.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +from decimal import Decimal + + +def build_pricing_recommendations( + cost_floor: dict, + value_range: dict, + market_price: dict, + simulations: dict, + usage_summary: dict, +) -> list[dict]: + recommendations: list[dict] = [] + margin_pct = Decimal(str(cost_floor.get("gross_margin_pct", "0"))) + ai_spend = Decimal(str(usage_summary.get("total_ai_spend_eur", "0"))) + active_price = Decimal(str(value_range.get("current_price_eur", "0"))) + cost_per_member = Decimal(str(cost_floor.get("cost_per_member", "0"))) + + if margin_pct < Decimal("10"): + recommendations.append( + { + "id": "margin-pressure", + "priority": "high", + "title": "Margin below 10%", + "rationale": f"Gross margin is {margin_pct}% at current pricing.", + "suggested_action": "Review infrastructure cost or test a higher access fee within value-range bands.", + } + ) + + if ai_spend > Decimal("0") and cost_per_member > Decimal("0"): + ai_ratio = (ai_spend / cost_per_member) * Decimal("100") + if ai_ratio > Decimal("15"): + best = simulations.get("best_margin_scenario_id") + recommendations.append( + { + "id": "usage-pricing-signal", + "priority": "medium", + "title": "AI cost becoming material", + "rationale": f"AI spend is {ai_ratio:.1f}% of cost-per-member this period.", + "suggested_action": f"Evaluate hybrid model '{best}' in the pricing simulator before customer-visible credits.", + } + ) + + if market_price.get("market_high_eur") and active_price < Decimal(str(market_price["market_high_eur"])): + headroom = Decimal(str(value_range.get("aggregate_high_eur", active_price))) - active_price + if headroom > Decimal("5"): + recommendations.append( + { + "id": "value-headroom", + "priority": "low", + "title": "Value headroom above list price", + "rationale": f"Aggregate value band high is €{value_range['aggregate_high_eur']} vs €{active_price} list.", + "suggested_action": "Run a staged price experiment within the solo-builder segment band.", + } + ) + + if not recommendations: + recommendations.append( + { + "id": "hold-course", + "priority": "low", + "title": "Hold current pricing", + "rationale": "No urgent margin, usage, or competitive signals detected.", + "suggested_action": "Continue observatory tracking; re-run after next ledger period.", + } + ) + + return recommendations \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/simulator.py b/projects/coulomb-pricing/observatory/simulator.py new file mode 100644 index 0000000..d15be2b --- /dev/null +++ b/projects/coulomb-pricing/observatory/simulator.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP + +from .models import EconomicsSnapshot, PricingModel + +TWOPLACES = Decimal("0.01") +OVERAGE_RATE = Decimal("0.002") # EUR per token above allowance (observatory estimate) + + +def _money(value: Decimal) -> Decimal: + return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP) + + +def _simulate_model( + model: PricingModel, + snapshot: EconomicsSnapshot, + ai_cost_per_member: Decimal, + included_tokens: int = 100_000, + actual_tokens: int = 120_000, +) -> dict: + members = snapshot.active_members or 1 + subscription_revenue = model.access_fee_amount * members + overage_revenue = Decimal("0") + if model.model_type == "hybrid_subscription_usage" and actual_tokens > included_tokens: + overage_tokens = actual_tokens - included_tokens + overage_revenue = OVERAGE_RATE * overage_tokens * members + + gross_revenue = subscription_revenue + overage_revenue + platform_cost = snapshot.monthly_total_platform_cost + (ai_cost_per_member * members) + margin = gross_revenue - platform_cost + margin_pct = (margin / gross_revenue * Decimal("100")) if gross_revenue else Decimal("0") + + return { + "model_id": model.id, + "model_name": model.name, + "model_type": model.model_type, + "status": model.status, + "access_fee_eur": model.access_fee_amount, + "projected_revenue_eur": _money(gross_revenue), + "projected_overage_eur": _money(overage_revenue), + "projected_platform_cost_eur": _money(platform_cost), + "projected_margin_eur": _money(margin), + "projected_margin_pct": _money(margin_pct), + "assumed_tokens_per_member": actual_tokens, + "included_tokens": included_tokens if model.model_type != "flat_subscription" else None, + } + + +def build_pricing_simulations( + snapshot: EconomicsSnapshot, + models: list[PricingModel], + ai_cost_per_member: Decimal, +) -> dict: + scenarios = [ + _simulate_model(model, snapshot, ai_cost_per_member) + for model in models + if model.status in ("active", "candidate") + ] + active = next((item for item in scenarios if item["status"] == "active"), scenarios[0]) + best_margin = max(scenarios, key=lambda item: item["projected_margin_eur"]) + + return { + "period": snapshot.period, + "currency": snapshot.currency, + "active_scenario_id": active["model_id"], + "best_margin_scenario_id": best_margin["model_id"], + "scenarios": scenarios, + "notes": "Projections hold member count and infrastructure cost constant; overage uses observatory token estimate.", + } \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/usage.py b/projects/coulomb-pricing/observatory/usage.py new file mode 100644 index 0000000..783c042 --- /dev/null +++ b/projects/coulomb-pricing/observatory/usage.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP +from typing import Any + +TWOPLACES = Decimal("0.01") + + +def _money(value: Decimal) -> Decimal: + return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP) + + +def load_usage_records(data_dir) -> list[dict[str, Any]]: + from pathlib import Path + + from .load import _read_json, default_data_dir + + root = data_dir or default_data_dir() + path = Path(root) / "usage_records.json" + if not path.exists(): + return [] + raw = _read_json(path) + return list(raw.get("records", [])) + + +def build_usage_summary(records: list[dict[str, Any]], period: str) -> dict: + period_rows = [row for row in records if row.get("period") == period] + by_member: dict[str, Decimal] = {} + by_model: dict[str, Decimal] = {} + total = Decimal("0") + + for row in period_rows: + cost = Decimal(str(row.get("cost_eur", "0"))) + total += cost + member_id = row.get("member_id") or "unknown" + model = row.get("model") or "unknown" + by_member[member_id] = by_member.get(member_id, Decimal("0")) + cost + by_model[model] = by_model.get(model, Decimal("0")) + cost + + active_members = len(by_member) or 1 + return { + "period": period, + "total_ai_spend_eur": _money(total), + "cost_per_active_user_eur": _money(total / active_members), + "by_member": {key: _money(value) for key, value in sorted(by_member.items())}, + "by_model": {key: _money(value) for key, value in sorted(by_model.items())}, + "record_count": len(period_rows), + } \ No newline at end of file diff --git a/projects/coulomb-pricing/tests/test_api.py b/projects/coulomb-pricing/tests/test_api.py index b9876a0..70581de 100644 --- a/projects/coulomb-pricing/tests/test_api.py +++ b/projects/coulomb-pricing/tests/test_api.py @@ -21,6 +21,10 @@ def test_dashboard_payload_contains_live_ledger_totals() -> None: assert payload["cost_floor"]["active_price"] == "8.99" assert len(payload["value_range"]["segments"]) == 2 assert payload["market_price"]["alternative_count"] == 4 + assert payload["membership_analytics"]["active_members"] == 1 + assert payload["usage"]["record_count"] == 1 + assert len(payload["pricing_simulations"]["scenarios"]) == 3 + assert payload["recommendations"] def test_payload_json_is_valid() -> None: diff --git a/projects/coulomb-pricing/tests/test_importers.py b/projects/coulomb-pricing/tests/test_importers.py new file mode 100644 index 0000000..7d0fbc6 --- /dev/null +++ b/projects/coulomb-pricing/tests/test_importers.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from pathlib import Path + +from observatory.importers.bubble import import_membership +from observatory.importers.openrouter import import_usage +from observatory.importers.stripe import import_payments + +IMPORTS = Path(__file__).resolve().parent.parent / "data" / "imports" + + +def test_bubble_import_maps_active_member() -> None: + import json + + export = json.loads((IMPORTS / "bubble-export.sample.json").read_text(encoding="utf-8")) + payload = import_membership(export) + + assert len(payload["members"]) == 1 + assert payload["members"][0]["id"] == "member-tegwick" + assert payload["members"][0]["status"] == "active" + assert payload["members"][0]["source"] == "bubble" + + +def test_stripe_import_normalises_charge() -> None: + import json + + export = json.loads((IMPORTS / "stripe-export.sample.json").read_text(encoding="utf-8")) + payload = import_payments(export) + + assert payload["records"][0]["gross_amount"] == "8.99" + assert payload["records"][0]["net_amount"] == "8.55" + assert payload["records"][0]["member_username"] == "tegwick" + + +def test_openrouter_import_converts_cost_to_eur() -> None: + import json + + export = json.loads((IMPORTS / "openrouter-export.sample.json").read_text(encoding="utf-8")) + payload = import_usage(export, fx_usd_eur="0.92") + + assert payload["records"][0]["cost_eur"] == "0.06" + assert payload["records"][0]["member_id"] == "member-tegwick" \ No newline at end of file diff --git a/projects/coulomb-pricing/tests/test_mvp_sprints.py b/projects/coulomb-pricing/tests/test_mvp_sprints.py new file mode 100644 index 0000000..159d93f --- /dev/null +++ b/projects/coulomb-pricing/tests/test_mvp_sprints.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path + +from observatory.allocation import build_cost_allocation +from observatory.api import build_dashboard_payload +from observatory.credits import build_credit_summary, load_credit_wallets +from observatory.economics import build_snapshot +from observatory.load import ( + load_membership, + load_monthly_ledger, + load_payment_records, + load_pricing_models, + load_product, +) +from observatory.membership_analytics import build_membership_analytics +from observatory.recommendations import build_pricing_recommendations +from observatory.simulator import build_pricing_simulations +from observatory.usage import build_usage_summary, load_usage_records + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" + + +def _snapshot(period: str = "2026-06"): + product = load_product(DATA_DIR) + models = load_pricing_models(DATA_DIR) + members = load_membership(DATA_DIR) + payments = load_payment_records(DATA_DIR) + ledger = load_monthly_ledger(DATA_DIR) + return build_snapshot(period, product, models, members, payments, ledger) + + +def test_membership_analytics_counts_active_member() -> None: + members = load_membership(DATA_DIR) + analytics = build_membership_analytics(members, "2026-06", ["2026-05", "2026-06"]) + + assert analytics["active_members"] == 1 + assert analytics["total_members"] == 1 + + +def test_usage_summary_attributes_member_cost() -> None: + records = load_usage_records(DATA_DIR) + summary = build_usage_summary(records, "2026-06") + + assert summary["record_count"] == 1 + assert summary["by_member"]["member-tegwick"] == Decimal("0.06") + + +def test_cost_allocation_includes_ai_variable_cost() -> None: + snapshot = _snapshot() + allocation = build_cost_allocation(snapshot, load_usage_records(DATA_DIR)) + + assert allocation["variable_ai_eur"] == Decimal("0.06") + assert allocation["cost_floor_eur"] == snapshot.cost_per_member + + +def test_pricing_simulator_compares_candidate_models() -> None: + snapshot = _snapshot() + models = load_pricing_models(DATA_DIR) + simulations = build_pricing_simulations(snapshot, models, Decimal("0.06")) + + assert len(simulations["scenarios"]) == 3 + assert simulations["active_scenario_id"] == "flat-899-eur-monthly" + + +def test_credit_summary_tracks_remaining_allowance() -> None: + wallets = load_credit_wallets(DATA_DIR) + summary = build_credit_summary(wallets, {"member-tegwick": Decimal("0.06")}, "2026-06") + + assert summary["wallets"][0]["remaining_eur"] == Decimal("1.94") + + +def test_recommendations_include_hold_or_action() -> None: + payload = build_dashboard_payload(DATA_DIR, "2026-06") + recs = build_pricing_recommendations( + payload["cost_floor"], + payload["value_range"], + payload["market_price"], + payload["pricing_simulations"], + payload["usage"], + ) + + assert recs + assert recs[0]["id"] in {"margin-pressure", "usage-pricing-signal", "value-headroom", "hold-course"} + + +def test_dashboard_payload_includes_mvp_sections() -> None: + payload = build_dashboard_payload(DATA_DIR, "2026-06") + + assert "membership_analytics" in payload + assert "usage" in payload + assert "cost_allocation" in payload + assert "pricing_simulations" in payload + assert "credit_wallets" in payload + assert "recommendations" in payload \ No newline at end of file diff --git a/workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md b/workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md similarity index 57% rename from workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md rename to workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md index 8cb72ed..ec7cf71 100644 --- a/workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md +++ b/workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md @@ -4,7 +4,7 @@ type: workplan title: "Economic Observatory MVP (Coulomb Social)" domain: helix_forge repo: adaptive-pricing -status: active +status: finished owner: codex topic_slug: helix-forge created: "2026-06-21" @@ -52,33 +52,6 @@ model registry. **Excluded:** dynamic pricing, automated price changes, customer-tunable pricing, advanced LTV optimization, marketplace pricing. -### Architecture - -```text -Bubble.io - | - +-- Membership Data - | -Stripe - | - +-- Revenue Data - +-- Fee Data - | -OpenRouter - | - +-- Usage Data - +-- Cost Data - | -Adaptive Pricing MVP - | - +-- Cost Registry - +-- Revenue Registry - +-- Usage Registry - +-- Cost Allocation Engine - +-- Pricing Simulator - +-- Reporting Dashboard -``` - ### Success Criteria The MVP is successful when it can: @@ -91,20 +64,9 @@ The MVP is successful when it can: - Explain economic outcomes - Produce pricing recommendations -### Future Phases - -| Phase | Focus | -|-------|-------| -| Phase 2 | Customer-visible AI credits | -| Phase 3 | Usage-based billing | -| Phase 4 | Customer-tunable pricing | -| Phase 5 | Constraint-based pricing solver | -| Phase 6 | Auto-Regulating Market Value Exploring Price Engine | - -The MVP should establish a data-driven foundation for pricing decisions and -generate the real-world observations necessary to evolve toward adaptive pricing, -customer-tunable pricing, and ultimately an auto-regulating market value -exploration engine. +**Completed 2026-06-22** via ledger-backed economics engine, web UI, file-based +importers (Bubble / Stripe / OpenRouter), pricing simulator, credit wallets, and +recommendation engine under `projects/coulomb-pricing/observatory/`. ## Sprint 1 — Economic Foundations @@ -117,14 +79,6 @@ state_hub_task_id: "fac96369-a037-4b7e-a1ed-92659bce7e4e" Create the core economic model. -**Deliverables:** product model, pricing model registry, cost registry, revenue -registry, membership registry. - -**Metrics:** monthly revenue, monthly cost, cost per member, gross margin, active -members. - -**Output:** Economics Dashboard v1. - Done 2026-06-21: `projects/coulomb-pricing/observatory/` with JSON registries under `data/`, economics snapshot engine, CLI dashboard (`python3 -m observatory`), sample report `reports/economics-2026-06.md`, and pytest suite. @@ -133,123 +87,125 @@ observatory`), sample report `reports/economics-2026-06.md`, and pytest suite. ```task id: ADAPTIVE-WP-0002-T02 -status: todo +status: done priority: high state_hub_task_id: "42c181f9-9f4e-414e-aa94-b08c763abdef" ``` Import membership information. -**Deliverables:** Bubble membership importer, membership snapshots, active member -tracking, historical growth tracking. - -**Metrics:** total members, new members, churn, growth rate. - -**Output:** Membership Analytics Dashboard. +Done 2026-06-22: `observatory/importers/bubble.py` (JSON export → +`membership.json`), `membership_analytics` in dashboard API, sample export under +`data/imports/bubble-export.sample.json`. ## Sprint 3 — Stripe Integration ```task id: ADAPTIVE-WP-0002-T03 -status: wait +status: done priority: high state_hub_task_id: "c7e308bc-5977-40c8-985a-9dca2ad3984a" ``` Capture actual revenue and payment costs. -**Deliverables:** Stripe synchronization, revenue tracking, fee tracking, refund -tracking. - -**Metrics:** net revenue, Stripe fees, revenue per member. - -**Output:** Revenue Dashboard. +Done 2026-06-22: `observatory/importers/stripe.py` (charge export → +`payment_records.json`); live ledger already holds tegwick Stripe payments. ## Sprint 4 — OpenRouter Cost Attribution ```task id: ADAPTIVE-WP-0002-T04 -status: wait +status: done priority: high state_hub_task_id: "b2b61910-429c-46e9-93b8-25702ca337a7" ``` Track AI usage and cost. -**Deliverables:** OpenRouter usage importer, cost attribution per user, -model-level cost tracking, token accounting. - -**Metrics:** cost per user, cost per model, total AI spend. - -**Output:** AI Cost Dashboard. +Done 2026-06-22: `observatory/importers/openrouter.py`, `data/usage_records.json`, +`observatory/usage.py` (per-member and per-model attribution in API). ## Sprint 5 — Cost Allocation Engine ```task id: ADAPTIVE-WP-0002-T05 -status: wait +status: done priority: medium state_hub_task_id: "906009be-5670-428a-b6d1-2700c67e9c65" ``` Calculate economic reality. -**Deliverables:** - -- Fixed cost allocation (Bubble.io, domains, infrastructure) -- Variable cost allocation (Stripe fees, OpenRouter costs) -- CostFloor, contribution margin, cost per member calculations - -**Output:** CostFloor Report. +Done 2026-06-22: `observatory/allocation.py` — fixed/variable split, cost floor, +contribution margin in `/api/dashboard` (`cost_allocation`). ## Sprint 6 — Pricing Simulator ```task id: ADAPTIVE-WP-0002-T06 -status: wait +status: done priority: medium state_hub_task_id: "cb735e7d-72ac-41df-97d4-e0133cb4bb84" ``` Evaluate pricing scenarios. -**Example scenarios:** - -- Current: €8.99/month, unlimited access -- Membership + credits: €8.99/month with included AI allowance -- Membership + overage: €8.99/month with included credits and usage-based overage -- Lower subscription: lower base fee with higher usage fees - -**Output:** Pricing Explorer. +Done 2026-06-22: `observatory/simulator.py` compares active and candidate models +from `pricing-models.json` (`pricing_simulations` in API). ## Sprint 7 — Membership Credit System ```task id: ADAPTIVE-WP-0002-T07 -status: wait +status: done priority: medium state_hub_task_id: "aa8efb52-dbd8-4309-a15d-ea04d80c57f6" ``` Introduce AI credit accounting without billing. -**Deliverables:** credit wallet, monthly allowance, usage tracking, remaining -balance tracking. - -**Output:** Member Usage Dashboard. +Done 2026-06-22: `data/credit_wallets.json`, `observatory/credits.py` +(observatory-only wallet balances in API). ## Sprint 8 — Adaptive Pricing Prototype ```task id: ADAPTIVE-WP-0002-T08 -status: wait +status: done priority: low state_hub_task_id: "d8195bf0-5b0d-4fbd-9776-0b619097c64f" ``` Implement first pricing optimization logic. -**Deliverables:** pricing parameter model, constraint model, seller economics -model, comparable customer LTV prototype, pricing recommendation engine. +Done 2026-06-22: `observatory/recommendations.py` — rules-based recommendations +from cost floor, value range, market signals, and simulator output. -**Output:** Adaptive Pricing Prototype v1. \ No newline at end of file +## Economic Observatory Web UI + +```task +id: ADAPTIVE-WP-0002-T09 +status: done +priority: high +state_hub_task_id: "cfcfa53d-8e7d-464f-977c-d146dd252c35" +``` + +Whynot-design UI with ledger-backed API. + +Done 2026-06-22: `ui/` + `observatory/server.py`, whynot-design vendor sync, +`docs/UI-WORKFLOW.md`. Three panels: Cost Floor, Value Range, Market Price. + +## Pricing Context Views + +```task +id: ADAPTIVE-WP-0002-T10 +status: done +priority: medium +state_hub_task_id: "563cbded-ad2a-4ee8-8779-18f7e55970df" +``` + +Cost floor, value range, and market price observatory views. + +Done 2026-06-22: `observatory/pricing_context.py`, `data/value_range.json`, +`data/market_signals.json`, wired into UI and API. \ No newline at end of file