From 4ef04dd5e52d5766f954c736d3ecbe523bef7770 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 23:05:05 +0200 Subject: [PATCH] observatory stuff --- .claude/rules/agents.md | 20 ++ .claude/rules/architecture.md | 8 + .claude/rules/first-session.md | 38 +++ .claude/rules/repo-boundary.md | 8 + .claude/rules/repo-identity.md | 5 + .claude/rules/session-protocol.md | 85 ++++++ .claude/rules/stack-and-commands.md | 19 ++ .claude/rules/workplan-convention.md | 40 +++ AGENTS.md | 28 +- CLAUDE.md | 12 + projects/coulomb-pricing/Makefile | 19 ++ projects/coulomb-pricing/README.md | 7 +- .../coulomb-pricing/data/market_signals.json | 56 ++++ .../coulomb-pricing/data/value_range.json | 40 +++ projects/coulomb-pricing/observatory/api.py | 9 + projects/coulomb-pricing/observatory/load.py | 8 + .../observatory/pricing_context.py | 93 ++++++ projects/coulomb-pricing/tests/test_api.py | 3 + .../tests/test_pricing_context.py | 61 ++++ projects/coulomb-pricing/ui/app.js | 282 +++++++++++++++--- projects/coulomb-pricing/ui/index.html | 259 ++++++++++------ projects/coulomb-pricing/ui/styles.css | 64 +++- 22 files changed, 1005 insertions(+), 159 deletions(-) create mode 100644 .claude/rules/agents.md create mode 100644 .claude/rules/architecture.md create mode 100644 .claude/rules/first-session.md create mode 100644 .claude/rules/repo-boundary.md create mode 100644 .claude/rules/repo-identity.md create mode 100644 .claude/rules/session-protocol.md create mode 100644 .claude/rules/stack-and-commands.md create mode 100644 .claude/rules/workplan-convention.md create mode 100644 CLAUDE.md create mode 100644 projects/coulomb-pricing/Makefile create mode 100644 projects/coulomb-pricing/data/market_signals.json create mode 100644 projects/coulomb-pricing/data/value_range.json create mode 100644 projects/coulomb-pricing/observatory/pricing_context.py create mode 100644 projects/coulomb-pricing/tests/test_pricing_context.py diff --git a/.claude/rules/agents.md b/.claude/rules/agents.md new file mode 100644 index 0000000..0e8a5d9 --- /dev/null +++ b/.claude/rules/agents.md @@ -0,0 +1,20 @@ +## Kaizen Agents + +Specialized agent personas available on demand via the state-hub MCP. + +**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category +**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them + +Common agents: + +| Agent | Category | When to use | +|-------|----------|-------------| +| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature | +| `code-refactoring` | quality | Code quality analysis and safe refactoring | +| `test-maintenance` | testing | Diagnose and fix failing tests | +| `requirements-engineering` | process | Prevent interface/mock mismatches upfront | +| `keepaTodofile` | process | Maintain TODO.md during work | +| `project-management` | process | Track status, determine next steps | +| `datamodel-optimization` | quality | Optimize dataclasses and data structures | + +All 17 agents: call `list_kaizen_agents()` for the full list. diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md new file mode 100644 index 0000000..7c2a645 --- /dev/null +++ b/.claude/rules/architecture.md @@ -0,0 +1,8 @@ +## Architecture + + + +## Quick Reference + +`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference diff --git a/.claude/rules/first-session.md b/.claude/rules/first-session.md new file mode 100644 index 0000000..a94c809 --- /dev/null +++ b/.claude/rules/first-session.md @@ -0,0 +1,38 @@ +## First Session Protocol + +Triggered when `get_domain_summary("financials")` shows **no workstreams**. +The project is registered but work has not yet been structured. + +**Step 1 — Read, don't write** +- `~/the-custodian/canon/projects/financials/project_charter_v0.1.md` — purpose, scope +- `~/the-custodian/canon/projects/financials/roadmap_v0.1.md` — planned phases +- Scan repo root: README, directory structure, existing code or docs + +**Step 2 — Survey in-progress work** +Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete. + +**Step 3 — Propose workstreams to Bernd** +Propose 1–3 workstreams — each a coherent strand, weeks to months, anchored to a +roadmap phase. **Wait for approval before creating.** + +**Step 4 — Create workplan file first, then DB record (ADR-001)** +``` +workplans/ADAPTIVE-WP-NNNN-.md ← write this first +``` +Then register in the hub: +``` +create_workstream(topic_id="f39fa2a3-c491-414c-a91b-b4c5fcc6139c", title="...", owner="...", description="...") +create_task(workstream_id="", title="...", priority="high|medium|low") +``` + +**Step 5 — Record the setup** +``` +add_progress_event( + summary="First session: structured financials into N workstreams, M tasks", + event_type="milestone", + topic_id="f39fa2a3-c491-414c-a91b-b4c5fcc6139c", + detail={"workstreams": [...], "tasks_created": M} +) +``` + + diff --git a/.claude/rules/repo-boundary.md b/.claude/rules/repo-boundary.md new file mode 100644 index 0000000..4f2e3c1 --- /dev/null +++ b/.claude/rules/repo-boundary.md @@ -0,0 +1,8 @@ +## Repo boundary + +This repo owns **adaptive-pricing** only. It does not own: + + diff --git a/.claude/rules/repo-identity.md b/.claude/rules/repo-identity.md new file mode 100644 index 0000000..34f4f63 --- /dev/null +++ b/.claude/rules/repo-identity.md @@ -0,0 +1,5 @@ +**Purpose:** Auto-regulating market value exploring price engine. + +**Domain:** financials +**Repo slug:** adaptive-pricing +**Topic ID:** f39fa2a3-c491-414c-a91b-b4c5fcc6139c diff --git a/.claude/rules/session-protocol.md b/.claude/rules/session-protocol.md new file mode 100644 index 0000000..19ece28 --- /dev/null +++ b/.claude/rules/session-protocol.md @@ -0,0 +1,85 @@ +## Session Protocol + +Dev Hub (State Hub API): http://127.0.0.1:8000 +MCP server name in `~/.claude.json`: `dev-hub` + +**Step 1 — Orient** + +Read the offline-safe brief first — it works without a live hub connection: +```bash +cat .custodian-brief.md +``` +Then call the MCP tool for richer cross-domain context when MCP tools are exposed: +``` +get_domain_summary("financials") +``` +If MCP tools are unavailable in the current agent session, use the REST API: +```bash +curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool +``` +If the hub is offline: `cd ~/state-hub && make api` + +**Step 2 — Check inbox** +With MCP tools: +``` +get_messages(to_agent="adaptive-pricing", unread_only=True) +``` +Mark read with `mark_message_read(message_id)`. Reply or act on coordination +requests before proceeding. + +Without MCP tools: +```bash +curl -s "http://127.0.0.1:8000/messages/?to_agent=adaptive-pricing&unread_only=true" \ + | python3 -m json.tool +curl -s -X PATCH "http://127.0.0.1:8000/messages//read" \ + -H "Content-Type: application/json" -d '{}' +``` + +**Step 3 — Scan workplans** +```bash +ls workplans/ +``` +For each file with `status: ready`, `active`, or `blocked`, note pending +`wait`/`todo`/`progress` tasks. + +**Step 4 — Present brief** + +1. **Active workstreams** for `financials` — title, task counts, blocking decisions +2. **Pending tasks** from `workplans/` + any `[repo:adaptive-pricing]` hub tasks +3. **Goal guidance** — if `goal_guidance` in summary: + - `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"* + - `alignment_warnings`: flag if active work is not aligned with current goal +4. **Suggested next action** — highest-priority open item +5. **SBOM status** — flag if `last_sbom_at` is unset for this repo + +If no workstreams: follow First Session Protocol (`first-session.md`). + +**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()` + +> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`) +> are First Session Protocol only. Work structure belongs in repo files (ADR-001). + +**Session close:** +With MCP tools: +``` +add_progress_event(summary="...", topic_id="f39fa2a3-c491-414c-a91b-b4c5fcc6139c", workstream_id="") +``` +Without MCP tools: +```bash +curl -s -X POST http://127.0.0.1:8000/progress/ \ + -H "Content-Type: application/json" \ + -d '{"topic_id":"f39fa2a3-c491-414c-a91b-b4c5fcc6139c","workstream_id":"","event_type":"note","summary":"what changed","author":"codex"}' +``` +If workplan files were modified, ensure the local copy is up to date first: +```bash +git -C pull --ff-only +cd ~/state-hub && make fix-consistency REPO=adaptive-pricing +``` +For repos where implementation runs on a remote machine (e.g. CoulombCore), +use the combined target which pulls before fixing: +```bash +cd ~/state-hub && make fix-consistency-remote REPO=adaptive-pricing +``` +**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback +will sync the file to match DB. **C-16** (repo behind remote) blocks all writes +until you pull — intentional to prevent clobbering remote progress. diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md new file mode 100644 index 0000000..dc53ac6 --- /dev/null +++ b/.claude/rules/stack-and-commands.md @@ -0,0 +1,19 @@ +## Stack + + +- **Language:** +- **Key deps:** + +## Dev Commands + +```bash +# TODO: Fill in the standard commands for this repo + +# Install dependencies + +# Run tests + +# Lint / type check + +# Build / package (if applicable) +``` diff --git a/.claude/rules/workplan-convention.md b/.claude/rules/workplan-convention.md new file mode 100644 index 0000000..7a35a1d --- /dev/null +++ b/.claude/rules/workplan-convention.md @@ -0,0 +1,40 @@ +## Workplan Convention (ADR-001) + +File location: `workplans/ADAPTIVE-WP-NNNN-.md` +ID prefix: `ADAPTIVE-WP-` + +Work items originate as files in this repo **before** being registered in the hub. + +Canonical workplan/workstream frontmatter statuses are: +`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`. +Use `proposed` for a newly drafted plan, `ready` after review against current +repo state, and `finished` when implementation is complete. `stalled` and +`needs_review` are derived health labels, not stored statuses. + +Closed workplans may be moved to `workplans/archived/` with a completion-date +prefix: `YYMMDD-ADAPTIVE-WP-NNNN-.md`. The frontmatter id remains +unchanged; the prefix is only for quick visual reference. + +Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**: +`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids +`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed +directly. Promote anything requiring analysis, design, approval, dependencies, or +multiple planned phases into a normal workplan. + +Ecosystem todos from other agents arrive as `[repo:adaptive-pricing]` hub tasks — +visible at session start. Pick one up by creating the workplan file, then registering +the workstream. + +Task blocks use this shape: + +```task +id: ADAPTIVE-WP-NNNN-T01 +status: wait | todo | progress | done | cancel +priority: high | medium | low +state_hub_task_id: "" # written by fix-consistency — do not edit +``` + +Status progression is `todo` → `progress` → `done`; use `wait` for waiting or +blocked work and `cancel` for stopped work. + + diff --git a/AGENTS.md b/AGENTS.md index d64fee4..2090791 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,37 +4,13 @@ **Purpose:** Auto-regulating market value exploring price engine. -**Domain:** helix_forge +**Domain:** financials **Repo slug:** adaptive-pricing **Topic ID:** `f39fa2a3-c491-414c-a91b-b4c5fcc6139c` **Workplan prefix:** `ADAPTIVE-WP-` --- -## Dev Workflow - -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 | -|------|---------| -| 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` | -| Run: observatory UI | `cd projects/coulomb-pricing && python3 -m observatory.server` → http://127.0.0.1:8765/ | -| 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 `python3 -m pytest` under -`projects/coulomb-pricing`, then `make fix-consistency` (expect PASS). - ---- - ## State Hub Integration The Custodian State Hub tracks work across all domains. Interact via HTTP REST — @@ -176,8 +152,6 @@ get wrong. **Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml` ---- - diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b314e22 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +# adaptive-pricing — Claude Code Instructions + +@SCOPE.md +@.claude/rules/repo-identity.md +@.claude/rules/session-protocol.md +@.claude/rules/first-session.md +@.claude/rules/workplan-convention.md +@.claude/rules/stack-and-commands.md +@.claude/rules/architecture.md +@.claude/rules/repo-boundary.md +@.claude/rules/credential-routing.md +@.claude/rules/agents.md diff --git a/projects/coulomb-pricing/Makefile b/projects/coulomb-pricing/Makefile new file mode 100644 index 0000000..27ff9d4 --- /dev/null +++ b/projects/coulomb-pricing/Makefile @@ -0,0 +1,19 @@ +.PHONY: help design test serve + +.DEFAULT_GOAL := help + +REF ?= + +help: ## List available make targets + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " %-12s %s\n", $$1, $$2}' + +design: ## Re-vendor whynot-design (optional: REF=v0.2.1 or commit SHA) + ./scripts/sync-whynot-design.sh $(REF) + python3 -m pytest -q tests/test_ui_vendor.py + +test: ## Run the full pytest suite + python3 -m pytest -q + +serve: ## Start the Economic Observatory UI on :8765 + python3 -m observatory.server \ No newline at end of file diff --git a/projects/coulomb-pricing/README.md b/projects/coulomb-pricing/README.md index 2498439..47a7906 100644 --- a/projects/coulomb-pricing/README.md +++ b/projects/coulomb-pricing/README.md @@ -32,11 +32,12 @@ cost-pass-through billing is not active. ```bash cd projects/coulomb-pricing -python3 -m pytest -q +make test +make design # pull whynot-design tokens/CSS/components +make design REF=v0.2.1 # bump to a tag or commit python3 -m observatory --period 2026-06 python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md -./scripts/sync-whynot-design.sh # pull whynot-design tokens/CSS/components -python3 -m observatory.server +make serve ``` Open **http://127.0.0.1:8765/** for the Economic Observatory UI (served from diff --git a/projects/coulomb-pricing/data/market_signals.json b/projects/coulomb-pricing/data/market_signals.json new file mode 100644 index 0000000..2c87f54 --- /dev/null +++ b/projects/coulomb-pricing/data/market_signals.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "currency": "EUR", + "last_reviewed": "2026-06", + "alternatives": [ + { + "id": "github-pro", + "name": "GitHub Pro", + "category": "indirect", + "price_monthly_eur": "4.00", + "billing": "monthly", + "features": ["private repos", "protected branches", "CI minutes"], + "signal_level": "S2", + "observed": "2026-06", + "source": "public pricing page", + "note": "Substitute for repository hosting; lacks community and pricing-intelligence positioning." + }, + { + "id": "circle-community", + "name": "Circle (community platform)", + "category": "indirect", + "price_monthly_eur": "49.00", + "billing": "monthly", + "features": ["courses", "events", "member spaces", "payments"], + "signal_level": "S2", + "observed": "2026-06", + "source": "public pricing page", + "note": "Higher floor for hosted community; Coulomb is narrower and operator-led." + }, + { + "id": "patreon-creator", + "name": "Patreon (creator membership)", + "category": "substitute", + "price_monthly_eur": "8.00", + "billing": "monthly", + "features": ["member posts", "Discord integration", "paywall"], + "signal_level": "S1", + "observed": "2026-06", + "source": "public pricing page", + "note": "Comparable entry price point; different delivery model (content vs repo access)." + }, + { + "id": "build-in-house", + "name": "Build in-house community", + "category": "workaround", + "price_monthly_eur": "0.00", + "billing": "operator time", + "features": ["self-hosted forum", "manual onboarding", "no shared pricing lab"], + "signal_level": "S3", + "observed": "2026-06", + "source": "operator estimate", + "note": "Hidden cost in time; common alternative for technical founders." + } + ], + "notes": "Market signals are manually curated until competitive intelligence imports exist." +} \ No newline at end of file diff --git a/projects/coulomb-pricing/data/value_range.json b/projects/coulomb-pricing/data/value_range.json new file mode 100644 index 0000000..d08e1b1 --- /dev/null +++ b/projects/coulomb-pricing/data/value_range.json @@ -0,0 +1,40 @@ +{ + "version": 1, + "currency": "EUR", + "product_id": "coulomb-social-membership", + "segments": [ + { + "id": "solo-builder", + "name": "Solo builder", + "low_eur": "8.99", + "high_eur": "19.00", + "confidence": "hypothesis", + "drivers": ["private repository access", "async community", "low switching cost"], + "evidence": "Founding member joined at list price without negotiation." + }, + { + "id": "small-team", + "name": "Small product team", + "low_eur": "12.00", + "high_eur": "29.00", + "confidence": "hypothesis", + "drivers": ["shared norms", "pricing experiment sandbox", "operator proximity"], + "evidence": "No team-tier offer yet; inferred from comparable community memberships." + } + ], + "value_drivers": [ + { + "id": "repo-access", + "label": "Repository & community access", + "strength": "S2", + "note": "Core deliverable today; differentiated by operator involvement, not feature breadth." + }, + { + "id": "pricing-lab", + "label": "Live pricing laboratory", + "strength": "S1", + "note": "Members observe real operator economics; value increases as observatory matures." + } + ], + "notes": "Value range is observatory-only until willingness-to-pay interviews and usage signals exist." +} \ No newline at end of file diff --git a/projects/coulomb-pricing/observatory/api.py b/projects/coulomb-pricing/observatory/api.py index 008956f..1fa8dd2 100644 --- a/projects/coulomb-pricing/observatory/api.py +++ b/projects/coulomb-pricing/observatory/api.py @@ -11,12 +11,15 @@ from .load import ( latest_period, load_budget, load_expense_records, + load_market_signals, load_membership, load_monthly_ledger, load_payment_records, load_pricing_models, load_product, + load_value_range, ) +from .pricing_context import build_cost_floor, build_market_price_view, build_value_range_view def _serialize(value: Any) -> Any: @@ -72,6 +75,9 @@ 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) + return _serialize( { "design_reference": "https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511", @@ -85,6 +91,9 @@ 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), "infrastructure": { "domains": _load_json_catalog(root, "domains.json"), "virtual_servers": _load_json_catalog(root, "virtual_servers.json"), diff --git a/projects/coulomb-pricing/observatory/load.py b/projects/coulomb-pricing/observatory/load.py index d2704a9..b8ec970 100644 --- a/projects/coulomb-pricing/observatory/load.py +++ b/projects/coulomb-pricing/observatory/load.py @@ -116,6 +116,14 @@ def load_payment_records(data_dir: Path | None = None) -> list[PaymentRecord]: ] +def load_value_range(data_dir: Path | None = None) -> dict: + return _read_json((data_dir or default_data_dir()) / "value_range.json") + + +def load_market_signals(data_dir: Path | None = None) -> dict: + return _read_json((data_dir or default_data_dir()) / "market_signals.json") + + def load_membership(data_dir: Path | None = None) -> list[MembershipRecord]: raw = _read_json((data_dir or default_data_dir()) / "membership.json") return [ diff --git a/projects/coulomb-pricing/observatory/pricing_context.py b/projects/coulomb-pricing/observatory/pricing_context.py new file mode 100644 index 0000000..859f7ca --- /dev/null +++ b/projects/coulomb-pricing/observatory/pricing_context.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from decimal import Decimal, ROUND_HALF_UP + +from .economics import active_pricing_model +from .models import EconomicsSnapshot, PricingModel, Product + +TWOPLACES = Decimal("0.01") + + +def _quantize(value: Decimal) -> Decimal: + return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP) + + +def build_cost_floor( + snapshot: EconomicsSnapshot, + models: list[PricingModel], +) -> dict: + active = next((m for m in models if m.status == "active"), None) + return { + "period": snapshot.period, + "currency": snapshot.currency, + "monthly_infrastructure_cost": snapshot.monthly_infrastructure_cost, + "monthly_payment_processing_cost": snapshot.monthly_payment_processing_cost, + "monthly_total_platform_cost": snapshot.monthly_total_platform_cost, + "cost_per_member": snapshot.cost_per_member, + "active_members": snapshot.active_members, + "monthly_revenue": snapshot.monthly_revenue, + "gross_margin": snapshot.gross_margin, + "gross_margin_pct": snapshot.gross_margin_pct, + "active_price": active.access_fee_amount if active else Decimal("0"), + "active_model_id": active.id if active else None, + "active_model_name": active.name if active else None, + } + + +def build_value_range_view( + raw: dict, + snapshot: EconomicsSnapshot, + product: Product, + models: list[PricingModel], +) -> dict: + model = active_pricing_model(models, product) + current = model.access_fee_amount + segments = [] + lows: list[Decimal] = [] + highs: list[Decimal] = [] + for item in raw.get("segments", []): + low = Decimal(str(item["low_eur"])) + high = Decimal(str(item["high_eur"])) + lows.append(low) + highs.append(high) + segments.append( + { + **item, + "headroom_to_high_eur": _quantize(high - current), + "below_floor": current < low, + } + ) + + aggregate_low = min(lows) if lows else current + aggregate_high = max(highs) if highs else current + + return { + "currency": raw.get("currency", snapshot.currency), + "product_id": raw.get("product_id", product.id), + "current_price_eur": current, + "aggregate_low_eur": aggregate_low, + "aggregate_high_eur": aggregate_high, + "cost_per_member_eur": snapshot.cost_per_member, + "segments": segments, + "value_drivers": raw.get("value_drivers", []), + "notes": raw.get("notes", ""), + } + + +def build_market_price_view(raw: dict) -> dict: + alternatives = list(raw.get("alternatives", [])) + prices = [ + Decimal(str(item["price_monthly_eur"])) + for item in alternatives + if item.get("price_monthly_eur") not in (None, "", "0", "0.00") + ] + return { + "currency": raw.get("currency", "EUR"), + "last_reviewed": raw.get("last_reviewed"), + "alternative_count": len(alternatives), + "priced_alternative_count": len(prices), + "market_low_eur": min(prices) if prices else None, + "market_high_eur": max(prices) if prices else None, + "alternatives": alternatives, + "notes": raw.get("notes", ""), + } \ 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 212d53c..b9876a0 100644 --- a/projects/coulomb-pricing/tests/test_api.py +++ b/projects/coulomb-pricing/tests/test_api.py @@ -18,6 +18,9 @@ def test_dashboard_payload_contains_live_ledger_totals() -> None: assert payload["snapshot"]["monthly_infrastructure_cost"] == "29.73" assert len(payload["history"]) == 18 assert payload["expense_record_count"] == 58 + assert payload["cost_floor"]["active_price"] == "8.99" + assert len(payload["value_range"]["segments"]) == 2 + assert payload["market_price"]["alternative_count"] == 4 def test_payload_json_is_valid() -> None: diff --git a/projects/coulomb-pricing/tests/test_pricing_context.py b/projects/coulomb-pricing/tests/test_pricing_context.py new file mode 100644 index 0000000..9c07b07 --- /dev/null +++ b/projects/coulomb-pricing/tests/test_pricing_context.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from decimal import Decimal +from pathlib import Path + +from observatory.economics import build_snapshot +from observatory.load import ( + load_market_signals, + load_membership, + load_monthly_ledger, + load_payment_records, + load_pricing_models, + load_product, + load_value_range, +) +from observatory.pricing_context import ( + build_cost_floor, + build_market_price_view, + build_value_range_view, +) + +DATA_DIR = Path(__file__).resolve().parent.parent / "data" + + +def _snapshot_for(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_cost_floor_derives_from_snapshot() -> None: + snapshot = _snapshot_for() + models = load_pricing_models(DATA_DIR) + floor = build_cost_floor(snapshot, models) + + assert floor["cost_per_member"] == snapshot.cost_per_member + assert floor["active_price"] == Decimal("8.99") + assert floor["active_model_id"] == "flat-899-eur-monthly" + + +def test_value_range_computes_headroom() -> None: + snapshot = _snapshot_for() + product = load_product(DATA_DIR) + models = load_pricing_models(DATA_DIR) + view = build_value_range_view(load_value_range(DATA_DIR), snapshot, product, models) + + assert view["current_price_eur"] == Decimal("8.99") + assert len(view["segments"]) == 2 + solo = next(item for item in view["segments"] if item["id"] == "solo-builder") + assert solo["headroom_to_high_eur"] == Decimal("10.01") + + +def test_market_price_summarises_alternatives() -> None: + view = build_market_price_view(load_market_signals(DATA_DIR)) + + assert view["alternative_count"] == 4 + assert view["market_low_eur"] == Decimal("4.00") + assert view["market_high_eur"] == Decimal("49.00") \ No newline at end of file diff --git a/projects/coulomb-pricing/ui/app.js b/projects/coulomb-pricing/ui/app.js index 46d2583..f55026c 100644 --- a/projects/coulomb-pricing/ui/app.js +++ b/projects/coulomb-pricing/ui/app.js @@ -1,5 +1,69 @@ const euro = (value) => `€${Number(value).toFixed(2)}`; +const SECTION_META = { + "cost-floor": { + eyebrow: "Economic Observatory · Cost Floor", + title: "Operator liquidity", + lede: (data) => + `Period ${data.period}. Minimum viable economics: platform cost, margin, and liquidity burn from ledgers.`, + }, + "value-range": { + eyebrow: "Economic Observatory · Value Range", + title: "Customer value bands", + lede: (data) => + `Period ${data.period}. Segment-level willingness-to-pay hypotheses above the cost floor.`, + }, + "market-price": { + eyebrow: "Economic Observatory · Market Price", + title: "Competitive context", + lede: (data) => + `Reviewed ${data.market_price.last_reviewed ?? "—"}. Evidence about alternatives, capabilities, and public pricing.`, + }, +}; + +let currentData = null; +let activeSection = "cost-floor"; + +function normalizeData(data) { + const snapshot = data.snapshot ?? {}; + const active = (data.pricing_models ?? []).find((model) => model.status === "active"); + + if (!data.cost_floor) { + data.cost_floor = { + cost_per_member: snapshot.cost_per_member ?? "0", + monthly_total_platform_cost: snapshot.monthly_total_platform_cost ?? "0", + active_price: active?.access_fee_amount ?? snapshot.monthly_revenue ?? "0", + active_model_name: active?.name ?? null, + }; + } + + if (!data.value_range) { + data.value_range = { + current_price_eur: active?.access_fee_amount ?? snapshot.monthly_revenue ?? "0", + aggregate_low_eur: active?.access_fee_amount ?? "0", + aggregate_high_eur: active?.access_fee_amount ?? "0", + cost_per_member_eur: snapshot.cost_per_member ?? "0", + segments: [], + value_drivers: [], + notes: "Value range data unavailable — restart observatory.server to load the latest API.", + }; + } + + if (!data.market_price) { + data.market_price = { + alternative_count: 0, + priced_alternative_count: 0, + market_low_eur: null, + market_high_eur: null, + last_reviewed: null, + alternatives: [], + notes: "Market signals unavailable — restart observatory.server to load the latest API.", + }; + } + + return data; +} + async function loadDashboard(period) { const query = period ? `?period=${encodeURIComponent(period)}` : ""; const response = await fetch(`/api/dashboard${query}`); @@ -13,7 +77,36 @@ function setBadge(el, status) { el.draft = status === "burning"; } -function renderMetrics(data) { +function setSection(sectionId) { + activeSection = sectionId; + document.querySelectorAll(".obs-panel").forEach((panel) => { + const active = panel.dataset.section === sectionId; + panel.hidden = !active; + panel.classList.toggle("obs-panel--active", active); + }); + document.querySelectorAll("wn-sidebar-item[data-section]").forEach((item) => { + item.active = item.dataset.section === sectionId; + }); + + const meta = SECTION_META[sectionId]; + const header = document.getElementById("page-header"); + header.eyebrow = meta.eyebrow; + header.title = meta.title; + if (currentData) { + header.lede = meta.lede(currentData); + } + + const badge = document.getElementById("liquidity-badge"); + badge.style.display = sectionId === "cost-floor" ? "" : "none"; +} + +function bindNavigation() { + document.querySelectorAll("wn-sidebar-item[data-section]").forEach((item) => { + item.addEventListener("click", () => setSection(item.dataset.section)); + }); +} + +function renderCostFloor(data) { const { snapshot, liquidity } = data; const cards = [ { @@ -27,14 +120,14 @@ function renderMetrics(data) { sub: snapshot.liquidity_status, }, { - label: "Active members", - value: snapshot.active_members, - sub: data.members[0]?.username ? `@${data.members[0].username}` : "—", + label: "Cost per member", + value: euro(data.cost_floor.cost_per_member), + sub: `Platform ${euro(data.cost_floor.monthly_total_platform_cost)} / mo`, }, { - label: "Member payment (gross)", - value: euro(snapshot.monthly_revenue), - sub: `Net ${euro(data.payments.at(-1)?.net_amount ?? 0)} after Stripe`, + label: "Gross margin", + value: euro(snapshot.gross_margin), + sub: `${snapshot.gross_margin_pct}% of gross revenue`, }, { label: "Infrastructure / month", @@ -42,9 +135,9 @@ function renderMetrics(data) { sub: `Processing ${euro(snapshot.monthly_payment_processing_cost)}`, }, { - label: "Cumulative net liquidity", - value: euro(liquidity.cumulative_net_liquidity), - sub: liquidity.liquidity_status, + label: "Active price", + value: euro(data.cost_floor.active_price), + sub: data.cost_floor.active_model_name ?? "—", }, ]; @@ -58,18 +151,14 @@ function renderMetrics(data) { ` ) .join(""); -} -function renderBudget(data) { - const { liquidity } = data; const initial = Number(liquidity.initial_budget); const remaining = Number(liquidity.remaining_budget); const pct = Math.max(0, Math.min(100, (remaining / initial) * 100)); const banner = document.getElementById("budget-banner"); - const caption = remaining >= 0 ? "Within allocated budget" : "Over allocated budget"; - document.getElementById("budget-fill").style.width = `${pct}%`; - document.getElementById("budget-caption").textContent = caption; + document.getElementById("budget-caption").textContent = + remaining >= 0 ? "Within allocated budget" : "Over allocated budget"; banner.variant = remaining < initial * 0.25 ? "warn" : "info"; document.getElementById("budget-stats").innerHTML = [ @@ -85,11 +174,9 @@ function renderBudget(data) { ` ) .join(""); -} -function renderChart(history) { - const max = Math.max(...history.map((row) => Math.abs(Number(row.net_liquidity))), 1); - document.getElementById("liquidity-chart").innerHTML = history + const max = Math.max(...data.history.map((row) => Math.abs(Number(row.net_liquidity))), 1); + document.getElementById("liquidity-chart").innerHTML = data.history .map((row) => { const value = Number(row.net_liquidity); const width = (Math.abs(value) / max) * 50; @@ -105,14 +192,12 @@ function renderChart(history) { `; }) .join(""); -} -function renderInfra(data) { const domains = data.infrastructure.domains?.domains ?? []; const servers = data.infrastructure.virtual_servers?.servers ?? []; const stripe = data.infrastructure.stripe?.membership ?? {}; const payout = data.infrastructure.stripe?.payout_account ?? "payout"; - const items = [ + const infraItems = [ ...domains.map( (d) => `${d.monthly_eur} EUR/mo · ${d.tld}` @@ -123,10 +208,8 @@ function renderInfra(data) { ), `${stripe.gross_monthly_eur} gross · ${stripe.fee_monthly_eur} fee · ${stripe.net_payout_monthly_eur} net to ${payout}`, ]; - document.getElementById("infra-stack").innerHTML = `
${items.join("")}
`; -} + document.getElementById("infra-stack").innerHTML = `
${infraItems.join("")}
`; -function renderTables(data) { document.getElementById("history-body").innerHTML = data.history .map( (row) => ` @@ -152,6 +235,138 @@ function renderTables(data) { ` ) .join(""); + + setBadge(document.getElementById("liquidity-badge"), snapshot.liquidity_status); +} + +function renderValueRangeBand(low, high, current) { + const min = Number(low); + const max = Number(high); + const cur = Number(current); + const span = Math.max(max - min, 0.01); + const curPct = Math.max(0, Math.min(100, ((cur - min) / span) * 100)); + return ` +
+
+ ${euro(min)} + ${euro(cur)} now + ${euro(max)} +
+
+
+
+
+
`; +} + +function renderValueRange(data) { + const vr = data.value_range; + document.getElementById("value-summary").innerHTML = [ + { + label: "Current price", + value: euro(vr.current_price_eur), + sub: data.cost_floor.active_model_name ?? "—", + }, + { + label: "Aggregate band", + value: `${euro(vr.aggregate_low_eur)} – ${euro(vr.aggregate_high_eur)}`, + sub: `${vr.segments.length} segments`, + }, + { + label: "Cost per member", + value: euro(vr.cost_per_member_eur), + sub: "Cost floor reference", + }, + ] + .map( + (card) => ` + + ${card.label} +
${card.value}
+ ${card.sub} +
` + ) + .join(""); + + document.getElementById("value-range-notes").textContent = vr.notes || ""; + document.getElementById("value-segments").innerHTML = vr.segments + .map( + (segment) => ` + + ${segment.name} + ${segment.confidence} + ${renderValueRangeBand(segment.low_eur, segment.high_eur, vr.current_price_eur)} +

${segment.evidence}

+ Headroom ${euro(segment.headroom_to_high_eur)} + ${segment.drivers.join(" · ")} +
` + ) + .join(""); + + document.getElementById("value-drivers").innerHTML = vr.value_drivers + .map( + (driver) => ` + + ${driver.note} + ${driver.strength} + ` + ) + .join(""); +} + +function renderMarketPrice(data) { + const market = data.market_price; + const span = + market.market_low_eur != null && market.market_high_eur != null + ? `${euro(market.market_low_eur)} – ${euro(market.market_high_eur)}` + : "—"; + + document.getElementById("market-summary").innerHTML = [ + { + label: "Alternatives tracked", + value: market.alternative_count, + sub: `${market.priced_alternative_count} with public monthly price`, + }, + { + label: "Priced span", + value: span, + sub: `Reviewed ${market.last_reviewed ?? "—"}`, + }, + { + label: "Coulomb list price", + value: euro(data.value_range.current_price_eur), + sub: data.cost_floor.active_model_name ?? "—", + }, + ] + .map( + (card) => ` + + ${card.label} +
${card.value}
+ ${card.sub} +
` + ) + .join(""); + + document.getElementById("market-notes").textContent = market.notes || ""; + document.getElementById("market-body").innerHTML = market.alternatives + .map((alt) => { + const price = + alt.price_monthly_eur && Number(alt.price_monthly_eur) > 0 + ? euro(alt.price_monthly_eur) + : "—"; + const features = (alt.features ?? []).join(", "); + return ` + + ${alt.name}
${alt.id}
+ ${alt.category} + ${price} + ${alt.signal_level} + ${features} + ${alt.source} · ${alt.observed}
${alt.note} + `; + }) + .join(""); } function populatePeriods(history, current) { @@ -182,19 +397,18 @@ async function loadDesignRefLabel() { } function render(data) { + currentData = normalizeData(data); document.getElementById("design-link").href = data.design_reference; loadDesignRefLabel(); - const header = document.getElementById("page-header"); - header.lede = `Period ${data.period}. Ledger-backed view of infrastructure spend, member payments, and remaining budget. Customer cost-pass-through billing is not active.`; - setBadge(document.getElementById("liquidity-badge"), data.snapshot.liquidity_status); - renderMetrics(data); - renderBudget(data); - renderChart(data.history); - renderInfra(data); - renderTables(data); + setSection(activeSection); + renderCostFloor(data); + renderValueRange(data); + renderMarketPrice(data); populatePeriods(data.history, data.period); } +bindNavigation(); + loadDashboard() .then(render) .catch((error) => { diff --git a/projects/coulomb-pricing/ui/index.html b/projects/coulomb-pricing/ui/index.html index 5f4ed11..198a97c 100644 --- a/projects/coulomb-pricing/ui/index.html +++ b/projects/coulomb-pricing/ui/index.html @@ -26,109 +26,182 @@ -
- +
+ + + + Cost Floor + + + Value Range + + + Market Price + + + -
-
- Snapshot -
-
-
-
+
+ -
-
- Liquidity & budget -
-
- -

-
- -
-
+
+
+
+ Snapshot +
+
+
+
-
-
-
- Monthly net liquidity -
+
+
+ Liquidity & budget +
+
+ +

+
+ +
+
+ +
+
+
+ Monthly net liquidity +
+
+

Member net payments minus infrastructure, by period.

+
+
+ +
+
+ Infrastructure stack +
+
+

Domains, hosting, and Stripe reference rates.

+
+
-

Member net payments minus infrastructure, by period.

-
+ +
+
+ Monthly ledger +
+
+

Computed from expense and payment record tables.

+
+ + + + + + + + + + + + +
PeriodMembersGrossInfrastructureProcessingNet liquidity
+
+
+ +
+
+ Pricing model registry +
+
+
+ + + + + + + + + + +
IDNameTypeStatus
+
+
-
-
- Infrastructure stack -
-
-

Domains, hosting, and Stripe reference rates.

-
+ -
-
-
- Monthly ledger -
-
-

Computed from expense and payment record tables.

-
- - - - - - - - - - - - -
PeriodMembersGrossInfrastructureProcessingNet liquidity
-
-
+ -
-

- Design: - Claude design share - · - whynot-design - · totals computed programmatically from ledgers -

-
-
+
+

+ Design: + Claude design share + · + whynot-design + · totals computed programmatically from ledgers +

+
+
+ diff --git a/projects/coulomb-pricing/ui/styles.css b/projects/coulomb-pricing/ui/styles.css index 32994ed..b07c6a3 100644 --- a/projects/coulomb-pricing/ui/styles.css +++ b/projects/coulomb-pricing/ui/styles.css @@ -4,10 +4,70 @@ body { background: var(--paper); } +.obs-app { + min-height: calc(100vh - 56px); +} + .obs-main { - max-width: 1080px; - margin: 0 auto; padding: var(--sp-6) var(--sp-5) var(--sp-9); + max-width: 960px; +} + +.obs-panel[hidden] { + display: none !important; +} + +wn-sidebar-item[data-section] { + cursor: pointer; +} + +.obs-metric__value--sm { + font-size: 22px; +} + +.obs-value-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--sp-3); +} + +.obs-range { + display: grid; + gap: var(--sp-2); +} + +.obs-range__labels { + display: flex; + justify-content: space-between; + gap: var(--sp-2); + font-size: 12px; + color: var(--fg-3); +} + +.obs-range__current { + color: var(--fg-1); +} + +.obs-range__track { + position: relative; + height: 10px; + border: 1px solid var(--border); + background: var(--paper-2); +} + +.obs-range__fill { + height: 100%; + background: var(--line-strong); + width: 0; +} + +.obs-range__marker { + position: absolute; + top: -3px; + width: 2px; + height: 16px; + background: var(--ink); + transform: translateX(-1px); } .obs-period {