generated from coulomb/repo-seed
Compare commits
34 Commits
3feba6eebc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7eedb90e1 | ||
|
|
a9a55e19f1 | ||
|
|
a76e57ba89 | ||
|
|
124ad48720 | ||
|
|
386c8a46fe | ||
|
|
656bbb81a5 | ||
|
|
0a683aea5a | ||
|
|
2a9a3e690f | ||
|
|
1810ae6e29 | ||
|
|
6c6f3d40ae | ||
|
|
ab700caa4b | ||
|
|
75c94bbfe5 | ||
|
|
d11e7a0742 | ||
|
|
327d3c551b | ||
| 0a38def5a5 | |||
| 04ee6d2421 | |||
| 4ef04dd5e5 | |||
| 1bdb518a94 | |||
| f8bd6f912f | |||
| a1a90a9504 | |||
| da3b7d66f0 | |||
| 9c1c2142fc | |||
| 7b84d34ea6 | |||
| bb3f152846 | |||
| fc2324692c | |||
| 86ce511764 | |||
| 31db9f8f31 | |||
| ea2c2c6403 | |||
| fe2174f37a | |||
| 8f42220d81 | |||
| a1a4aa972f | |||
| d648a3263d | |||
| 6c02a0cfa9 | |||
| 594bb6c5f3 |
20
.claude/rules/agents.md
Normal file
20
.claude/rules/agents.md
Normal file
@@ -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.
|
||||
38
.claude/rules/architecture.md
Normal file
38
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,38 @@
|
||||
## Architecture
|
||||
|
||||
The repo has two layers:
|
||||
|
||||
1. Root framework layer
|
||||
- `INTENT.md`, `docs/`, `research/`, and `registry/` define the generic
|
||||
adaptive-pricing vocabulary, lifecycle model, and roadmap.
|
||||
- `workplans/` is the repo-native source of truth for tracked work (ADR-001).
|
||||
|
||||
2. Project implementation layer
|
||||
- `projects/coulomb-pricing/` contains the first concrete deployment:
|
||||
Coulomb Social's Economic Observatory MVP.
|
||||
- `observatory/` is a small Python package that reads JSON ledgers and
|
||||
registries from `data/`, computes economics snapshots, and serves a local UI.
|
||||
|
||||
Current Coulomb data flow:
|
||||
- `data/*.json` ledgers and registries
|
||||
- `observatory/load.py` parses JSON into dataclasses
|
||||
- `observatory/ledger.py` builds monthly cost rows
|
||||
- `observatory/economics.py` computes liquidity, margins, and snapshots
|
||||
- `observatory/allocation.py`, `usage.py`, `pricing_context.py`,
|
||||
`simulator.py`, `credits.py`, and `recommendations.py` derive higher-level
|
||||
pricing views
|
||||
- `observatory/api.py` assembles the dashboard payload
|
||||
- `observatory/__main__.py` renders the Markdown report
|
||||
- `observatory/server.py` exposes `/api/dashboard` and serves `ui/`
|
||||
|
||||
External integrations are file-based in MVP:
|
||||
- Bubble export importer
|
||||
- Stripe export importer
|
||||
- OpenRouter export importer
|
||||
|
||||
The internal model and ledgers are the source of truth. Provider exports feed
|
||||
the ledgers; they do not replace them.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference
|
||||
38
.claude/rules/first-session.md
Normal file
38
.claude/rules/first-session.md
Normal file
@@ -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-<slug>.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="<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}
|
||||
)
|
||||
```
|
||||
|
||||
<!-- Delete or archive this file once past first session -->
|
||||
12
.claude/rules/repo-boundary.md
Normal file
12
.claude/rules/repo-boundary.md
Normal file
@@ -0,0 +1,12 @@
|
||||
## Repo boundary
|
||||
|
||||
This repo owns **adaptive-pricing** only. It does not own:
|
||||
- State Hub server code, DB schema, or consistency tooling → `~/state-hub`
|
||||
- SSH certificates, login flows, or secret vending → `ops-warden`,
|
||||
`ops-bridge`, OpenBao, Keycloak, and related custody systems
|
||||
- Bubble.io, Stripe, or OpenRouter product runtimes themselves; this repo only
|
||||
models or imports their pricing-relevant data
|
||||
- Generic whynot-design source assets; this repo only vendors the UI artifacts
|
||||
needed by `projects/coulomb-pricing/ui/`
|
||||
- Unrelated Coulomb or marketplace application code outside
|
||||
`projects/coulomb-pricing/`
|
||||
10
.claude/rules/repo-identity.md
Normal file
10
.claude/rules/repo-identity.md
Normal file
@@ -0,0 +1,10 @@
|
||||
**Purpose:** Auto-regulating market value exploring price engine.
|
||||
|
||||
**Domain:** financials
|
||||
**Repo slug:** adaptive-pricing
|
||||
**State Hub topic:** helix-forge
|
||||
**Topic ID:** f39fa2a3-c491-414c-a91b-b4c5fcc6139c
|
||||
|
||||
Repo classification and hub topic are intentionally separate here:
|
||||
- Repo/business domain: `financials`
|
||||
- Shared hub topic: `helix-forge` in the hub's `infotech` domain
|
||||
91
.claude/rules/session-protocol.md
Normal file
91
.claude/rules/session-protocol.md
Normal file
@@ -0,0 +1,91 @@
|
||||
## 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")
|
||||
```
|
||||
Repo-specific work tracking still syncs through topic
|
||||
`f39fa2a3-c491-414c-a91b-b4c5fcc6139c` (`helix-forge`).
|
||||
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/<id>/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. **Repo-relevant workstreams** under topic `helix-forge` — 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="<uuid>")
|
||||
```
|
||||
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":"<uuid>","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 <repo_path> pull --ff-only
|
||||
statehub fix-consistency
|
||||
```
|
||||
Fallback when the CLI is unavailable:
|
||||
```bash
|
||||
/home/worsch/state-hub/.venv/bin/python /home/worsch/state-hub/custodian_cli.py \
|
||||
fix-consistency --repo adaptive-pricing --repo-path /home/worsch/adaptive-pricing
|
||||
```
|
||||
Legacy wrapper:
|
||||
```bash
|
||||
cd ~/state-hub && make fix-consistency 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.
|
||||
28
.claude/rules/stack-and-commands.md
Normal file
28
.claude/rules/stack-and-commands.md
Normal file
@@ -0,0 +1,28 @@
|
||||
## Stack
|
||||
|
||||
- **Language:** Markdown-heavy repo with a Python 3 implementation subtree
|
||||
- **Key deps:** Python stdlib for the observatory runtime, `pytest` for tests,
|
||||
optional `make` for wrappers, whynot-design vendored UI assets in
|
||||
`projects/coulomb-pricing/ui/vendor/`
|
||||
|
||||
## Dev Commands
|
||||
|
||||
```bash
|
||||
# Root repo: metadata/workplan sync
|
||||
statehub fix-consistency
|
||||
/home/worsch/state-hub/.venv/bin/python /home/worsch/state-hub/custodian_cli.py \
|
||||
fix-consistency --repo adaptive-pricing --repo-path /home/worsch/adaptive-pricing
|
||||
|
||||
# Project runtime
|
||||
cd /home/worsch/adaptive-pricing/projects/coulomb-pricing
|
||||
python3 -m observatory --period 2026-06
|
||||
python3 -m observatory --period 2026-06 --output reports/economics-2026-06.md
|
||||
python3 -m observatory.server
|
||||
|
||||
# Tests
|
||||
python3 -m pytest -q
|
||||
make test
|
||||
|
||||
# UI vendor refresh
|
||||
make design
|
||||
```
|
||||
40
.claude/rules/workplan-convention.md
Normal file
40
.claude/rules/workplan-convention.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## Workplan Convention (ADR-001)
|
||||
|
||||
File location: `workplans/ADAPTIVE-WP-NNNN-<slug>.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-<slug>.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: "<uuid>" # 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.
|
||||
|
||||
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->
|
||||
@@ -1,29 +1,18 @@
|
||||
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
|
||||
# Custodian Brief — adaptive-pricing
|
||||
|
||||
**Domain:** helix_forge
|
||||
**Last synced:** 2026-06-21 23:19 UTC
|
||||
**Domain:** financials
|
||||
**Last synced:** 2026-07-02 19:33 UTC
|
||||
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
|
||||
|
||||
## Active Workstreams
|
||||
|
||||
### Economic Observatory MVP (Coulomb Social)
|
||||
Progress: 0/8 done | workstream_id: `9e0b7784-702a-4bc7-b7a1-3ff801f9c768`
|
||||
|
||||
**Open tasks:**
|
||||
- ! Sprint 2 — Bubble.io Integration `42c181f9`
|
||||
- ! Sprint 3 — Stripe Integration `c7e308bc`
|
||||
- ! Sprint 4 — OpenRouter Cost Attribution `b2b61910`
|
||||
- ! Sprint 5 — Cost Allocation Engine `906009be`
|
||||
- ! Sprint 6 — Pricing Simulator `cb735e7d`
|
||||
- ! Sprint 7 — Membership Credit System `aa8efb52`
|
||||
- ! Sprint 8 — Adaptive Pricing Prototype `d8195bf0`
|
||||
- … and 1 more open tasks
|
||||
*(none — repo may need first-session setup)*
|
||||
|
||||
---
|
||||
## MCP Orientation (when available)
|
||||
|
||||
If the state-hub MCP server is reachable, call:
|
||||
`get_domain_summary("helix_forge")`
|
||||
`get_domain_summary("financials")`
|
||||
This provides richer cross-domain context.
|
||||
If the MCP call fails, use this file as your orientation source.
|
||||
|
||||
27
.repo-classification.yaml
Normal file
27
.repo-classification.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
repo_classification:
|
||||
standard: Repo Classification Standard
|
||||
version: '1.0'
|
||||
classified_at: '2026-06-22'
|
||||
classified_by: human
|
||||
category: product
|
||||
domain: financials
|
||||
secondary_domains:
|
||||
- infotech
|
||||
- agents
|
||||
capability_tags:
|
||||
- pricing
|
||||
- monetization
|
||||
- lifecycle
|
||||
- decision-support
|
||||
- product-development
|
||||
business_stake:
|
||||
- finance
|
||||
- product
|
||||
- sales
|
||||
- intelligence
|
||||
- automation
|
||||
business_mechanics:
|
||||
- intention
|
||||
- control
|
||||
- adaptation
|
||||
notes: Adaptive pricing product; standard §13.6 — human confirmed.
|
||||
94
AGENTS.md
94
AGENTS.md
@@ -4,33 +4,16 @@
|
||||
|
||||
**Purpose:** Auto-regulating market value exploring price engine.
|
||||
|
||||
**Domain:** helix_forge
|
||||
**Primary repo domain:** financials
|
||||
**Repo slug:** adaptive-pricing
|
||||
**State Hub topic:** `helix-forge`
|
||||
**Topic ID:** `f39fa2a3-c491-414c-a91b-b4c5fcc6139c`
|
||||
**Workplan prefix:** `ADAPTIVE-WP-`
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
| 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 |
|
||||
| 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`.
|
||||
`adaptive-pricing` is classified as a `financials` repo in
|
||||
`.repo-classification.yaml`, but State Hub coordination currently runs through
|
||||
the shared `helix-forge` topic in the hub's `infotech` domain. Keep repo-domain
|
||||
fields (`domain`) and hub-topic fields (`topic_slug`, `topic_id`) distinct.
|
||||
|
||||
---
|
||||
|
||||
@@ -50,7 +33,7 @@ there is no MCP server for Codex agents.
|
||||
# Offline brief — works without hub connection
|
||||
cat .custodian-brief.md
|
||||
|
||||
# Active workstreams for this domain
|
||||
# Active workstreams for this repo's hub topic
|
||||
curl -s "http://127.0.0.1:8000/workstreams/?topic_id=f39fa2a3-c491-414c-a91b-b4c5fcc6139c&status=active" \
|
||||
| python3 -m json.tool
|
||||
|
||||
@@ -103,7 +86,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
## Session Protocol
|
||||
|
||||
**Start:**
|
||||
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||
1. `cat .custodian-brief.md` — hub-topic goal and open workstreams (offline-safe)
|
||||
2. Check inbox: `GET /messages/?to_agent=adaptive-pricing&unread_only=true`; mark read
|
||||
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
|
||||
@@ -115,12 +98,22 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
||||
**Close:**
|
||||
1. Update workplan file task statuses to reflect progress
|
||||
2. Log: `POST /progress/` with a summary of what changed
|
||||
3. Note for the custodian operator: after workplan file changes, run from
|
||||
`~/state-hub`:
|
||||
3. After workplan file changes, run:
|
||||
```bash
|
||||
statehub fix-consistency
|
||||
```
|
||||
Fallback when the CLI is unavailable:
|
||||
```bash
|
||||
/home/worsch/state-hub/.venv/bin/python /home/worsch/state-hub/custodian_cli.py \
|
||||
fix-consistency --repo adaptive-pricing --repo-path /home/worsch/adaptive-pricing
|
||||
```
|
||||
Legacy wrapper:
|
||||
```bash
|
||||
cd ~/state-hub
|
||||
make fix-consistency REPO=adaptive-pricing
|
||||
```
|
||||
This syncs task status from files into the hub DB.
|
||||
Coding agents should run the direct CLI when available. This syncs task
|
||||
status from files into the hub DB.
|
||||
|
||||
---
|
||||
|
||||
@@ -175,8 +168,6 @@ get wrong.
|
||||
|
||||
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
|
||||
|
||||
---
|
||||
|
||||
<!-- REPO-AGENTS-EXTENSIONS -->
|
||||
<!-- Append repo-specific agent instructions below this marker.
|
||||
The state-hub template sync preserves content after this line. -->
|
||||
@@ -186,12 +177,42 @@ get wrong.
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `INTENT.md`, `docs/`, `research/`, `registry/` | Generic adaptive-pricing framework |
|
||||
| `projects/<slug>/` | Deployment-specific MVP material (integrations, configs, project docs) |
|
||||
| `projects/<slug>/` | Deployment-specific implementations, integrations, data, and project docs |
|
||||
| `workplans/` | ADR-001 workplans and task tracking (including MVP execution plans) |
|
||||
|
||||
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`.
|
||||
|
||||
## Developer Workflow
|
||||
|
||||
The root repo is documentation- and workplan-heavy. The current executable
|
||||
runtime lives under `projects/coulomb-pricing/`.
|
||||
|
||||
```bash
|
||||
# Generate the Coulomb observatory Markdown report
|
||||
cd projects/coulomb-pricing
|
||||
python3 -m observatory --period 2026-06
|
||||
|
||||
# Start the local observatory UI
|
||||
python3 -m observatory.server
|
||||
|
||||
# Run tests when pytest is available
|
||||
python3 -m pytest -q
|
||||
|
||||
# Makefile wrappers when make is available
|
||||
make test
|
||||
make serve
|
||||
make design
|
||||
|
||||
# Sync workplan metadata back into the hub after workplan edits
|
||||
statehub fix-consistency
|
||||
|
||||
# Fallback when the CLI is not installed
|
||||
/home/worsch/state-hub/.venv/bin/python /home/worsch/state-hub/custodian_cli.py \
|
||||
fix-consistency --repo adaptive-pricing --repo-path /home/worsch/adaptive-pricing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -218,11 +239,11 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
|
||||
id: ADAPTIVE-WP-NNNN
|
||||
type: workplan
|
||||
title: "..."
|
||||
domain: helix_forge
|
||||
domain: financials
|
||||
repo: adaptive-pricing
|
||||
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||
owner: codex
|
||||
topic_slug: ...
|
||||
topic_slug: helix-forge
|
||||
created: "YYYY-MM-DD"
|
||||
updated: "YYYY-MM-DD"
|
||||
state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
||||
@@ -252,5 +273,6 @@ Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blo
|
||||
|
||||
To create a new workplan:
|
||||
1. Write the file following the format above
|
||||
2. Notify the custodian operator to run `make fix-consistency REPO=adaptive-pricing`
|
||||
(or send a message to the hub agent via `POST /messages/`)
|
||||
2. Run `statehub fix-consistency` locally; use the direct CLI fallback above if
|
||||
`statehub` is not installed. Ask the operator only if the CLI or State Hub
|
||||
API is unavailable.
|
||||
|
||||
12
CLAUDE.md
Normal file
12
CLAUDE.md
Normal file
@@ -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
|
||||
10
README.md
10
README.md
@@ -12,12 +12,16 @@ pricing to payment-provider execution.
|
||||
|-----|---------|
|
||||
| [INTENT.md](INTENT.md) | Project purpose, problem space, lifecycle model |
|
||||
| [docs/ProductRequirementsDocument.md](docs/ProductRequirementsDocument.md) | Generic product requirements |
|
||||
| [docs/ImplementationRoadmap.md](docs/ImplementationRoadmap.md) | Milestone-based implementation path from observatory MVP to adaptive engine |
|
||||
| [docs/StripePublication.md](docs/StripePublication.md) | Provider-neutral publication model and Stripe shadow-publication flow |
|
||||
| [docs/GovernanceWorkflows.md](docs/GovernanceWorkflows.md) | Governance policy, recommendation workflow, tuning contract, and audit surfaces |
|
||||
| [AGENTS.md](AGENTS.md) | Agent instructions, dev workflow, State Hub integration |
|
||||
| [workplans/](workplans/) | Active workstreams and tasks |
|
||||
| [projects/coulomb-pricing/](projects/coulomb-pricing/) | Coulomb Social MVP deployment material |
|
||||
|
||||
## Status
|
||||
|
||||
Early framework phase (documentation and research). First implementation:
|
||||
[Economic Observatory MVP](workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md)
|
||||
for Coulomb Social.
|
||||
Framework-first repo with one finished project-specific implementation:
|
||||
[Economic Observatory MVP](workplans/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md)
|
||||
for Coulomb Social. The root remains the generic pricing framework; the current
|
||||
runtime lives under `projects/coulomb-pricing/observatory/`.
|
||||
|
||||
58
SCOPE.md
58
SCOPE.md
@@ -12,30 +12,64 @@ adapting, and implementing pricing models across the product lifecycle. See
|
||||
|
||||
## In Scope
|
||||
|
||||
- Generic framework documentation (`INTENT.md`, `docs/`, `research/`, `registry/`).
|
||||
- Pricing model vocabulary, lifecycle reasoning, and capability registry.
|
||||
- Project-specific deployments under `projects/<slug>/`.
|
||||
- Generic pricing core under `adaptive_pricing_core/`, including:
|
||||
canonical pricing schema and validation, boundary evaluation, comparable
|
||||
customer LTV, customer-tuning solver, provider-publication primitives, Stripe
|
||||
mapping, and governance models.
|
||||
- Generic framework documentation and research (`INTENT.md`, `docs/`,
|
||||
`research/`, `registry/`).
|
||||
- Project-specific proving grounds under `projects/<slug>/`, with Coulomb
|
||||
Social as the first full adopter of the generic core.
|
||||
- Local execution surfaces that keep internal pricing definitions as the source
|
||||
of truth, including provider shadow publication, governed recommendations,
|
||||
tuning contracts, health checks, and audit history.
|
||||
- State Hub workplans under `workplans/` (ADR-001).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Owning unrelated adjacent systems (Bubble.io, Stripe, OpenRouter runtimes).
|
||||
- Making irreversible operational or pricing decisions without human approval.
|
||||
- Project-specific MVP material in generic doc paths (use `projects/<slug>/`).
|
||||
- Owning unrelated adjacent systems as the source of truth, including Bubble,
|
||||
Stripe, and OpenRouter runtimes.
|
||||
- Live payment-provider mutation as the default repo behavior. Current provider
|
||||
execution stops at mapping, shadow-state publication, drift detection, and
|
||||
rollback planning.
|
||||
- Treating approximate provider mappings as fully enforced billing behavior when
|
||||
they still require supplemental operational or contract logic.
|
||||
- Autonomous customer-visible pricing rollouts or irreversible pricing changes
|
||||
without human approval.
|
||||
- Project-specific deployment material in generic doc paths (use
|
||||
`projects/<slug>/`).
|
||||
|
||||
## Current State
|
||||
|
||||
- **Phase:** early framework — documentation, research, and registry scaffolding.
|
||||
- **Runtime:** none in this repo yet; first implementation is the Coulomb Social
|
||||
Economic Observatory MVP (`ADAPTIVE-WP-0002`).
|
||||
- **Bootstrap:** State Hub integration (`ADAPTIVE-WP-0001`) wires agent orientation,
|
||||
workplan tracking, and custodian brief sync.
|
||||
- **Phase:** framework plus executable core. The repo is no longer just docs and
|
||||
research around a single MVP; it now contains reusable pricing-core modules
|
||||
and one concrete deployment.
|
||||
- **Implemented milestones:** `ADAPTIVE-WP-0003` through `ADAPTIVE-WP-0008`
|
||||
are finished. The repo now has canonical pricing models, explainable boundary
|
||||
validation, comparable-customer LTV simulation, customer-tuning, provider
|
||||
shadow publication, and governance workflows.
|
||||
- **Generic runtime surface:** reusable implementation lives under
|
||||
`adaptive_pricing_core/`.
|
||||
- **Deployment surface:** Coulomb Social remains the first proving ground under
|
||||
`projects/coulomb-pricing/observatory/`, including the dashboard/API, data
|
||||
loaders, tuning pilot, Stripe shadow-publication flow, and governed
|
||||
recommendation surfaces.
|
||||
- **Execution boundary:** Stripe support is currently a provider abstraction and
|
||||
local shadow-state publisher, not live Stripe API management.
|
||||
- **Coordination:** State Hub integration (`ADAPTIVE-WP-0001`) remains the repo
|
||||
workflow backbone for orientation, workplan tracking, and brief sync.
|
||||
|
||||
## Getting Oriented
|
||||
|
||||
- Start with: `INTENT.md`
|
||||
- Product requirements (generic): `docs/ProductRequirementsDocument.md`
|
||||
- Canonical pricing schema: `docs/PricingModelSchema.md`
|
||||
- Customer tuning: `docs/CustomerTuningSolver.md`
|
||||
- Provider publication: `docs/StripePublication.md`
|
||||
- Governance workflow: `docs/GovernanceWorkflows.md`
|
||||
- Implementation roadmap: `docs/ImplementationRoadmap.md`
|
||||
- Agent instructions: `AGENTS.md`
|
||||
- Workplans: `workplans/`
|
||||
- Coulomb MVP artifacts: `projects/coulomb-pricing/`
|
||||
- Generic code: `adaptive_pricing_core/`
|
||||
- Coulomb deployment artifacts: `projects/coulomb-pricing/`
|
||||
- Offline hub brief: `.custodian-brief.md`
|
||||
61
adaptive_pricing_core/__init__.py
Normal file
61
adaptive_pricing_core/__init__.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from .boundary_engine import (
|
||||
BoundaryPolicy,
|
||||
CommitmentTerms,
|
||||
ConstraintResult,
|
||||
PricingConfiguration,
|
||||
ValidationResult,
|
||||
default_commitment_terms,
|
||||
validate_pricing_configuration,
|
||||
)
|
||||
from .comparable_ltv import (
|
||||
ComparableCustomerProfile,
|
||||
ComparableLTVEstimate,
|
||||
LTVPolicy,
|
||||
PricingComparison,
|
||||
PricingModelComparison,
|
||||
SensitivityCase,
|
||||
SensitivityOutcome,
|
||||
compare_pricing_configurations,
|
||||
default_sensitivity_cases,
|
||||
estimate_comparable_customer_ltv,
|
||||
select_reference_estimate,
|
||||
)
|
||||
from .pricing_models import (
|
||||
ChargeComponent,
|
||||
Commitment,
|
||||
PricingModel,
|
||||
PricingModelStatus,
|
||||
TunableParameter,
|
||||
load_pricing_models,
|
||||
validate_pricing_catalog,
|
||||
validate_pricing_model,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"BoundaryPolicy",
|
||||
"ChargeComponent",
|
||||
"ComparableCustomerProfile",
|
||||
"ComparableLTVEstimate",
|
||||
"Commitment",
|
||||
"CommitmentTerms",
|
||||
"ConstraintResult",
|
||||
"LTVPolicy",
|
||||
"PricingModel",
|
||||
"PricingComparison",
|
||||
"PricingModelComparison",
|
||||
"PricingModelStatus",
|
||||
"PricingConfiguration",
|
||||
"SensitivityCase",
|
||||
"SensitivityOutcome",
|
||||
"TunableParameter",
|
||||
"ValidationResult",
|
||||
"compare_pricing_configurations",
|
||||
"default_sensitivity_cases",
|
||||
"default_commitment_terms",
|
||||
"estimate_comparable_customer_ltv",
|
||||
"load_pricing_models",
|
||||
"select_reference_estimate",
|
||||
"validate_pricing_configuration",
|
||||
"validate_pricing_catalog",
|
||||
"validate_pricing_model",
|
||||
]
|
||||
937
adaptive_pricing_core/boundary_engine.py
Normal file
937
adaptive_pricing_core/boundary_engine.py
Normal file
@@ -0,0 +1,937 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Any, Callable, Literal
|
||||
|
||||
from .pricing_models import PricingModel
|
||||
|
||||
ConstraintSeverity = Literal["hard", "soft"]
|
||||
ConstraintStatus = Literal["pass", "fail", "review"]
|
||||
ValidationDecision = Literal["accepted", "requires_approval", "rejected"]
|
||||
ConstraintEvaluator = Callable[
|
||||
["PricingConfiguration", "BoundaryPolicy", "PricingMetrics", "PricingMetrics"],
|
||||
"ConstraintResult",
|
||||
]
|
||||
|
||||
TWOPLACES = Decimal("0.01")
|
||||
PCTPLACES = Decimal("0.1")
|
||||
|
||||
|
||||
def _money(value: Decimal) -> Decimal:
|
||||
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _percent(value: Decimal) -> Decimal:
|
||||
return value.quantize(PCTPLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _decimal(value: Decimal | str | int | float | None) -> Decimal:
|
||||
if value in (None, ""):
|
||||
return Decimal("0")
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _recurring_non_usage_component_revenue(model: PricingModel) -> Decimal:
|
||||
total = Decimal("0")
|
||||
for component in model.charge_components:
|
||||
if component.kind in {"access", "usage", "setup"}:
|
||||
continue
|
||||
if component.amount is None:
|
||||
continue
|
||||
if component.cadence == "one_time":
|
||||
continue
|
||||
total += component.amount
|
||||
return total
|
||||
|
||||
|
||||
def _access_fee_amount(model: PricingModel) -> Decimal:
|
||||
for component in model.charge_components:
|
||||
if component.kind == "access" and component.amount is not None:
|
||||
return component.amount
|
||||
return model.access_fee_amount
|
||||
|
||||
|
||||
def _usage_component(model: PricingModel):
|
||||
return next((component for component in model.charge_components if component.kind == "usage"), None)
|
||||
|
||||
|
||||
def _tunable_default(model: PricingModel, key: str) -> Decimal | None:
|
||||
parameter = next((item for item in model.tunable_parameters if item.key == key), None)
|
||||
if not parameter or parameter.default_value in (None, ""):
|
||||
return None
|
||||
return Decimal(str(parameter.default_value))
|
||||
|
||||
|
||||
def _default_included_units(model: PricingModel) -> Decimal:
|
||||
usage = _usage_component(model)
|
||||
if usage and usage.included_units is not None:
|
||||
return usage.included_units
|
||||
value = _tunable_default(model, "included_tokens")
|
||||
return value if value is not None else Decimal("0")
|
||||
|
||||
|
||||
def _default_usage_unit_price(model: PricingModel) -> Decimal:
|
||||
usage = _usage_component(model)
|
||||
if usage and usage.unit_price is not None:
|
||||
return usage.unit_price
|
||||
value = _tunable_default(model, "overage_unit_price")
|
||||
return value if value is not None else Decimal("0")
|
||||
|
||||
|
||||
def _months_from_commitment(value: Decimal, unit: str | None) -> int:
|
||||
normalized = (unit or "month").lower()
|
||||
if normalized in {"month", "months"}:
|
||||
return int(value)
|
||||
if normalized in {"year", "years"}:
|
||||
return int(value * Decimal("12"))
|
||||
return int(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommitmentTerms:
|
||||
contract_duration_months: int | None = None
|
||||
minimum_monthly_turnover: Decimal = Decimal("0")
|
||||
prepaid_amount: Decimal = Decimal("0")
|
||||
guaranteed_platform_fee: Decimal = Decimal("0")
|
||||
customer_funded_onboarding: Decimal = Decimal("0")
|
||||
reduced_cancellation_flexibility: bool = False
|
||||
|
||||
|
||||
def default_commitment_terms(model: PricingModel) -> CommitmentTerms:
|
||||
contract_duration = None
|
||||
minimum_turnover = Decimal("0")
|
||||
prepaid_amount = Decimal("0")
|
||||
guaranteed_platform_fee = Decimal("0")
|
||||
|
||||
for commitment in model.commitments:
|
||||
raw_value = _decimal(commitment.value)
|
||||
if commitment.kind == "contract_duration":
|
||||
contract_duration = _months_from_commitment(raw_value, commitment.unit)
|
||||
elif commitment.kind in {"minimum_turnover", "minimum_monthly_turnover"}:
|
||||
minimum_turnover = raw_value
|
||||
elif commitment.kind in {"guaranteed_platform_fee", "minimum_platform_fee"}:
|
||||
guaranteed_platform_fee = raw_value
|
||||
elif commitment.kind == "prepayment" and (commitment.unit or "").lower() in {"eur", "usd"}:
|
||||
prepaid_amount = raw_value
|
||||
|
||||
tunable_contract_duration = _tunable_default(model, "contract_duration_months")
|
||||
if contract_duration is None and tunable_contract_duration is not None:
|
||||
contract_duration = int(tunable_contract_duration)
|
||||
|
||||
return CommitmentTerms(
|
||||
contract_duration_months=contract_duration,
|
||||
minimum_monthly_turnover=minimum_turnover,
|
||||
prepaid_amount=prepaid_amount,
|
||||
guaranteed_platform_fee=guaranteed_platform_fee,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PricingConfiguration:
|
||||
model: PricingModel
|
||||
segment: str | None = None
|
||||
expected_usage_units: Decimal = Decimal("0")
|
||||
expected_usage_variance_pct: Decimal = Decimal("0")
|
||||
allocated_fixed_cost: Decimal = Decimal("0")
|
||||
direct_cost_amount: Decimal = Decimal("0")
|
||||
unit_cost: Decimal = Decimal("0")
|
||||
support_cost: Decimal = Decimal("0")
|
||||
onboarding_cost: Decimal = Decimal("0")
|
||||
risk_cost: Decimal = Decimal("0")
|
||||
payment_fee_fixed: Decimal = Decimal("0")
|
||||
payment_fee_rate_pct: Decimal = Decimal("0")
|
||||
access_fee_amount: Decimal | None = None
|
||||
included_units: Decimal | None = None
|
||||
usage_unit_price: Decimal | None = None
|
||||
discount_amount: Decimal = Decimal("0")
|
||||
commitment_terms: CommitmentTerms = field(default_factory=CommitmentTerms)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundaryPolicy:
|
||||
minimum_margin_pct: Decimal = Decimal("0")
|
||||
target_margin_pct: Decimal = Decimal("15")
|
||||
max_payment_fee_pct: Decimal = Decimal("10")
|
||||
max_expected_usage_variance_pct: Decimal = Decimal("50")
|
||||
approval_discount_pct: Decimal = Decimal("10")
|
||||
max_discount_pct: Decimal = Decimal("25")
|
||||
minimum_contract_duration_for_discount_months: int = 3
|
||||
minimum_turnover_multiple_for_discount: Decimal = Decimal("1")
|
||||
minimum_prepayment_months_for_discount: Decimal = Decimal("1")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PricingMetrics:
|
||||
currency: str
|
||||
monthly_revenue: Decimal
|
||||
effective_monthly_revenue: Decimal
|
||||
payment_fees: Decimal
|
||||
payment_fee_pct: Decimal
|
||||
allocated_fixed_cost: Decimal
|
||||
direct_cost_amount: Decimal
|
||||
variable_usage_cost: Decimal
|
||||
support_cost: Decimal
|
||||
onboarding_cost: Decimal
|
||||
customer_funded_onboarding: Decimal
|
||||
risk_cost: Decimal
|
||||
total_monthly_cost: Decimal
|
||||
monthly_margin: Decimal
|
||||
margin_pct: Decimal
|
||||
cost_floor_revenue: Decimal
|
||||
minimum_margin_revenue: Decimal
|
||||
target_margin_revenue: Decimal
|
||||
expected_usage_units: Decimal
|
||||
included_units: Decimal
|
||||
billable_usage_units: Decimal
|
||||
unit_cost: Decimal
|
||||
usage_unit_price: Decimal
|
||||
access_fee_amount: Decimal
|
||||
contract_duration_months: int
|
||||
minimum_monthly_turnover: Decimal
|
||||
prepaid_amount: Decimal
|
||||
guaranteed_platform_fee: Decimal
|
||||
concession_value: Decimal
|
||||
concession_pct: Decimal
|
||||
baseline_monthly_revenue: Decimal
|
||||
baseline_margin: Decimal
|
||||
baseline_margin_pct: Decimal
|
||||
meaningful_commitment_signals: tuple[str, ...]
|
||||
reduced_cancellation_flexibility: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ConstraintResult:
|
||||
id: str
|
||||
title: str
|
||||
severity: ConstraintSeverity
|
||||
status: ConstraintStatus
|
||||
summary: str
|
||||
reason: str
|
||||
actual_value: Decimal | str | int | None = None
|
||||
threshold_value: Decimal | str | int | None = None
|
||||
unit: str | None = None
|
||||
details: dict[str, Any] = field(default_factory=dict)
|
||||
suggested_action: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BoundaryConstraint:
|
||||
id: str
|
||||
title: str
|
||||
severity: ConstraintSeverity
|
||||
evaluator: ConstraintEvaluator
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ValidationResult:
|
||||
model_id: str
|
||||
model_name: str
|
||||
decision: ValidationDecision
|
||||
valid: bool
|
||||
requires_approval: bool
|
||||
summary: str
|
||||
configuration: dict[str, Any]
|
||||
metrics: PricingMetrics
|
||||
policy: BoundaryPolicy
|
||||
constraints: tuple[ConstraintResult, ...]
|
||||
|
||||
|
||||
def _required_revenue(cost: Decimal, margin_pct: Decimal) -> Decimal:
|
||||
if margin_pct >= Decimal("100"):
|
||||
return Decimal("Infinity")
|
||||
ratio = Decimal("1") - (margin_pct / Decimal("100"))
|
||||
if ratio <= Decimal("0"):
|
||||
return Decimal("Infinity")
|
||||
return _money(cost / ratio)
|
||||
|
||||
|
||||
def _meaningful_commitment_signals(
|
||||
metrics: PricingMetrics,
|
||||
baseline_metrics: PricingMetrics,
|
||||
policy: BoundaryPolicy,
|
||||
) -> tuple[str, ...]:
|
||||
signals: list[str] = []
|
||||
|
||||
if metrics.minimum_monthly_turnover >= (
|
||||
metrics.monthly_revenue * policy.minimum_turnover_multiple_for_discount
|
||||
) and metrics.minimum_monthly_turnover > Decimal("0"):
|
||||
signals.append("minimum_monthly_turnover")
|
||||
|
||||
if metrics.prepaid_amount >= (
|
||||
metrics.monthly_revenue * policy.minimum_prepayment_months_for_discount
|
||||
) and metrics.prepaid_amount > Decimal("0"):
|
||||
signals.append("prepayment")
|
||||
|
||||
if metrics.guaranteed_platform_fee >= metrics.monthly_revenue and metrics.guaranteed_platform_fee > Decimal("0"):
|
||||
signals.append("guaranteed_platform_fee")
|
||||
|
||||
if metrics.customer_funded_onboarding >= metrics.onboarding_cost and metrics.onboarding_cost > Decimal("0"):
|
||||
signals.append("customer_funded_onboarding")
|
||||
|
||||
if (
|
||||
metrics.contract_duration_months >= policy.minimum_contract_duration_for_discount_months
|
||||
and metrics.contract_duration_months > baseline_metrics.contract_duration_months
|
||||
):
|
||||
signals.append("longer_contract_duration")
|
||||
|
||||
if metrics.reduced_cancellation_flexibility:
|
||||
signals.append("reduced_cancellation_flexibility")
|
||||
|
||||
return tuple(signals)
|
||||
|
||||
|
||||
def _build_metrics(
|
||||
configuration: PricingConfiguration,
|
||||
policy: BoundaryPolicy,
|
||||
*,
|
||||
baseline_metrics: PricingMetrics | None = None,
|
||||
) -> PricingMetrics:
|
||||
model = configuration.model
|
||||
defaults = default_commitment_terms(model)
|
||||
|
||||
access_fee_amount = configuration.access_fee_amount
|
||||
if access_fee_amount is None:
|
||||
access_fee_amount = _access_fee_amount(model)
|
||||
|
||||
included_units = configuration.included_units
|
||||
if included_units is None:
|
||||
included_units = _default_included_units(model)
|
||||
|
||||
usage_unit_price = configuration.usage_unit_price
|
||||
if usage_unit_price is None:
|
||||
usage_unit_price = _default_usage_unit_price(model)
|
||||
|
||||
contract_duration_months = configuration.commitment_terms.contract_duration_months
|
||||
if contract_duration_months is None:
|
||||
contract_duration_months = defaults.contract_duration_months or 1
|
||||
|
||||
minimum_monthly_turnover = (
|
||||
configuration.commitment_terms.minimum_monthly_turnover
|
||||
if configuration.commitment_terms.minimum_monthly_turnover > Decimal("0")
|
||||
else defaults.minimum_monthly_turnover
|
||||
)
|
||||
prepaid_amount = (
|
||||
configuration.commitment_terms.prepaid_amount
|
||||
if configuration.commitment_terms.prepaid_amount > Decimal("0")
|
||||
else defaults.prepaid_amount
|
||||
)
|
||||
guaranteed_platform_fee = (
|
||||
configuration.commitment_terms.guaranteed_platform_fee
|
||||
if configuration.commitment_terms.guaranteed_platform_fee > Decimal("0")
|
||||
else defaults.guaranteed_platform_fee
|
||||
)
|
||||
customer_funded_onboarding = configuration.commitment_terms.customer_funded_onboarding
|
||||
reduced_cancellation_flexibility = configuration.commitment_terms.reduced_cancellation_flexibility
|
||||
|
||||
expected_usage_units = _decimal(configuration.expected_usage_units)
|
||||
billable_usage_units = max(expected_usage_units - included_units, Decimal("0"))
|
||||
|
||||
recurring_revenue = (
|
||||
access_fee_amount
|
||||
+ _recurring_non_usage_component_revenue(model)
|
||||
+ (usage_unit_price * billable_usage_units)
|
||||
- configuration.discount_amount
|
||||
)
|
||||
monthly_revenue = _money(max(recurring_revenue, Decimal("0")))
|
||||
effective_monthly_revenue = _money(
|
||||
max(monthly_revenue, minimum_monthly_turnover, guaranteed_platform_fee)
|
||||
)
|
||||
|
||||
payment_fees = _money(
|
||||
configuration.payment_fee_fixed
|
||||
+ (effective_monthly_revenue * configuration.payment_fee_rate_pct / Decimal("100"))
|
||||
)
|
||||
if effective_monthly_revenue > Decimal("0"):
|
||||
payment_fee_pct = _percent(
|
||||
(payment_fees / effective_monthly_revenue) * Decimal("100")
|
||||
)
|
||||
else:
|
||||
payment_fee_pct = Decimal("100.0") if payment_fees > Decimal("0") else Decimal("0.0")
|
||||
|
||||
variable_usage_cost = _money(expected_usage_units * configuration.unit_cost)
|
||||
residual_onboarding_cost = max(
|
||||
configuration.onboarding_cost - customer_funded_onboarding,
|
||||
Decimal("0"),
|
||||
)
|
||||
total_monthly_cost = _money(
|
||||
configuration.allocated_fixed_cost
|
||||
+ configuration.direct_cost_amount
|
||||
+ variable_usage_cost
|
||||
+ configuration.support_cost
|
||||
+ residual_onboarding_cost
|
||||
+ configuration.risk_cost
|
||||
+ payment_fees
|
||||
)
|
||||
monthly_margin = _money(effective_monthly_revenue - total_monthly_cost)
|
||||
if effective_monthly_revenue > Decimal("0"):
|
||||
margin_pct = _percent((monthly_margin / effective_monthly_revenue) * Decimal("100"))
|
||||
else:
|
||||
margin_pct = Decimal("-100.0") if total_monthly_cost > Decimal("0") else Decimal("0.0")
|
||||
|
||||
cost_floor_revenue = _money(total_monthly_cost)
|
||||
minimum_margin_revenue = _required_revenue(total_monthly_cost, policy.minimum_margin_pct)
|
||||
target_margin_revenue = _required_revenue(total_monthly_cost, policy.target_margin_pct)
|
||||
|
||||
baseline_revenue = Decimal("0")
|
||||
baseline_margin = Decimal("0")
|
||||
baseline_margin_pct = Decimal("0.0")
|
||||
concession_value = Decimal("0")
|
||||
concession_pct = Decimal("0.0")
|
||||
meaningful_commitment_signals: tuple[str, ...] = ()
|
||||
baseline_contract_duration = contract_duration_months
|
||||
|
||||
if baseline_metrics is not None:
|
||||
baseline_revenue = baseline_metrics.effective_monthly_revenue
|
||||
baseline_margin = baseline_metrics.monthly_margin
|
||||
baseline_margin_pct = baseline_metrics.margin_pct
|
||||
concession_value = _money(max(baseline_metrics.monthly_margin - monthly_margin, Decimal("0")))
|
||||
if baseline_metrics.effective_monthly_revenue > Decimal("0"):
|
||||
concession_pct = _percent(
|
||||
(concession_value / baseline_metrics.effective_monthly_revenue) * Decimal("100")
|
||||
)
|
||||
else:
|
||||
concession_pct = Decimal("0.0")
|
||||
baseline_contract_duration = baseline_metrics.contract_duration_months
|
||||
|
||||
probe_baseline = baseline_metrics
|
||||
if probe_baseline is None:
|
||||
probe_baseline = PricingMetrics(
|
||||
currency=model.currency,
|
||||
monthly_revenue=monthly_revenue,
|
||||
effective_monthly_revenue=effective_monthly_revenue,
|
||||
payment_fees=payment_fees,
|
||||
payment_fee_pct=payment_fee_pct,
|
||||
allocated_fixed_cost=_money(configuration.allocated_fixed_cost),
|
||||
direct_cost_amount=_money(configuration.direct_cost_amount),
|
||||
variable_usage_cost=variable_usage_cost,
|
||||
support_cost=_money(configuration.support_cost),
|
||||
onboarding_cost=_money(configuration.onboarding_cost),
|
||||
customer_funded_onboarding=_money(customer_funded_onboarding),
|
||||
risk_cost=_money(configuration.risk_cost),
|
||||
total_monthly_cost=total_monthly_cost,
|
||||
monthly_margin=monthly_margin,
|
||||
margin_pct=margin_pct,
|
||||
cost_floor_revenue=cost_floor_revenue,
|
||||
minimum_margin_revenue=minimum_margin_revenue,
|
||||
target_margin_revenue=target_margin_revenue,
|
||||
expected_usage_units=expected_usage_units,
|
||||
included_units=included_units,
|
||||
billable_usage_units=billable_usage_units,
|
||||
unit_cost=configuration.unit_cost,
|
||||
usage_unit_price=usage_unit_price,
|
||||
access_fee_amount=access_fee_amount,
|
||||
contract_duration_months=baseline_contract_duration,
|
||||
minimum_monthly_turnover=minimum_monthly_turnover,
|
||||
prepaid_amount=prepaid_amount,
|
||||
guaranteed_platform_fee=guaranteed_platform_fee,
|
||||
concession_value=Decimal("0"),
|
||||
concession_pct=Decimal("0.0"),
|
||||
baseline_monthly_revenue=effective_monthly_revenue,
|
||||
baseline_margin=monthly_margin,
|
||||
baseline_margin_pct=margin_pct,
|
||||
meaningful_commitment_signals=(),
|
||||
reduced_cancellation_flexibility=reduced_cancellation_flexibility,
|
||||
)
|
||||
|
||||
meaningful_commitment_signals = _meaningful_commitment_signals(
|
||||
PricingMetrics(
|
||||
currency=model.currency,
|
||||
monthly_revenue=monthly_revenue,
|
||||
effective_monthly_revenue=effective_monthly_revenue,
|
||||
payment_fees=payment_fees,
|
||||
payment_fee_pct=payment_fee_pct,
|
||||
allocated_fixed_cost=_money(configuration.allocated_fixed_cost),
|
||||
direct_cost_amount=_money(configuration.direct_cost_amount),
|
||||
variable_usage_cost=variable_usage_cost,
|
||||
support_cost=_money(configuration.support_cost),
|
||||
onboarding_cost=_money(configuration.onboarding_cost),
|
||||
customer_funded_onboarding=_money(customer_funded_onboarding),
|
||||
risk_cost=_money(configuration.risk_cost),
|
||||
total_monthly_cost=total_monthly_cost,
|
||||
monthly_margin=monthly_margin,
|
||||
margin_pct=margin_pct,
|
||||
cost_floor_revenue=cost_floor_revenue,
|
||||
minimum_margin_revenue=minimum_margin_revenue,
|
||||
target_margin_revenue=target_margin_revenue,
|
||||
expected_usage_units=expected_usage_units,
|
||||
included_units=included_units,
|
||||
billable_usage_units=billable_usage_units,
|
||||
unit_cost=configuration.unit_cost,
|
||||
usage_unit_price=usage_unit_price,
|
||||
access_fee_amount=access_fee_amount,
|
||||
contract_duration_months=contract_duration_months,
|
||||
minimum_monthly_turnover=minimum_monthly_turnover,
|
||||
prepaid_amount=prepaid_amount,
|
||||
guaranteed_platform_fee=guaranteed_platform_fee,
|
||||
concession_value=concession_value,
|
||||
concession_pct=concession_pct,
|
||||
baseline_monthly_revenue=baseline_revenue,
|
||||
baseline_margin=baseline_margin,
|
||||
baseline_margin_pct=baseline_margin_pct,
|
||||
meaningful_commitment_signals=(),
|
||||
reduced_cancellation_flexibility=reduced_cancellation_flexibility,
|
||||
),
|
||||
probe_baseline,
|
||||
policy,
|
||||
)
|
||||
|
||||
return PricingMetrics(
|
||||
currency=model.currency,
|
||||
monthly_revenue=monthly_revenue,
|
||||
effective_monthly_revenue=effective_monthly_revenue,
|
||||
payment_fees=payment_fees,
|
||||
payment_fee_pct=payment_fee_pct,
|
||||
allocated_fixed_cost=_money(configuration.allocated_fixed_cost),
|
||||
direct_cost_amount=_money(configuration.direct_cost_amount),
|
||||
variable_usage_cost=variable_usage_cost,
|
||||
support_cost=_money(configuration.support_cost),
|
||||
onboarding_cost=_money(configuration.onboarding_cost),
|
||||
customer_funded_onboarding=_money(customer_funded_onboarding),
|
||||
risk_cost=_money(configuration.risk_cost),
|
||||
total_monthly_cost=total_monthly_cost,
|
||||
monthly_margin=monthly_margin,
|
||||
margin_pct=margin_pct,
|
||||
cost_floor_revenue=cost_floor_revenue,
|
||||
minimum_margin_revenue=minimum_margin_revenue,
|
||||
target_margin_revenue=target_margin_revenue,
|
||||
expected_usage_units=expected_usage_units,
|
||||
included_units=included_units,
|
||||
billable_usage_units=billable_usage_units,
|
||||
unit_cost=configuration.unit_cost,
|
||||
usage_unit_price=usage_unit_price,
|
||||
access_fee_amount=access_fee_amount,
|
||||
contract_duration_months=contract_duration_months,
|
||||
minimum_monthly_turnover=minimum_monthly_turnover,
|
||||
prepaid_amount=prepaid_amount,
|
||||
guaranteed_platform_fee=guaranteed_platform_fee,
|
||||
concession_value=concession_value,
|
||||
concession_pct=concession_pct,
|
||||
baseline_monthly_revenue=_money(baseline_revenue),
|
||||
baseline_margin=_money(baseline_margin),
|
||||
baseline_margin_pct=baseline_margin_pct,
|
||||
meaningful_commitment_signals=meaningful_commitment_signals,
|
||||
reduced_cancellation_flexibility=reduced_cancellation_flexibility,
|
||||
)
|
||||
|
||||
|
||||
def _baseline_configuration(configuration: PricingConfiguration) -> PricingConfiguration:
|
||||
return PricingConfiguration(
|
||||
model=configuration.model,
|
||||
segment=configuration.segment,
|
||||
expected_usage_units=configuration.expected_usage_units,
|
||||
expected_usage_variance_pct=configuration.expected_usage_variance_pct,
|
||||
allocated_fixed_cost=configuration.allocated_fixed_cost,
|
||||
direct_cost_amount=configuration.direct_cost_amount,
|
||||
unit_cost=configuration.unit_cost,
|
||||
support_cost=configuration.support_cost,
|
||||
onboarding_cost=configuration.onboarding_cost,
|
||||
risk_cost=configuration.risk_cost,
|
||||
payment_fee_fixed=configuration.payment_fee_fixed,
|
||||
payment_fee_rate_pct=configuration.payment_fee_rate_pct,
|
||||
commitment_terms=default_commitment_terms(configuration.model),
|
||||
)
|
||||
|
||||
|
||||
def _segment_eligibility(
|
||||
configuration: PricingConfiguration,
|
||||
_policy: BoundaryPolicy,
|
||||
_metrics: PricingMetrics,
|
||||
_baseline: PricingMetrics,
|
||||
) -> ConstraintResult:
|
||||
if not configuration.segment or not configuration.model.eligibility:
|
||||
return ConstraintResult(
|
||||
id="segment-eligibility",
|
||||
title="Segment eligibility",
|
||||
severity="hard",
|
||||
status="pass",
|
||||
summary="Segment eligibility not restrictive for this evaluation.",
|
||||
reason="No customer segment was supplied or the model declares no eligibility list.",
|
||||
)
|
||||
|
||||
if configuration.segment in configuration.model.eligibility:
|
||||
return ConstraintResult(
|
||||
id="segment-eligibility",
|
||||
title="Segment eligibility",
|
||||
severity="hard",
|
||||
status="pass",
|
||||
summary=f"Segment '{configuration.segment}' is eligible.",
|
||||
reason="The supplied customer segment is listed in the model eligibility rules.",
|
||||
actual_value=configuration.segment,
|
||||
)
|
||||
|
||||
return ConstraintResult(
|
||||
id="segment-eligibility",
|
||||
title="Segment eligibility",
|
||||
severity="hard",
|
||||
status="fail",
|
||||
summary=f"Segment '{configuration.segment}' is not eligible for this model.",
|
||||
reason="The model declares an explicit eligibility list and the supplied segment is outside it.",
|
||||
actual_value=configuration.segment,
|
||||
details={"eligible_segments": list(configuration.model.eligibility)},
|
||||
suggested_action="Choose an eligible segment or use a different pricing model.",
|
||||
)
|
||||
|
||||
|
||||
def _usage_variance_limit(
|
||||
configuration: PricingConfiguration,
|
||||
policy: BoundaryPolicy,
|
||||
_metrics: PricingMetrics,
|
||||
_baseline: PricingMetrics,
|
||||
) -> ConstraintResult:
|
||||
actual = _percent(configuration.expected_usage_variance_pct)
|
||||
if actual <= policy.max_expected_usage_variance_pct:
|
||||
return ConstraintResult(
|
||||
id="usage-variance-limit",
|
||||
title="Usage variance limit",
|
||||
severity="hard",
|
||||
status="pass",
|
||||
summary=f"Expected usage variance {actual}% is within the allowed range.",
|
||||
reason="The scenario stays inside the configured usage-variance guardrail.",
|
||||
actual_value=actual,
|
||||
threshold_value=policy.max_expected_usage_variance_pct,
|
||||
unit="percent",
|
||||
)
|
||||
|
||||
return ConstraintResult(
|
||||
id="usage-variance-limit",
|
||||
title="Usage variance limit",
|
||||
severity="hard",
|
||||
status="fail",
|
||||
summary=f"Expected usage variance {actual}% exceeds the allowed {policy.max_expected_usage_variance_pct}%.",
|
||||
reason="High-variance usage invalidates the pricing configuration until the seller widens the guardrail or changes the package.",
|
||||
actual_value=actual,
|
||||
threshold_value=policy.max_expected_usage_variance_pct,
|
||||
unit="percent",
|
||||
suggested_action="Reduce exposure to volatile usage or tighten the included-usage assumptions.",
|
||||
)
|
||||
|
||||
|
||||
def _payment_fee_limit(
|
||||
_configuration: PricingConfiguration,
|
||||
policy: BoundaryPolicy,
|
||||
metrics: PricingMetrics,
|
||||
_baseline: PricingMetrics,
|
||||
) -> ConstraintResult:
|
||||
if metrics.payment_fee_pct <= policy.max_payment_fee_pct:
|
||||
return ConstraintResult(
|
||||
id="payment-fee-limit",
|
||||
title="Payment fee limit",
|
||||
severity="hard",
|
||||
status="pass",
|
||||
summary=f"Payment fees consume {metrics.payment_fee_pct}% of revenue, within policy.",
|
||||
reason="Payment-provider fees remain inside the configured coverage ceiling.",
|
||||
actual_value=metrics.payment_fee_pct,
|
||||
threshold_value=policy.max_payment_fee_pct,
|
||||
unit="percent",
|
||||
)
|
||||
|
||||
return ConstraintResult(
|
||||
id="payment-fee-limit",
|
||||
title="Payment fee limit",
|
||||
severity="hard",
|
||||
status="fail",
|
||||
summary=f"Payment fees consume {metrics.payment_fee_pct}% of revenue, above the {policy.max_payment_fee_pct}% ceiling.",
|
||||
reason="The pricing configuration leaves too little contribution margin after provider fees.",
|
||||
actual_value=metrics.payment_fee_pct,
|
||||
threshold_value=policy.max_payment_fee_pct,
|
||||
unit="percent",
|
||||
suggested_action="Increase revenue, reduce fee burden, or change the collection method.",
|
||||
)
|
||||
|
||||
|
||||
def _cost_floor_coverage(
|
||||
_configuration: PricingConfiguration,
|
||||
_policy: BoundaryPolicy,
|
||||
metrics: PricingMetrics,
|
||||
_baseline: PricingMetrics,
|
||||
) -> ConstraintResult:
|
||||
if metrics.monthly_margin >= Decimal("0"):
|
||||
return ConstraintResult(
|
||||
id="cost-floor-coverage",
|
||||
title="Cost floor coverage",
|
||||
severity="hard",
|
||||
status="pass",
|
||||
summary=f"Monthly revenue clears the cost floor by {metrics.monthly_margin} {metrics.currency}.",
|
||||
reason="Expected revenue covers allocated fixed cost, variable cost, and payment fees.",
|
||||
actual_value=metrics.monthly_margin,
|
||||
threshold_value=Decimal("0.00"),
|
||||
unit=metrics.currency,
|
||||
)
|
||||
|
||||
return ConstraintResult(
|
||||
id="cost-floor-coverage",
|
||||
title="Cost floor coverage",
|
||||
severity="hard",
|
||||
status="fail",
|
||||
summary=f"Monthly revenue misses the cost floor by {abs(metrics.monthly_margin)} {metrics.currency}.",
|
||||
reason=f"At least {metrics.cost_floor_revenue} {metrics.currency} monthly revenue is required to break even under these assumptions.",
|
||||
actual_value=metrics.monthly_margin,
|
||||
threshold_value=Decimal("0.00"),
|
||||
unit=metrics.currency,
|
||||
suggested_action="Raise price, lower cost, or add stronger commitments before offering this configuration.",
|
||||
)
|
||||
|
||||
|
||||
def _minimum_margin(
|
||||
_configuration: PricingConfiguration,
|
||||
policy: BoundaryPolicy,
|
||||
metrics: PricingMetrics,
|
||||
_baseline: PricingMetrics,
|
||||
) -> ConstraintResult:
|
||||
if metrics.margin_pct >= policy.minimum_margin_pct:
|
||||
return ConstraintResult(
|
||||
id="minimum-margin",
|
||||
title="Minimum margin",
|
||||
severity="hard",
|
||||
status="pass",
|
||||
summary=f"Margin {metrics.margin_pct}% satisfies the hard minimum.",
|
||||
reason="The configuration meets the seller's minimum margin boundary.",
|
||||
actual_value=metrics.margin_pct,
|
||||
threshold_value=policy.minimum_margin_pct,
|
||||
unit="percent",
|
||||
)
|
||||
|
||||
return ConstraintResult(
|
||||
id="minimum-margin",
|
||||
title="Minimum margin",
|
||||
severity="hard",
|
||||
status="fail",
|
||||
summary=f"Margin {metrics.margin_pct}% is below the hard minimum of {policy.minimum_margin_pct}%.",
|
||||
reason=f"At least {metrics.minimum_margin_revenue} {metrics.currency} monthly revenue is required to satisfy the minimum margin boundary.",
|
||||
actual_value=metrics.margin_pct,
|
||||
threshold_value=policy.minimum_margin_pct,
|
||||
unit="percent",
|
||||
suggested_action="Increase price, reduce costs, or add commitment-backed protection.",
|
||||
)
|
||||
|
||||
|
||||
def _target_margin_approval(
|
||||
_configuration: PricingConfiguration,
|
||||
policy: BoundaryPolicy,
|
||||
metrics: PricingMetrics,
|
||||
_baseline: PricingMetrics,
|
||||
) -> ConstraintResult:
|
||||
if metrics.margin_pct >= policy.target_margin_pct:
|
||||
return ConstraintResult(
|
||||
id="target-margin-approval",
|
||||
title="Target margin approval threshold",
|
||||
severity="soft",
|
||||
status="pass",
|
||||
summary=f"Margin {metrics.margin_pct}% satisfies the target margin threshold.",
|
||||
reason="No sales or pricing approval is required on margin grounds.",
|
||||
actual_value=metrics.margin_pct,
|
||||
threshold_value=policy.target_margin_pct,
|
||||
unit="percent",
|
||||
)
|
||||
|
||||
return ConstraintResult(
|
||||
id="target-margin-approval",
|
||||
title="Target margin approval threshold",
|
||||
severity="soft",
|
||||
status="review",
|
||||
summary=f"Margin {metrics.margin_pct}% is below the target threshold of {policy.target_margin_pct}%.",
|
||||
reason=f"The configuration is economically viable but falls short of the seller's preferred target margin of {policy.target_margin_pct}%.",
|
||||
actual_value=metrics.margin_pct,
|
||||
threshold_value=policy.target_margin_pct,
|
||||
unit="percent",
|
||||
suggested_action="Route through approval or improve economics before release.",
|
||||
)
|
||||
|
||||
|
||||
def _discount_exposure_limit(
|
||||
_configuration: PricingConfiguration,
|
||||
policy: BoundaryPolicy,
|
||||
metrics: PricingMetrics,
|
||||
_baseline: PricingMetrics,
|
||||
) -> ConstraintResult:
|
||||
if metrics.concession_pct <= policy.max_discount_pct:
|
||||
return ConstraintResult(
|
||||
id="discount-exposure-limit",
|
||||
title="Discount exposure limit",
|
||||
severity="hard",
|
||||
status="pass",
|
||||
summary=f"Economic concession {metrics.concession_pct}% stays inside the hard discount ceiling.",
|
||||
reason="The configuration does not exceed the seller's maximum discount exposure.",
|
||||
actual_value=metrics.concession_pct,
|
||||
threshold_value=policy.max_discount_pct,
|
||||
unit="percent",
|
||||
)
|
||||
|
||||
return ConstraintResult(
|
||||
id="discount-exposure-limit",
|
||||
title="Discount exposure limit",
|
||||
severity="hard",
|
||||
status="fail",
|
||||
summary=f"Economic concession {metrics.concession_pct}% exceeds the hard discount ceiling of {policy.max_discount_pct}%.",
|
||||
reason="The proposed economics reduce seller value too far relative to the model baseline.",
|
||||
actual_value=metrics.concession_pct,
|
||||
threshold_value=policy.max_discount_pct,
|
||||
unit="percent",
|
||||
suggested_action="Reduce the concession or offset it with a materially stronger commitment structure.",
|
||||
)
|
||||
|
||||
|
||||
def _discount_approval_threshold(
|
||||
_configuration: PricingConfiguration,
|
||||
policy: BoundaryPolicy,
|
||||
metrics: PricingMetrics,
|
||||
_baseline: PricingMetrics,
|
||||
) -> ConstraintResult:
|
||||
if metrics.concession_pct <= policy.approval_discount_pct:
|
||||
return ConstraintResult(
|
||||
id="discount-approval-threshold",
|
||||
title="Discount approval threshold",
|
||||
severity="soft",
|
||||
status="pass",
|
||||
summary=f"Economic concession {metrics.concession_pct}% is inside the self-serve threshold.",
|
||||
reason="No extra approval is needed for discount exposure.",
|
||||
actual_value=metrics.concession_pct,
|
||||
threshold_value=policy.approval_discount_pct,
|
||||
unit="percent",
|
||||
)
|
||||
|
||||
return ConstraintResult(
|
||||
id="discount-approval-threshold",
|
||||
title="Discount approval threshold",
|
||||
severity="soft",
|
||||
status="review",
|
||||
summary=f"Economic concession {metrics.concession_pct}% exceeds the self-serve threshold of {policy.approval_discount_pct}%.",
|
||||
reason="The configuration may still be acceptable, but it now requires seller approval instead of self-serve acceptance.",
|
||||
actual_value=metrics.concession_pct,
|
||||
threshold_value=policy.approval_discount_pct,
|
||||
unit="percent",
|
||||
suggested_action="Escalate for approval or reduce the concession magnitude.",
|
||||
)
|
||||
|
||||
|
||||
def _commitment_backed_concession(
|
||||
_configuration: PricingConfiguration,
|
||||
_policy: BoundaryPolicy,
|
||||
metrics: PricingMetrics,
|
||||
_baseline: PricingMetrics,
|
||||
) -> ConstraintResult:
|
||||
if metrics.concession_value <= Decimal("0"):
|
||||
return ConstraintResult(
|
||||
id="commitment-backed-concession",
|
||||
title="Commitment-backed concession",
|
||||
severity="hard",
|
||||
status="pass",
|
||||
summary="No economic concession was introduced relative to the model baseline.",
|
||||
reason="The configuration does not weaken seller economics versus the baseline assumptions.",
|
||||
actual_value=metrics.concession_value,
|
||||
threshold_value=Decimal("0.00"),
|
||||
unit=metrics.currency,
|
||||
)
|
||||
|
||||
if metrics.meaningful_commitment_signals:
|
||||
return ConstraintResult(
|
||||
id="commitment-backed-concession",
|
||||
title="Commitment-backed concession",
|
||||
severity="hard",
|
||||
status="pass",
|
||||
summary="The economic concession is backed by explicit commitments.",
|
||||
reason="The configuration introduces weaker unit economics, but it also adds meaningful seller protections.",
|
||||
actual_value=metrics.concession_value,
|
||||
threshold_value=Decimal("0.00"),
|
||||
unit=metrics.currency,
|
||||
details={"signals": list(metrics.meaningful_commitment_signals)},
|
||||
)
|
||||
|
||||
return ConstraintResult(
|
||||
id="commitment-backed-concession",
|
||||
title="Commitment-backed concession",
|
||||
severity="hard",
|
||||
status="fail",
|
||||
summary="The configuration introduces weaker seller economics without an offsetting commitment.",
|
||||
reason="Discounts and improved customer unit economics must be tied to enforceable or economically meaningful commitments.",
|
||||
actual_value=metrics.concession_value,
|
||||
threshold_value=Decimal("0.00"),
|
||||
unit=metrics.currency,
|
||||
suggested_action="Add minimum turnover, prepayment, guaranteed fees, or a materially longer contract before offering this concession.",
|
||||
)
|
||||
|
||||
|
||||
def default_constraints() -> tuple[BoundaryConstraint, ...]:
|
||||
return (
|
||||
BoundaryConstraint("segment-eligibility", "Segment eligibility", "hard", _segment_eligibility),
|
||||
BoundaryConstraint("usage-variance-limit", "Usage variance limit", "hard", _usage_variance_limit),
|
||||
BoundaryConstraint("payment-fee-limit", "Payment fee limit", "hard", _payment_fee_limit),
|
||||
BoundaryConstraint("cost-floor-coverage", "Cost floor coverage", "hard", _cost_floor_coverage),
|
||||
BoundaryConstraint("minimum-margin", "Minimum margin", "hard", _minimum_margin),
|
||||
BoundaryConstraint("target-margin-approval", "Target margin approval threshold", "soft", _target_margin_approval),
|
||||
BoundaryConstraint("discount-exposure-limit", "Discount exposure limit", "hard", _discount_exposure_limit),
|
||||
BoundaryConstraint("discount-approval-threshold", "Discount approval threshold", "soft", _discount_approval_threshold),
|
||||
BoundaryConstraint("commitment-backed-concession", "Commitment-backed concession", "hard", _commitment_backed_concession),
|
||||
)
|
||||
|
||||
|
||||
def _configuration_view(configuration: PricingConfiguration, metrics: PricingMetrics) -> dict[str, Any]:
|
||||
return {
|
||||
"segment": configuration.segment,
|
||||
"currency": metrics.currency,
|
||||
"access_fee_amount": metrics.access_fee_amount,
|
||||
"included_units": metrics.included_units,
|
||||
"usage_unit_price": metrics.usage_unit_price,
|
||||
"expected_usage_units": metrics.expected_usage_units,
|
||||
"expected_usage_variance_pct": _percent(configuration.expected_usage_variance_pct),
|
||||
"allocated_fixed_cost": metrics.allocated_fixed_cost,
|
||||
"direct_cost_amount": metrics.direct_cost_amount,
|
||||
"unit_cost": metrics.unit_cost,
|
||||
"support_cost": metrics.support_cost,
|
||||
"onboarding_cost": metrics.onboarding_cost,
|
||||
"risk_cost": metrics.risk_cost,
|
||||
"payment_fee_fixed": _money(configuration.payment_fee_fixed),
|
||||
"payment_fee_rate_pct": _percent(configuration.payment_fee_rate_pct),
|
||||
"discount_amount": _money(configuration.discount_amount),
|
||||
"commitment_terms": {
|
||||
"contract_duration_months": metrics.contract_duration_months,
|
||||
"minimum_monthly_turnover": metrics.minimum_monthly_turnover,
|
||||
"prepaid_amount": metrics.prepaid_amount,
|
||||
"guaranteed_platform_fee": metrics.guaranteed_platform_fee,
|
||||
"customer_funded_onboarding": metrics.customer_funded_onboarding,
|
||||
"reduced_cancellation_flexibility": metrics.reduced_cancellation_flexibility,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def validate_pricing_configuration(
|
||||
configuration: PricingConfiguration,
|
||||
policy: BoundaryPolicy,
|
||||
constraints: tuple[BoundaryConstraint, ...] | None = None,
|
||||
) -> ValidationResult:
|
||||
baseline_metrics = _build_metrics(_baseline_configuration(configuration), policy)
|
||||
metrics = _build_metrics(configuration, policy, baseline_metrics=baseline_metrics)
|
||||
|
||||
results = tuple(
|
||||
constraint.evaluator(configuration, policy, metrics, baseline_metrics)
|
||||
for constraint in (constraints or default_constraints())
|
||||
)
|
||||
|
||||
hard_failures = [result for result in results if result.status == "fail" and result.severity == "hard"]
|
||||
soft_reviews = [result for result in results if result.status == "review"]
|
||||
valid = not hard_failures
|
||||
requires_approval = valid and bool(soft_reviews)
|
||||
|
||||
if hard_failures:
|
||||
decision: ValidationDecision = "rejected"
|
||||
summary = "Rejected: " + ", ".join(result.title for result in hard_failures) + "."
|
||||
elif soft_reviews:
|
||||
decision = "requires_approval"
|
||||
summary = "Approval required: " + ", ".join(result.title for result in soft_reviews) + "."
|
||||
else:
|
||||
decision = "accepted"
|
||||
summary = "Accepted: all boundary constraints passed."
|
||||
|
||||
return ValidationResult(
|
||||
model_id=configuration.model.id,
|
||||
model_name=configuration.model.name,
|
||||
decision=decision,
|
||||
valid=valid,
|
||||
requires_approval=requires_approval,
|
||||
summary=summary,
|
||||
configuration=_configuration_view(configuration, metrics),
|
||||
metrics=metrics,
|
||||
policy=policy,
|
||||
constraints=results,
|
||||
)
|
||||
496
adaptive_pricing_core/comparable_ltv.py
Normal file
496
adaptive_pricing_core/comparable_ltv.py
Normal file
@@ -0,0 +1,496 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Any
|
||||
|
||||
from .boundary_engine import (
|
||||
BoundaryPolicy,
|
||||
PricingConfiguration,
|
||||
ValidationResult,
|
||||
validate_pricing_configuration,
|
||||
)
|
||||
|
||||
TWOPLACES = Decimal("0.01")
|
||||
PCTPLACES = Decimal("0.1")
|
||||
|
||||
|
||||
def _money(value: Decimal) -> Decimal:
|
||||
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _percent(value: Decimal) -> Decimal:
|
||||
return value.quantize(PCTPLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _decimal(value: Decimal | str | int | float | None) -> Decimal:
|
||||
if value in (None, ""):
|
||||
return Decimal("0")
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComparableCustomerProfile:
|
||||
id: str
|
||||
name: str
|
||||
segment: str
|
||||
eligible_model_ids: tuple[str, ...] = ()
|
||||
members_per_customer: int = 1
|
||||
expected_monthly_usage_units: Decimal = Decimal("0")
|
||||
usage_variance_pct: Decimal = Decimal("0")
|
||||
monthly_churn_pct: Decimal = Decimal("0")
|
||||
monthly_default_pct: Decimal = Decimal("0")
|
||||
monthly_support_cost: Decimal = Decimal("0")
|
||||
monthly_risk_cost: Decimal = Decimal("0")
|
||||
acquisition_cost: Decimal = Decimal("0")
|
||||
upfront_investment_cost: Decimal = Decimal("0")
|
||||
allocated_fixed_cost: Decimal | None = None
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LTVPolicy:
|
||||
horizon_months: int = 24
|
||||
monthly_discount_rate_pct: Decimal = Decimal("1")
|
||||
required_improvement_factor: Decimal = Decimal("1.05")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensitivityCase:
|
||||
id: str
|
||||
name: str
|
||||
usage_multiplier: Decimal = Decimal("1")
|
||||
usage_variance_delta_pct: Decimal = Decimal("0")
|
||||
monthly_churn_delta_pct: Decimal = Decimal("0")
|
||||
monthly_default_delta_pct: Decimal = Decimal("0")
|
||||
monthly_support_cost_delta: Decimal = Decimal("0")
|
||||
monthly_risk_cost_delta: Decimal = Decimal("0")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ComparableLTVEstimate:
|
||||
model_id: str
|
||||
model_name: str
|
||||
validation: ValidationResult
|
||||
average_comparable_customer_lifetime_value: Decimal
|
||||
discounted_margin_ltv: Decimal
|
||||
expected_lifetime_months: Decimal
|
||||
payback_months: int | None
|
||||
adjusted_monthly_churn_pct: Decimal
|
||||
adjusted_monthly_default_pct: Decimal
|
||||
acquisition_cost: Decimal
|
||||
upfront_investment_cost: Decimal
|
||||
seller_cost_recovery_months: int | None
|
||||
assumptions: dict[str, Any]
|
||||
explanation: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SensitivityOutcome:
|
||||
case_id: str
|
||||
case_name: str
|
||||
average_comparable_customer_lifetime_value: Decimal
|
||||
delta_vs_base: Decimal
|
||||
delta_vs_base_pct: Decimal | None
|
||||
decision: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PricingModelComparison:
|
||||
model_id: str
|
||||
model_name: str
|
||||
model_type: str
|
||||
status: str
|
||||
validation_decision: str
|
||||
valid: bool
|
||||
requires_approval: bool
|
||||
average_comparable_customer_lifetime_value: Decimal
|
||||
expected_lifetime_months: Decimal
|
||||
base_monthly_margin: Decimal
|
||||
base_margin_pct: Decimal
|
||||
payment_fee_pct: Decimal
|
||||
contract_duration_months: int
|
||||
reference_model_id: str | None
|
||||
passes_required_improvement: bool
|
||||
required_improvement_threshold: Decimal | None
|
||||
improvement_vs_reference_pct: Decimal | None
|
||||
sensitivity: tuple[SensitivityOutcome, ...]
|
||||
sensitivity_floor_ltv: Decimal
|
||||
sensitivity_ceiling_ltv: Decimal
|
||||
key_drivers: tuple[str, ...]
|
||||
comparison_summary: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PricingComparison:
|
||||
profile: ComparableCustomerProfile
|
||||
policy: LTVPolicy
|
||||
boundary_policy: BoundaryPolicy
|
||||
reference_model_id: str | None
|
||||
best_ltv_model_id: str | None
|
||||
best_valid_model_id: str | None
|
||||
comparisons: tuple[PricingModelComparison, ...]
|
||||
notes: tuple[str, ...]
|
||||
|
||||
|
||||
def default_sensitivity_cases() -> tuple[SensitivityCase, ...]:
|
||||
return (
|
||||
SensitivityCase(
|
||||
id="usage-downside",
|
||||
name="Usage downside",
|
||||
usage_multiplier=Decimal("0.75"),
|
||||
monthly_churn_delta_pct=Decimal("1.5"),
|
||||
monthly_risk_cost_delta=Decimal("0.05"),
|
||||
),
|
||||
SensitivityCase(
|
||||
id="usage-upside",
|
||||
name="Usage upside",
|
||||
usage_multiplier=Decimal("1.35"),
|
||||
usage_variance_delta_pct=Decimal("10"),
|
||||
),
|
||||
SensitivityCase(
|
||||
id="risk-spike",
|
||||
name="Risk spike",
|
||||
usage_multiplier=Decimal("1.00"),
|
||||
monthly_churn_delta_pct=Decimal("3.0"),
|
||||
monthly_default_delta_pct=Decimal("1.0"),
|
||||
monthly_support_cost_delta=Decimal("0.25"),
|
||||
monthly_risk_cost_delta=Decimal("0.20"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _risk_multiplier_for_default(validation: ValidationResult) -> Decimal:
|
||||
metrics = validation.metrics
|
||||
multiplier = Decimal("1")
|
||||
if metrics.prepaid_amount >= metrics.effective_monthly_revenue and metrics.prepaid_amount > Decimal("0"):
|
||||
multiplier *= Decimal("0.50")
|
||||
if (
|
||||
metrics.guaranteed_platform_fee >= metrics.effective_monthly_revenue
|
||||
and metrics.guaranteed_platform_fee > Decimal("0")
|
||||
):
|
||||
multiplier *= Decimal("0.75")
|
||||
return multiplier
|
||||
|
||||
|
||||
def _risk_multiplier_for_churn(validation: ValidationResult) -> Decimal:
|
||||
metrics = validation.metrics
|
||||
multiplier = Decimal("1")
|
||||
if metrics.reduced_cancellation_flexibility:
|
||||
multiplier *= Decimal("0.85")
|
||||
return multiplier
|
||||
|
||||
|
||||
def _discount_rate(policy: LTVPolicy) -> Decimal:
|
||||
return Decimal("1") + (policy.monthly_discount_rate_pct / Decimal("100"))
|
||||
|
||||
|
||||
def required_improvement_threshold(reference_ltv: Decimal, factor: Decimal) -> Decimal:
|
||||
if reference_ltv >= Decimal("0"):
|
||||
return _money(reference_ltv * factor)
|
||||
improvement = abs(reference_ltv) * (factor - Decimal("1"))
|
||||
return _money(reference_ltv + improvement)
|
||||
|
||||
|
||||
def _pct_delta(candidate: Decimal, reference: Decimal) -> Decimal | None:
|
||||
if reference == Decimal("0"):
|
||||
return None
|
||||
return _percent(((candidate - reference) / abs(reference)) * Decimal("100"))
|
||||
|
||||
|
||||
def estimate_comparable_customer_ltv(
|
||||
configuration: PricingConfiguration,
|
||||
profile: ComparableCustomerProfile,
|
||||
boundary_policy: BoundaryPolicy,
|
||||
policy: LTVPolicy,
|
||||
) -> ComparableLTVEstimate:
|
||||
validation = validate_pricing_configuration(configuration, boundary_policy)
|
||||
|
||||
base_churn_pct = max(profile.monthly_churn_pct, Decimal("0"))
|
||||
base_default_pct = max(profile.monthly_default_pct, Decimal("0"))
|
||||
adjusted_churn_pct = _percent(base_churn_pct * _risk_multiplier_for_churn(validation))
|
||||
adjusted_default_pct = _percent(base_default_pct * _risk_multiplier_for_default(validation))
|
||||
|
||||
monthly_margin = validation.metrics.monthly_margin
|
||||
acquisition_cost = _money(profile.acquisition_cost)
|
||||
upfront_investment_cost = _money(profile.upfront_investment_cost)
|
||||
committed_months = max(validation.metrics.contract_duration_months, 1)
|
||||
|
||||
survival = Decimal("1")
|
||||
expected_lifetime_months = Decimal("0")
|
||||
discounted_margin = Decimal("0")
|
||||
cumulative_discounted_margin = Decimal("0")
|
||||
payback_months: int | None = None
|
||||
recovery_months: int | None = None
|
||||
hurdle = acquisition_cost + upfront_investment_cost
|
||||
|
||||
adjusted_churn_rate = adjusted_churn_pct / Decimal("100")
|
||||
adjusted_default_rate = adjusted_default_pct / Decimal("100")
|
||||
discount_rate = _discount_rate(policy)
|
||||
|
||||
for month in range(1, policy.horizon_months + 1):
|
||||
expected_lifetime_months += survival
|
||||
discounted_month_margin = (monthly_margin * survival) / (discount_rate ** month)
|
||||
discounted_margin += discounted_month_margin
|
||||
cumulative_discounted_margin += discounted_month_margin
|
||||
|
||||
if payback_months is None and cumulative_discounted_margin >= hurdle:
|
||||
payback_months = month
|
||||
if recovery_months is None and cumulative_discounted_margin >= Decimal("0"):
|
||||
recovery_months = month
|
||||
|
||||
if month < committed_months:
|
||||
survival *= Decimal("1") - adjusted_default_rate
|
||||
else:
|
||||
survival *= (Decimal("1") - adjusted_default_rate) * (Decimal("1") - adjusted_churn_rate)
|
||||
|
||||
average_ltv = _money(discounted_margin - hurdle)
|
||||
explanation = (
|
||||
f"Monthly margin {validation.metrics.monthly_margin} {validation.metrics.currency}, "
|
||||
f"expected lifetime {expected_lifetime_months.quantize(Decimal('0.1'))} months, "
|
||||
f"discounted seller LTV {average_ltv} {validation.metrics.currency}."
|
||||
)
|
||||
|
||||
return ComparableLTVEstimate(
|
||||
model_id=configuration.model.id,
|
||||
model_name=configuration.model.name,
|
||||
validation=validation,
|
||||
average_comparable_customer_lifetime_value=average_ltv,
|
||||
discounted_margin_ltv=_money(discounted_margin),
|
||||
expected_lifetime_months=expected_lifetime_months.quantize(Decimal("0.1")),
|
||||
payback_months=payback_months,
|
||||
adjusted_monthly_churn_pct=adjusted_churn_pct,
|
||||
adjusted_monthly_default_pct=adjusted_default_pct,
|
||||
acquisition_cost=acquisition_cost,
|
||||
upfront_investment_cost=upfront_investment_cost,
|
||||
seller_cost_recovery_months=recovery_months,
|
||||
assumptions={
|
||||
"horizon_months": policy.horizon_months,
|
||||
"monthly_discount_rate_pct": _percent(policy.monthly_discount_rate_pct),
|
||||
"base_monthly_churn_pct": _percent(base_churn_pct),
|
||||
"base_monthly_default_pct": _percent(base_default_pct),
|
||||
"committed_months": committed_months,
|
||||
},
|
||||
explanation=explanation,
|
||||
)
|
||||
|
||||
|
||||
def _apply_sensitivity(
|
||||
configuration: PricingConfiguration,
|
||||
profile: ComparableCustomerProfile,
|
||||
case: SensitivityCase,
|
||||
) -> tuple[PricingConfiguration, ComparableCustomerProfile]:
|
||||
return (
|
||||
replace(
|
||||
configuration,
|
||||
expected_usage_units=_money(configuration.expected_usage_units * case.usage_multiplier),
|
||||
expected_usage_variance_pct=max(
|
||||
configuration.expected_usage_variance_pct + case.usage_variance_delta_pct,
|
||||
Decimal("0"),
|
||||
),
|
||||
support_cost=max(configuration.support_cost + case.monthly_support_cost_delta, Decimal("0")),
|
||||
risk_cost=max(configuration.risk_cost + case.monthly_risk_cost_delta, Decimal("0")),
|
||||
),
|
||||
replace(
|
||||
profile,
|
||||
monthly_churn_pct=max(profile.monthly_churn_pct + case.monthly_churn_delta_pct, Decimal("0")),
|
||||
monthly_default_pct=max(profile.monthly_default_pct + case.monthly_default_delta_pct, Decimal("0")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def select_reference_estimate(
|
||||
estimates: list[ComparableLTVEstimate],
|
||||
eligible_model_ids: tuple[str, ...] = (),
|
||||
) -> ComparableLTVEstimate | None:
|
||||
candidates = estimates
|
||||
if eligible_model_ids:
|
||||
allowed = set(eligible_model_ids)
|
||||
candidates = [estimate for estimate in estimates if estimate.model_id in allowed]
|
||||
|
||||
valid = [estimate for estimate in candidates if estimate.validation.valid]
|
||||
if valid:
|
||||
return max(valid, key=lambda item: item.average_comparable_customer_lifetime_value)
|
||||
if candidates:
|
||||
return max(candidates, key=lambda item: item.average_comparable_customer_lifetime_value)
|
||||
return None
|
||||
|
||||
|
||||
def _key_drivers(
|
||||
estimate: ComparableLTVEstimate,
|
||||
reference: ComparableLTVEstimate | None,
|
||||
) -> tuple[str, ...]:
|
||||
drivers: list[str] = []
|
||||
metrics = estimate.validation.metrics
|
||||
if reference is None:
|
||||
return ("no_reference_model_available",)
|
||||
|
||||
ref_metrics = reference.validation.metrics
|
||||
if metrics.monthly_margin > ref_metrics.monthly_margin:
|
||||
drivers.append("higher_monthly_margin")
|
||||
if estimate.expected_lifetime_months > reference.expected_lifetime_months:
|
||||
drivers.append("longer_expected_lifetime")
|
||||
if metrics.contract_duration_months > ref_metrics.contract_duration_months:
|
||||
drivers.append("longer_commitment_window")
|
||||
if metrics.payment_fee_pct < ref_metrics.payment_fee_pct:
|
||||
drivers.append("lower_payment_fee_burden")
|
||||
if metrics.minimum_monthly_turnover > ref_metrics.minimum_monthly_turnover:
|
||||
drivers.append("stronger_revenue_commitment")
|
||||
if not drivers:
|
||||
drivers.append("reference_like_economics")
|
||||
return tuple(drivers)
|
||||
|
||||
|
||||
def _comparison_summary(
|
||||
estimate: ComparableLTVEstimate,
|
||||
reference: ComparableLTVEstimate | None,
|
||||
passes_required_improvement: bool,
|
||||
threshold: Decimal | None,
|
||||
) -> str:
|
||||
if reference is None:
|
||||
return f"{estimate.model_name}: no reference model available for improvement comparison."
|
||||
|
||||
improvement = _pct_delta(
|
||||
estimate.average_comparable_customer_lifetime_value,
|
||||
reference.average_comparable_customer_lifetime_value,
|
||||
)
|
||||
if estimate.model_id == reference.model_id:
|
||||
return f"{estimate.model_name}: reference model for this comparable-customer segment."
|
||||
if passes_required_improvement:
|
||||
return (
|
||||
f"{estimate.model_name}: LTV {estimate.average_comparable_customer_lifetime_value} "
|
||||
f"meets threshold {threshold} with improvement {improvement}%."
|
||||
)
|
||||
return (
|
||||
f"{estimate.model_name}: LTV {estimate.average_comparable_customer_lifetime_value} "
|
||||
f"misses threshold {threshold} with improvement {improvement}%."
|
||||
)
|
||||
|
||||
|
||||
def compare_pricing_configurations(
|
||||
configurations: list[PricingConfiguration],
|
||||
profile: ComparableCustomerProfile,
|
||||
boundary_policy: BoundaryPolicy,
|
||||
policy: LTVPolicy,
|
||||
sensitivity_cases: tuple[SensitivityCase, ...] | None = None,
|
||||
) -> PricingComparison:
|
||||
base_estimates = [
|
||||
estimate_comparable_customer_ltv(configuration, profile, boundary_policy, policy)
|
||||
for configuration in configurations
|
||||
]
|
||||
reference = select_reference_estimate(base_estimates, profile.eligible_model_ids)
|
||||
|
||||
comparisons: list[PricingModelComparison] = []
|
||||
for configuration, estimate in zip(configurations, base_estimates, strict=True):
|
||||
outcomes: list[SensitivityOutcome] = []
|
||||
for case in sensitivity_cases or default_sensitivity_cases():
|
||||
scenario_configuration, scenario_profile = _apply_sensitivity(configuration, profile, case)
|
||||
scenario_estimate = estimate_comparable_customer_ltv(
|
||||
scenario_configuration,
|
||||
scenario_profile,
|
||||
boundary_policy,
|
||||
policy,
|
||||
)
|
||||
delta_vs_base = _money(
|
||||
scenario_estimate.average_comparable_customer_lifetime_value
|
||||
- estimate.average_comparable_customer_lifetime_value
|
||||
)
|
||||
outcomes.append(
|
||||
SensitivityOutcome(
|
||||
case_id=case.id,
|
||||
case_name=case.name,
|
||||
average_comparable_customer_lifetime_value=scenario_estimate.average_comparable_customer_lifetime_value,
|
||||
delta_vs_base=delta_vs_base,
|
||||
delta_vs_base_pct=_pct_delta(
|
||||
scenario_estimate.average_comparable_customer_lifetime_value,
|
||||
estimate.average_comparable_customer_lifetime_value,
|
||||
),
|
||||
decision=scenario_estimate.validation.decision,
|
||||
)
|
||||
)
|
||||
|
||||
threshold: Decimal | None = None
|
||||
passes_required_improvement = True
|
||||
if reference is not None and estimate.model_id != reference.model_id:
|
||||
threshold = required_improvement_threshold(
|
||||
reference.average_comparable_customer_lifetime_value,
|
||||
policy.required_improvement_factor,
|
||||
)
|
||||
passes_required_improvement = (
|
||||
estimate.average_comparable_customer_lifetime_value >= threshold
|
||||
)
|
||||
|
||||
improvement_vs_reference_pct = (
|
||||
Decimal("0.0")
|
||||
if reference is not None and estimate.model_id == reference.model_id
|
||||
else (
|
||||
_pct_delta(
|
||||
estimate.average_comparable_customer_lifetime_value,
|
||||
reference.average_comparable_customer_lifetime_value,
|
||||
)
|
||||
if reference is not None
|
||||
else None
|
||||
)
|
||||
)
|
||||
|
||||
sensitivity_floor = min(
|
||||
[estimate.average_comparable_customer_lifetime_value]
|
||||
+ [item.average_comparable_customer_lifetime_value for item in outcomes]
|
||||
)
|
||||
sensitivity_ceiling = max(
|
||||
[estimate.average_comparable_customer_lifetime_value]
|
||||
+ [item.average_comparable_customer_lifetime_value for item in outcomes]
|
||||
)
|
||||
|
||||
comparisons.append(
|
||||
PricingModelComparison(
|
||||
model_id=estimate.model_id,
|
||||
model_name=estimate.model_name,
|
||||
model_type=configuration.model.model_type,
|
||||
status=configuration.model.status,
|
||||
validation_decision=estimate.validation.decision,
|
||||
valid=estimate.validation.valid,
|
||||
requires_approval=estimate.validation.requires_approval,
|
||||
average_comparable_customer_lifetime_value=estimate.average_comparable_customer_lifetime_value,
|
||||
expected_lifetime_months=estimate.expected_lifetime_months,
|
||||
base_monthly_margin=estimate.validation.metrics.monthly_margin,
|
||||
base_margin_pct=estimate.validation.metrics.margin_pct,
|
||||
payment_fee_pct=estimate.validation.metrics.payment_fee_pct,
|
||||
contract_duration_months=estimate.validation.metrics.contract_duration_months,
|
||||
reference_model_id=reference.model_id if reference else None,
|
||||
passes_required_improvement=passes_required_improvement,
|
||||
required_improvement_threshold=threshold,
|
||||
improvement_vs_reference_pct=improvement_vs_reference_pct,
|
||||
sensitivity=tuple(outcomes),
|
||||
sensitivity_floor_ltv=sensitivity_floor,
|
||||
sensitivity_ceiling_ltv=sensitivity_ceiling,
|
||||
key_drivers=_key_drivers(estimate, reference),
|
||||
comparison_summary=_comparison_summary(
|
||||
estimate,
|
||||
reference,
|
||||
passes_required_improvement,
|
||||
threshold,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
best_ltv = max(comparisons, key=lambda item: item.average_comparable_customer_lifetime_value, default=None)
|
||||
valid_comparisons = [item for item in comparisons if item.valid]
|
||||
best_valid = max(valid_comparisons, key=lambda item: item.average_comparable_customer_lifetime_value, default=None)
|
||||
|
||||
notes = (
|
||||
"Comparable-customer LTV uses discounted expected seller margin over a finite horizon.",
|
||||
"Reference selection prefers valid eligible predefined models with the highest seller LTV.",
|
||||
"Negative reference LTV uses additive improvement semantics so tuned models must become less negative, not more negative.",
|
||||
)
|
||||
|
||||
return PricingComparison(
|
||||
profile=profile,
|
||||
policy=policy,
|
||||
boundary_policy=boundary_policy,
|
||||
reference_model_id=reference.model_id if reference else None,
|
||||
best_ltv_model_id=best_ltv.model_id if best_ltv else None,
|
||||
best_valid_model_id=best_valid.model_id if best_valid else None,
|
||||
comparisons=tuple(comparisons),
|
||||
notes=notes,
|
||||
)
|
||||
511
adaptive_pricing_core/customer_tuning.py
Normal file
511
adaptive_pricing_core/customer_tuning.py
Normal file
@@ -0,0 +1,511 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Literal
|
||||
|
||||
from .boundary_engine import (
|
||||
BoundaryPolicy,
|
||||
CommitmentTerms,
|
||||
ConstraintResult,
|
||||
PricingConfiguration,
|
||||
ValidationResult,
|
||||
)
|
||||
from .comparable_ltv import (
|
||||
ComparableCustomerProfile,
|
||||
ComparableLTVEstimate,
|
||||
LTVPolicy,
|
||||
estimate_comparable_customer_ltv,
|
||||
required_improvement_threshold,
|
||||
select_reference_estimate,
|
||||
)
|
||||
|
||||
SolverPreference = Literal["lower_usage_price", "seller_ltv"]
|
||||
ApprovalMode = Literal["self_serve_only", "allow_approval"]
|
||||
TuningDecision = Literal["accepted", "requires_approval", "rejected"]
|
||||
|
||||
TWOPLACES = Decimal("0.01")
|
||||
|
||||
|
||||
def _money(value: Decimal) -> Decimal:
|
||||
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _usage_component(configuration: PricingConfiguration):
|
||||
return next(
|
||||
(component for component in configuration.model.charge_components if component.kind == "usage"),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _default_usage_unit_price(configuration: PricingConfiguration) -> Decimal:
|
||||
usage_component = _usage_component(configuration)
|
||||
if configuration.usage_unit_price is not None:
|
||||
return configuration.usage_unit_price
|
||||
if usage_component and usage_component.unit_price is not None:
|
||||
return usage_component.unit_price
|
||||
for parameter in configuration.model.tunable_parameters:
|
||||
if parameter.key == "overage_unit_price" and parameter.default_value not in (None, ""):
|
||||
return Decimal(str(parameter.default_value))
|
||||
return Decimal("0")
|
||||
|
||||
|
||||
def _percent_delta(candidate: Decimal, reference: Decimal) -> Decimal | None:
|
||||
if reference == Decimal("0"):
|
||||
return None
|
||||
return _money(((candidate - reference) / abs(reference)) * Decimal("100"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UsagePriceSearchPolicy:
|
||||
min_usage_unit_price: Decimal | None = None
|
||||
max_usage_unit_price: Decimal | None = None
|
||||
usage_unit_price_step: Decimal = Decimal("0.0001")
|
||||
max_usage_price_multiplier: Decimal = Decimal("4")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomerTuningRequest:
|
||||
included_units: Decimal | None = None
|
||||
contract_duration_months: int | None = None
|
||||
minimum_monthly_turnover: Decimal = Decimal("0")
|
||||
prepaid_amount: Decimal = Decimal("0")
|
||||
guaranteed_platform_fee: Decimal = Decimal("0")
|
||||
customer_funded_onboarding: Decimal = Decimal("0")
|
||||
reduced_cancellation_flexibility: bool | None = None
|
||||
preference: SolverPreference = "lower_usage_price"
|
||||
approval_mode: ApprovalMode = "self_serve_only"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CustomerTuningOutcome:
|
||||
model_id: str
|
||||
model_name: str
|
||||
decision: TuningDecision
|
||||
valid: bool
|
||||
requires_approval: bool
|
||||
preference: SolverPreference
|
||||
approval_mode: ApprovalMode
|
||||
request: CustomerTuningRequest
|
||||
solved_configuration: dict[str, object]
|
||||
solved_usage_unit_price: Decimal
|
||||
reference_model_id: str | None
|
||||
reference_model_name: str | None
|
||||
reference_ltv: Decimal | None
|
||||
required_improvement_threshold: Decimal | None
|
||||
average_comparable_customer_lifetime_value: Decimal
|
||||
improvement_vs_reference_pct: Decimal | None
|
||||
passes_required_improvement: bool
|
||||
evaluated_candidates: int
|
||||
tradeoffs: tuple[str, ...]
|
||||
binding_constraints: tuple[ConstraintResult, ...]
|
||||
validation: ValidationResult
|
||||
explanation: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _CandidateAssessment:
|
||||
configuration: PricingConfiguration
|
||||
estimate: ComparableLTVEstimate
|
||||
decision: TuningDecision
|
||||
passes_required_improvement: bool
|
||||
improvement_vs_reference_pct: Decimal | None
|
||||
|
||||
|
||||
def _price_range(
|
||||
configuration: PricingConfiguration,
|
||||
search_policy: UsagePriceSearchPolicy,
|
||||
) -> tuple[Decimal, ...]:
|
||||
step = search_policy.usage_unit_price_step
|
||||
if step <= Decimal("0"):
|
||||
raise ValueError("usage_unit_price_step must be positive")
|
||||
|
||||
default_usage_price = _default_usage_unit_price(configuration)
|
||||
min_usage_price = search_policy.min_usage_unit_price
|
||||
if min_usage_price is None:
|
||||
min_usage_price = max(configuration.unit_cost, default_usage_price / Decimal("10"), step)
|
||||
max_usage_price = search_policy.max_usage_unit_price
|
||||
if max_usage_price is None:
|
||||
base = default_usage_price if default_usage_price > Decimal("0") else step
|
||||
max_usage_price = max(min_usage_price, base * search_policy.max_usage_price_multiplier)
|
||||
|
||||
if max_usage_price < min_usage_price:
|
||||
max_usage_price = min_usage_price
|
||||
|
||||
values: list[Decimal] = []
|
||||
current = min_usage_price
|
||||
while current <= max_usage_price:
|
||||
values.append(current)
|
||||
current += step
|
||||
if not values or values[-1] != max_usage_price:
|
||||
values.append(max_usage_price)
|
||||
return tuple(dict.fromkeys(values))
|
||||
|
||||
|
||||
def _resolved_search_policy(
|
||||
configuration: PricingConfiguration,
|
||||
request: CustomerTuningRequest,
|
||||
search_policy: UsagePriceSearchPolicy | None,
|
||||
) -> UsagePriceSearchPolicy:
|
||||
policy = search_policy or UsagePriceSearchPolicy()
|
||||
if request.preference != "lower_usage_price" or policy.max_usage_unit_price is not None:
|
||||
return policy
|
||||
|
||||
return replace(
|
||||
policy,
|
||||
max_usage_unit_price=_default_usage_unit_price(configuration),
|
||||
)
|
||||
|
||||
|
||||
def _commitment_terms(
|
||||
base_terms: CommitmentTerms,
|
||||
request: CustomerTuningRequest,
|
||||
) -> CommitmentTerms:
|
||||
return replace(
|
||||
base_terms,
|
||||
contract_duration_months=(
|
||||
request.contract_duration_months
|
||||
if request.contract_duration_months is not None
|
||||
else base_terms.contract_duration_months
|
||||
),
|
||||
minimum_monthly_turnover=request.minimum_monthly_turnover,
|
||||
prepaid_amount=request.prepaid_amount,
|
||||
guaranteed_platform_fee=request.guaranteed_platform_fee,
|
||||
customer_funded_onboarding=request.customer_funded_onboarding,
|
||||
reduced_cancellation_flexibility=(
|
||||
request.reduced_cancellation_flexibility
|
||||
if request.reduced_cancellation_flexibility is not None
|
||||
else base_terms.reduced_cancellation_flexibility
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _candidate_configuration(
|
||||
base_configuration: PricingConfiguration,
|
||||
request: CustomerTuningRequest,
|
||||
usage_unit_price: Decimal,
|
||||
) -> PricingConfiguration:
|
||||
return replace(
|
||||
base_configuration,
|
||||
included_units=(
|
||||
request.included_units
|
||||
if request.included_units is not None
|
||||
else base_configuration.included_units
|
||||
),
|
||||
usage_unit_price=usage_unit_price,
|
||||
commitment_terms=_commitment_terms(base_configuration.commitment_terms, request),
|
||||
)
|
||||
|
||||
|
||||
def _candidate_decision(
|
||||
validation: ValidationResult,
|
||||
passes_required_improvement: bool,
|
||||
approval_mode: ApprovalMode,
|
||||
) -> TuningDecision:
|
||||
if not validation.valid or not passes_required_improvement:
|
||||
return "rejected"
|
||||
if validation.requires_approval:
|
||||
return "requires_approval" if approval_mode == "allow_approval" else "rejected"
|
||||
return "accepted"
|
||||
|
||||
|
||||
def _headroom_by_constraint(
|
||||
configuration: PricingConfiguration,
|
||||
validation: ValidationResult,
|
||||
) -> dict[str, Decimal]:
|
||||
metrics = validation.metrics
|
||||
policy = validation.policy
|
||||
return {
|
||||
"usage-variance-limit": policy.max_expected_usage_variance_pct - configuration.expected_usage_variance_pct,
|
||||
"payment-fee-limit": policy.max_payment_fee_pct - metrics.payment_fee_pct,
|
||||
"cost-floor-coverage": metrics.monthly_margin,
|
||||
"minimum-margin": metrics.margin_pct - policy.minimum_margin_pct,
|
||||
"target-margin-approval": metrics.margin_pct - policy.target_margin_pct,
|
||||
"discount-exposure-limit": policy.max_discount_pct - metrics.concession_pct,
|
||||
"discount-approval-threshold": policy.approval_discount_pct - metrics.concession_pct,
|
||||
}
|
||||
|
||||
|
||||
def _binding_constraints(
|
||||
configuration: PricingConfiguration,
|
||||
validation: ValidationResult,
|
||||
) -> tuple[ConstraintResult, ...]:
|
||||
flagged = tuple(result for result in validation.constraints if result.status != "pass")
|
||||
if flagged:
|
||||
return flagged
|
||||
|
||||
headroom = _headroom_by_constraint(configuration, validation)
|
||||
ordered_ids = [
|
||||
constraint_id
|
||||
for constraint_id, _headroom in sorted(headroom.items(), key=lambda item: item[1])
|
||||
if constraint_id in {result.id for result in validation.constraints}
|
||||
]
|
||||
selected_ids = ordered_ids[:2]
|
||||
if not selected_ids:
|
||||
return ()
|
||||
|
||||
return tuple(
|
||||
result for result in validation.constraints if result.id in selected_ids
|
||||
)
|
||||
|
||||
|
||||
def _tradeoffs(
|
||||
base_configuration: PricingConfiguration,
|
||||
candidate_configuration: PricingConfiguration,
|
||||
validation: ValidationResult,
|
||||
) -> tuple[str, ...]:
|
||||
tradeoffs: list[str] = []
|
||||
|
||||
if (
|
||||
base_configuration.included_units is not None
|
||||
and candidate_configuration.included_units is not None
|
||||
and candidate_configuration.included_units < base_configuration.included_units
|
||||
):
|
||||
tradeoffs.append("lower_included_usage")
|
||||
if (
|
||||
base_configuration.included_units is not None
|
||||
and candidate_configuration.included_units is not None
|
||||
and candidate_configuration.included_units > base_configuration.included_units
|
||||
):
|
||||
tradeoffs.append("higher_included_usage")
|
||||
if (
|
||||
base_configuration.usage_unit_price is not None
|
||||
and candidate_configuration.usage_unit_price is not None
|
||||
and candidate_configuration.usage_unit_price < base_configuration.usage_unit_price
|
||||
):
|
||||
tradeoffs.append("lower_usage_price")
|
||||
if (
|
||||
base_configuration.usage_unit_price is not None
|
||||
and candidate_configuration.usage_unit_price is not None
|
||||
and candidate_configuration.usage_unit_price > base_configuration.usage_unit_price
|
||||
):
|
||||
tradeoffs.append("higher_usage_price")
|
||||
|
||||
baseline_duration = base_configuration.commitment_terms.contract_duration_months or 0
|
||||
candidate_duration = validation.metrics.contract_duration_months
|
||||
if candidate_duration > baseline_duration:
|
||||
tradeoffs.append("longer_contract_duration")
|
||||
if validation.metrics.minimum_monthly_turnover > Decimal("0"):
|
||||
tradeoffs.append("minimum_monthly_turnover")
|
||||
if validation.metrics.prepaid_amount > Decimal("0"):
|
||||
tradeoffs.append("prepayment")
|
||||
if validation.metrics.guaranteed_platform_fee > Decimal("0"):
|
||||
tradeoffs.append("guaranteed_platform_fee")
|
||||
if validation.metrics.customer_funded_onboarding > Decimal("0"):
|
||||
tradeoffs.append("customer_funded_onboarding")
|
||||
if validation.metrics.reduced_cancellation_flexibility:
|
||||
tradeoffs.append("reduced_cancellation_flexibility")
|
||||
|
||||
for signal in validation.metrics.meaningful_commitment_signals:
|
||||
if signal not in tradeoffs:
|
||||
tradeoffs.append(signal)
|
||||
|
||||
return tuple(tradeoffs)
|
||||
|
||||
|
||||
def _explanation(
|
||||
assessment: _CandidateAssessment,
|
||||
request: CustomerTuningRequest,
|
||||
reference_estimate: ComparableLTVEstimate | None,
|
||||
threshold: Decimal | None,
|
||||
tradeoffs: tuple[str, ...],
|
||||
binding_constraints: tuple[ConstraintResult, ...],
|
||||
) -> str:
|
||||
validation = assessment.estimate.validation
|
||||
metrics = validation.metrics
|
||||
if assessment.decision in {"accepted", "requires_approval"}:
|
||||
outcome = (
|
||||
"Accepted self-serve tuning"
|
||||
if assessment.decision == "accepted"
|
||||
else "Requires seller approval"
|
||||
)
|
||||
parts = [
|
||||
f"{outcome} at {metrics.usage_unit_price} {metrics.currency} usage price.",
|
||||
(
|
||||
f"Comparable-customer LTV {assessment.estimate.average_comparable_customer_lifetime_value} "
|
||||
f"{metrics.currency}"
|
||||
),
|
||||
]
|
||||
if reference_estimate is not None and threshold is not None:
|
||||
parts.append(
|
||||
f"clears threshold {threshold} {metrics.currency} versus {reference_estimate.model_name}."
|
||||
)
|
||||
if tradeoffs:
|
||||
parts.append("Trade-offs: " + ", ".join(tradeoffs) + ".")
|
||||
return " ".join(parts)
|
||||
|
||||
failed_constraints = [result.title for result in binding_constraints if result.status == "fail"]
|
||||
review_constraints = [result.title for result in binding_constraints if result.status == "review"]
|
||||
parts = ["Rejected tuning request."]
|
||||
if not assessment.passes_required_improvement and reference_estimate is not None and threshold is not None:
|
||||
parts.append(
|
||||
(
|
||||
f"LTV {assessment.estimate.average_comparable_customer_lifetime_value} {metrics.currency} "
|
||||
f"misses threshold {threshold} {metrics.currency} versus {reference_estimate.model_name}."
|
||||
)
|
||||
)
|
||||
if failed_constraints:
|
||||
parts.append("Hard blockers: " + ", ".join(failed_constraints) + ".")
|
||||
if review_constraints and request.approval_mode == "self_serve_only":
|
||||
parts.append("Self-serve blockers: " + ", ".join(review_constraints) + ".")
|
||||
if tradeoffs:
|
||||
parts.append("Attempted trade-offs: " + ", ".join(tradeoffs) + ".")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _acceptable_candidates(
|
||||
candidates: tuple[_CandidateAssessment, ...],
|
||||
) -> tuple[_CandidateAssessment, ...]:
|
||||
return tuple(candidate for candidate in candidates if candidate.decision in {"accepted", "requires_approval"})
|
||||
|
||||
|
||||
def _candidate_sort_key(
|
||||
candidate: _CandidateAssessment,
|
||||
preference: SolverPreference,
|
||||
) -> tuple[Decimal, Decimal]:
|
||||
usage_price = candidate.estimate.validation.metrics.usage_unit_price
|
||||
ltv = candidate.estimate.average_comparable_customer_lifetime_value
|
||||
if preference == "lower_usage_price":
|
||||
return (usage_price, -ltv)
|
||||
return (-ltv, usage_price)
|
||||
|
||||
|
||||
def _fallback_sort_key(
|
||||
candidate: _CandidateAssessment,
|
||||
preference: SolverPreference,
|
||||
) -> tuple[int, int, int, Decimal, Decimal]:
|
||||
usage_price = candidate.estimate.validation.metrics.usage_unit_price
|
||||
ltv = candidate.estimate.average_comparable_customer_lifetime_value
|
||||
return (
|
||||
0 if candidate.passes_required_improvement else 1,
|
||||
0 if candidate.estimate.validation.valid else 1,
|
||||
0 if not candidate.estimate.validation.requires_approval else 1,
|
||||
usage_price if preference == "lower_usage_price" else -ltv,
|
||||
-ltv if preference == "lower_usage_price" else usage_price,
|
||||
)
|
||||
|
||||
|
||||
def _select_candidate(
|
||||
candidates: tuple[_CandidateAssessment, ...],
|
||||
preference: SolverPreference,
|
||||
) -> _CandidateAssessment:
|
||||
acceptable = _acceptable_candidates(candidates)
|
||||
if acceptable:
|
||||
return min(acceptable, key=lambda candidate: _candidate_sort_key(candidate, preference))
|
||||
return min(candidates, key=lambda candidate: _fallback_sort_key(candidate, preference))
|
||||
|
||||
|
||||
def solve_customer_tuning(
|
||||
base_configuration: PricingConfiguration,
|
||||
reference_configurations: list[PricingConfiguration],
|
||||
profile: ComparableCustomerProfile,
|
||||
boundary_policy: BoundaryPolicy,
|
||||
ltv_policy: LTVPolicy,
|
||||
request: CustomerTuningRequest,
|
||||
search_policy: UsagePriceSearchPolicy | None = None,
|
||||
) -> CustomerTuningOutcome:
|
||||
if _usage_component(base_configuration) is None:
|
||||
raise ValueError("customer tuning prototype currently requires a usage-priced model")
|
||||
|
||||
reference_estimates = [
|
||||
estimate_comparable_customer_ltv(configuration, profile, boundary_policy, ltv_policy)
|
||||
for configuration in reference_configurations
|
||||
]
|
||||
reference_estimate = select_reference_estimate(reference_estimates, profile.eligible_model_ids)
|
||||
threshold = (
|
||||
required_improvement_threshold(
|
||||
reference_estimate.average_comparable_customer_lifetime_value,
|
||||
ltv_policy.required_improvement_factor,
|
||||
)
|
||||
if reference_estimate is not None
|
||||
else None
|
||||
)
|
||||
|
||||
candidates: list[_CandidateAssessment] = []
|
||||
for usage_unit_price in _price_range(
|
||||
base_configuration,
|
||||
_resolved_search_policy(base_configuration, request, search_policy),
|
||||
):
|
||||
configuration = _candidate_configuration(base_configuration, request, usage_unit_price)
|
||||
estimate = estimate_comparable_customer_ltv(
|
||||
configuration,
|
||||
profile,
|
||||
boundary_policy,
|
||||
ltv_policy,
|
||||
)
|
||||
passes_required_improvement = (
|
||||
True
|
||||
if threshold is None
|
||||
else estimate.average_comparable_customer_lifetime_value >= threshold
|
||||
)
|
||||
decision = _candidate_decision(
|
||||
estimate.validation,
|
||||
passes_required_improvement,
|
||||
request.approval_mode,
|
||||
)
|
||||
candidates.append(
|
||||
_CandidateAssessment(
|
||||
configuration=configuration,
|
||||
estimate=estimate,
|
||||
decision=decision,
|
||||
passes_required_improvement=passes_required_improvement,
|
||||
improvement_vs_reference_pct=(
|
||||
_percent_delta(
|
||||
estimate.average_comparable_customer_lifetime_value,
|
||||
reference_estimate.average_comparable_customer_lifetime_value,
|
||||
)
|
||||
if reference_estimate is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if not candidates:
|
||||
raise ValueError("customer tuning search produced no candidates")
|
||||
|
||||
selected = _select_candidate(tuple(candidates), request.preference)
|
||||
binding_constraints = _binding_constraints(selected.configuration, selected.estimate.validation)
|
||||
tradeoffs = _tradeoffs(
|
||||
base_configuration,
|
||||
selected.configuration,
|
||||
selected.estimate.validation,
|
||||
)
|
||||
explanation = _explanation(
|
||||
selected,
|
||||
request,
|
||||
reference_estimate,
|
||||
threshold,
|
||||
tradeoffs,
|
||||
binding_constraints,
|
||||
)
|
||||
|
||||
return CustomerTuningOutcome(
|
||||
model_id=base_configuration.model.id,
|
||||
model_name=base_configuration.model.name,
|
||||
decision=selected.decision,
|
||||
valid=selected.estimate.validation.valid,
|
||||
requires_approval=selected.estimate.validation.requires_approval,
|
||||
preference=request.preference,
|
||||
approval_mode=request.approval_mode,
|
||||
request=request,
|
||||
solved_configuration=selected.estimate.validation.configuration,
|
||||
solved_usage_unit_price=selected.estimate.validation.metrics.usage_unit_price,
|
||||
reference_model_id=reference_estimate.model_id if reference_estimate else None,
|
||||
reference_model_name=reference_estimate.model_name if reference_estimate else None,
|
||||
reference_ltv=(
|
||||
reference_estimate.average_comparable_customer_lifetime_value
|
||||
if reference_estimate is not None
|
||||
else None
|
||||
),
|
||||
required_improvement_threshold=threshold,
|
||||
average_comparable_customer_lifetime_value=(
|
||||
selected.estimate.average_comparable_customer_lifetime_value
|
||||
),
|
||||
improvement_vs_reference_pct=selected.improvement_vs_reference_pct,
|
||||
passes_required_improvement=selected.passes_required_improvement,
|
||||
evaluated_candidates=len(candidates),
|
||||
tradeoffs=tradeoffs,
|
||||
binding_constraints=binding_constraints,
|
||||
validation=selected.estimate.validation,
|
||||
explanation=explanation,
|
||||
)
|
||||
174
adaptive_pricing_core/governance.py
Normal file
174
adaptive_pricing_core/governance.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
GovernanceDecision = Literal["proceed", "approval_required", "blocked"]
|
||||
RecommendationType = Literal["research", "simulation", "model_change", "execution"]
|
||||
RecommendationPriority = Literal["high", "medium", "low"]
|
||||
RiskSeverity = Literal["low", "medium", "high"]
|
||||
HealthStatus = Literal["pass", "warn", "fail"]
|
||||
|
||||
|
||||
def _decimal(value: Decimal | str | int | float | None, default: str = "0") -> Decimal:
|
||||
if value in (None, ""):
|
||||
return Decimal(default)
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernancePolicy:
|
||||
policy_id: str
|
||||
max_self_serve_discount_pct: Decimal = Decimal("10")
|
||||
max_customer_visible_price_increase_pct: Decimal = Decimal("15")
|
||||
max_active_experiments: int = 2
|
||||
max_concurrent_candidate_rollouts: int = 1
|
||||
require_approval_for_candidate_rollout: bool = True
|
||||
require_approval_for_approximate_provider_mapping: bool = True
|
||||
block_unsupported_provider_artifacts: bool = True
|
||||
drift_blocks_execution: bool = True
|
||||
require_approval_for_price_change: bool = True
|
||||
require_customer_notice_for_price_increase: bool = True
|
||||
customer_notice_days: int = 30
|
||||
grandfather_existing_customers: bool = True
|
||||
customer_visible_tuning_enabled: bool = False
|
||||
customer_visible_tuning_requires_active_model: bool = True
|
||||
communication_owner_role: str = "operator"
|
||||
default_approver_role: str = "operator"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApprovalRequirement:
|
||||
id: str
|
||||
title: str
|
||||
approver_role: str
|
||||
reason: str
|
||||
blocking: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceRisk:
|
||||
id: str
|
||||
severity: RiskSeverity
|
||||
summary: str
|
||||
mitigation: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupportingObservation:
|
||||
id: str
|
||||
title: str
|
||||
summary: str
|
||||
source_ref: str
|
||||
value: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceAssessment:
|
||||
decision: GovernanceDecision
|
||||
summary: str
|
||||
approvals: tuple[ApprovalRequirement, ...]
|
||||
risks: tuple[GovernanceRisk, ...]
|
||||
supporting_observations: tuple[SupportingObservation, ...]
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SellerRecommendation:
|
||||
id: str
|
||||
recommendation_type: RecommendationType
|
||||
priority: RecommendationPriority
|
||||
title: str
|
||||
rationale: str
|
||||
suggested_action: str
|
||||
confidence: Decimal
|
||||
governance: GovernanceAssessment
|
||||
risks: tuple[GovernanceRisk, ...]
|
||||
supporting_observations: tuple[SupportingObservation, ...]
|
||||
related_model_ids: tuple[str, ...] = ()
|
||||
related_profile_ids: tuple[str, ...] = ()
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthCheck:
|
||||
id: str
|
||||
title: str
|
||||
status: HealthStatus
|
||||
summary: str
|
||||
value: str | None = None
|
||||
threshold: str | None = None
|
||||
suggested_action: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SafeTuningParameter:
|
||||
key: str
|
||||
label: str
|
||||
description: str
|
||||
data_type: str
|
||||
default_value: str | None = None
|
||||
min_value: str | None = None
|
||||
max_value: str | None = None
|
||||
customer_visible: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SafeTuningExample:
|
||||
id: str
|
||||
title: str
|
||||
outcome: str
|
||||
summary: str
|
||||
customer_message: str
|
||||
visible_to_customer: bool
|
||||
tradeoffs: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SafeTuningContract:
|
||||
model_id: str
|
||||
model_name: str
|
||||
mode: str
|
||||
customer_visible: bool
|
||||
tunable_parameters: tuple[SafeTuningParameter, ...]
|
||||
tradeoff_lexicon: dict[str, str]
|
||||
examples: tuple[SafeTuningExample, ...]
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def governance_policy_from_dict(raw: dict[str, Any]) -> GovernancePolicy:
|
||||
return GovernancePolicy(
|
||||
policy_id=raw.get("policy_id", "default-governance-policy"),
|
||||
max_self_serve_discount_pct=_decimal(raw.get("max_self_serve_discount_pct"), "10"),
|
||||
max_customer_visible_price_increase_pct=_decimal(
|
||||
raw.get("max_customer_visible_price_increase_pct"),
|
||||
"15",
|
||||
),
|
||||
max_active_experiments=int(raw.get("max_active_experiments", 2)),
|
||||
max_concurrent_candidate_rollouts=int(raw.get("max_concurrent_candidate_rollouts", 1)),
|
||||
require_approval_for_candidate_rollout=bool(
|
||||
raw.get("require_approval_for_candidate_rollout", True)
|
||||
),
|
||||
require_approval_for_approximate_provider_mapping=bool(
|
||||
raw.get("require_approval_for_approximate_provider_mapping", True)
|
||||
),
|
||||
block_unsupported_provider_artifacts=bool(
|
||||
raw.get("block_unsupported_provider_artifacts", True)
|
||||
),
|
||||
drift_blocks_execution=bool(raw.get("drift_blocks_execution", True)),
|
||||
require_approval_for_price_change=bool(raw.get("require_approval_for_price_change", True)),
|
||||
require_customer_notice_for_price_increase=bool(
|
||||
raw.get("require_customer_notice_for_price_increase", True)
|
||||
),
|
||||
customer_notice_days=int(raw.get("customer_notice_days", 30)),
|
||||
grandfather_existing_customers=bool(raw.get("grandfather_existing_customers", True)),
|
||||
customer_visible_tuning_enabled=bool(raw.get("customer_visible_tuning_enabled", False)),
|
||||
customer_visible_tuning_requires_active_model=bool(
|
||||
raw.get("customer_visible_tuning_requires_active_model", True)
|
||||
),
|
||||
communication_owner_role=raw.get("communication_owner_role", "operator"),
|
||||
default_approver_role=raw.get("default_approver_role", "operator"),
|
||||
metadata=dict(raw.get("metadata", {})),
|
||||
)
|
||||
323
adaptive_pricing_core/pricing_models.py
Normal file
323
adaptive_pricing_core/pricing_models.py
Normal file
@@ -0,0 +1,323 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
PricingModelStatus = Literal["active", "candidate", "retired"]
|
||||
ChargeComponentKind = Literal[
|
||||
"access",
|
||||
"setup",
|
||||
"usage",
|
||||
"support",
|
||||
"discount",
|
||||
"risk_adjustment",
|
||||
]
|
||||
ParameterClass = Literal[
|
||||
"fixed",
|
||||
"seller_controlled",
|
||||
"customer_tunable",
|
||||
"calculated",
|
||||
"constrained",
|
||||
"provider",
|
||||
]
|
||||
|
||||
_ALLOWED_COMPONENT_KINDS = {
|
||||
"access",
|
||||
"setup",
|
||||
"usage",
|
||||
"support",
|
||||
"discount",
|
||||
"risk_adjustment",
|
||||
}
|
||||
_ALLOWED_PARAMETER_CLASSES = {
|
||||
"fixed",
|
||||
"seller_controlled",
|
||||
"customer_tunable",
|
||||
"calculated",
|
||||
"constrained",
|
||||
"provider",
|
||||
}
|
||||
|
||||
|
||||
def _money(value: str | int | float | Decimal | None) -> Decimal | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _tuple_dict(value: dict[str, Any] | None) -> dict[str, Any]:
|
||||
return dict(value or {})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ChargeComponent:
|
||||
id: str
|
||||
kind: ChargeComponentKind | str
|
||||
amount: Decimal | None = None
|
||||
cadence: str | None = None
|
||||
meter: str | None = None
|
||||
unit: str | None = None
|
||||
unit_price: Decimal | None = None
|
||||
included_units: Decimal | None = None
|
||||
label: str | None = None
|
||||
billing_treatment: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Commitment:
|
||||
id: str
|
||||
kind: str
|
||||
value: str
|
||||
unit: str | None = None
|
||||
description: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TunableParameter:
|
||||
key: str
|
||||
parameter_class: ParameterClass | str
|
||||
data_type: str
|
||||
description: str = ""
|
||||
default_value: str | None = None
|
||||
min_value: Decimal | None = None
|
||||
max_value: Decimal | None = None
|
||||
options: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PricingModel:
|
||||
id: str
|
||||
name: str
|
||||
model_type: str
|
||||
lifecycle_phase: str
|
||||
currency: str
|
||||
access_fee_amount: Decimal
|
||||
access_fee_cadence: str
|
||||
status: PricingModelStatus
|
||||
description: str = ""
|
||||
included_usage: str | None = None
|
||||
overage_meter: str | None = None
|
||||
charge_components: tuple[ChargeComponent, ...] = ()
|
||||
commitments: tuple[Commitment, ...] = ()
|
||||
tunable_parameters: tuple[TunableParameter, ...] = ()
|
||||
eligibility: tuple[str, ...] = ()
|
||||
provider_hints: dict[str, Any] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _parse_charge_component(raw: dict[str, Any]) -> ChargeComponent:
|
||||
return ChargeComponent(
|
||||
id=raw["id"],
|
||||
kind=raw["kind"],
|
||||
amount=_money(raw.get("amount")),
|
||||
cadence=raw.get("cadence"),
|
||||
meter=raw.get("meter"),
|
||||
unit=raw.get("unit"),
|
||||
unit_price=_money(raw.get("unit_price")),
|
||||
included_units=_money(raw.get("included_units")),
|
||||
label=raw.get("label"),
|
||||
billing_treatment=raw.get("billing_treatment"),
|
||||
metadata=_tuple_dict(raw.get("metadata")),
|
||||
)
|
||||
|
||||
|
||||
def _parse_commitment(raw: dict[str, Any]) -> Commitment:
|
||||
return Commitment(
|
||||
id=raw["id"],
|
||||
kind=raw["kind"],
|
||||
value=str(raw["value"]),
|
||||
unit=raw.get("unit"),
|
||||
description=raw.get("description", ""),
|
||||
)
|
||||
|
||||
|
||||
def _parse_tunable_parameter(raw: dict[str, Any]) -> TunableParameter:
|
||||
return TunableParameter(
|
||||
key=raw["key"],
|
||||
parameter_class=raw["parameter_class"],
|
||||
data_type=raw["data_type"],
|
||||
description=raw.get("description", ""),
|
||||
default_value=str(raw["default_value"]) if raw.get("default_value") is not None else None,
|
||||
min_value=_money(raw.get("min_value")),
|
||||
max_value=_money(raw.get("max_value")),
|
||||
options=tuple(str(item) for item in raw.get("options", [])),
|
||||
)
|
||||
|
||||
|
||||
def _legacy_charge_components(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
components: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": f"{raw['id']}-access",
|
||||
"kind": "access",
|
||||
"amount": raw["access_fee_amount"],
|
||||
"cadence": raw["access_fee_cadence"],
|
||||
"label": "Recurring access fee",
|
||||
"billing_treatment": "recurring",
|
||||
"metadata": {"included_usage": raw.get("included_usage")},
|
||||
}
|
||||
]
|
||||
if raw.get("model_type") == "hybrid_subscription_usage" or raw.get("overage_meter"):
|
||||
components.append(
|
||||
{
|
||||
"id": f"{raw['id']}-usage",
|
||||
"kind": "usage",
|
||||
"meter": raw.get("overage_meter") or "usage",
|
||||
"unit": raw.get("unit") or "usage_unit",
|
||||
"included_units": raw.get("included_units") or raw.get("included_tokens"),
|
||||
"unit_price": raw.get("unit_price") or raw.get("overage_unit_price"),
|
||||
"label": "Variable usage component",
|
||||
"billing_treatment": "metered",
|
||||
"metadata": {"included_usage": raw.get("included_usage")},
|
||||
}
|
||||
)
|
||||
return components
|
||||
|
||||
|
||||
def _access_component(components: tuple[ChargeComponent, ...]) -> ChargeComponent | None:
|
||||
return next((component for component in components if component.kind == "access"), None)
|
||||
|
||||
|
||||
def _usage_component(components: tuple[ChargeComponent, ...]) -> ChargeComponent | None:
|
||||
return next((component for component in components if component.kind == "usage"), None)
|
||||
|
||||
|
||||
def _parse_pricing_model(raw: dict[str, Any]) -> PricingModel:
|
||||
charge_components = tuple(
|
||||
_parse_charge_component(item)
|
||||
for item in (raw.get("charge_components") or _legacy_charge_components(raw))
|
||||
)
|
||||
access_component = _access_component(charge_components)
|
||||
usage_component = _usage_component(charge_components)
|
||||
|
||||
access_fee_amount = _money(raw.get("access_fee_amount"))
|
||||
if access_fee_amount is None:
|
||||
access_fee_amount = access_component.amount if access_component else Decimal("0")
|
||||
|
||||
access_fee_cadence = raw.get("access_fee_cadence")
|
||||
if access_fee_cadence is None:
|
||||
access_fee_cadence = access_component.cadence if access_component else "monthly"
|
||||
|
||||
metadata = _tuple_dict(raw.get("metadata"))
|
||||
included_usage = raw.get("included_usage") or metadata.get("included_usage")
|
||||
if included_usage is None and access_component:
|
||||
included_usage = access_component.metadata.get("included_usage")
|
||||
if included_usage is None and usage_component:
|
||||
included_usage = usage_component.metadata.get("included_usage")
|
||||
|
||||
overage_meter = raw.get("overage_meter") or (usage_component.meter if usage_component else None)
|
||||
|
||||
return PricingModel(
|
||||
id=raw["id"],
|
||||
name=raw["name"],
|
||||
model_type=raw["model_type"],
|
||||
lifecycle_phase=raw["lifecycle_phase"],
|
||||
currency=raw["currency"],
|
||||
access_fee_amount=access_fee_amount or Decimal("0"),
|
||||
access_fee_cadence=access_fee_cadence or "monthly",
|
||||
status=raw["status"],
|
||||
description=raw.get("description", ""),
|
||||
included_usage=included_usage,
|
||||
overage_meter=overage_meter,
|
||||
charge_components=charge_components,
|
||||
commitments=tuple(
|
||||
_parse_commitment(item) for item in raw.get("commitments", [])
|
||||
),
|
||||
tunable_parameters=tuple(
|
||||
_parse_tunable_parameter(item) for item in raw.get("tunable_parameters", [])
|
||||
),
|
||||
eligibility=tuple(str(item) for item in raw.get("eligibility", [])),
|
||||
provider_hints=_tuple_dict(raw.get("provider_hints")),
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def load_pricing_models(path: str | Path) -> list[PricingModel]:
|
||||
raw = _read_json(Path(path))
|
||||
models = [_parse_pricing_model(item) for item in raw["models"]]
|
||||
issues = validate_pricing_catalog(models)
|
||||
if issues:
|
||||
formatted = "; ".join(
|
||||
f"{model_id}: {', '.join(model_issues)}"
|
||||
for model_id, model_issues in sorted(issues.items())
|
||||
)
|
||||
raise ValueError(f"invalid pricing catalog: {formatted}")
|
||||
return models
|
||||
|
||||
|
||||
def validate_pricing_catalog(models: list[PricingModel]) -> dict[str, list[str]]:
|
||||
issues: dict[str, list[str]] = {}
|
||||
ids = [model.id for model in models]
|
||||
if len(ids) != len(set(ids)):
|
||||
issues.setdefault("__catalog__", []).append("duplicate model ids")
|
||||
for model in models:
|
||||
model_issues = validate_pricing_model(model)
|
||||
if model_issues:
|
||||
issues[model.id] = model_issues
|
||||
return issues
|
||||
|
||||
|
||||
def validate_pricing_model(model: PricingModel) -> list[str]:
|
||||
issues: list[str] = []
|
||||
|
||||
if model.status not in {"active", "candidate", "retired"}:
|
||||
issues.append(f"unsupported status '{model.status}'")
|
||||
|
||||
if model.access_fee_amount < Decimal("0"):
|
||||
issues.append("access_fee_amount must be non-negative")
|
||||
|
||||
if not model.charge_components:
|
||||
issues.append("at least one charge component is required")
|
||||
|
||||
component_ids = [component.id for component in model.charge_components]
|
||||
if len(component_ids) != len(set(component_ids)):
|
||||
issues.append("charge component ids must be unique")
|
||||
|
||||
access_components = [component for component in model.charge_components if component.kind == "access"]
|
||||
if len(access_components) != 1:
|
||||
issues.append("exactly one access charge component is required")
|
||||
|
||||
for component in model.charge_components:
|
||||
if component.kind not in _ALLOWED_COMPONENT_KINDS:
|
||||
issues.append(f"unsupported charge component kind '{component.kind}'")
|
||||
if component.kind == "access":
|
||||
if component.amount is None:
|
||||
issues.append("access charge component must define amount")
|
||||
if component.cadence is None:
|
||||
issues.append("access charge component must define cadence")
|
||||
if component.kind == "usage" and component.meter is None:
|
||||
issues.append("usage charge component must define meter")
|
||||
|
||||
usage_components = [component for component in model.charge_components if component.kind == "usage"]
|
||||
if model.model_type == "hybrid_subscription_usage" and not usage_components:
|
||||
issues.append("hybrid_subscription_usage requires a usage charge component")
|
||||
|
||||
tunable_keys = [parameter.key for parameter in model.tunable_parameters]
|
||||
if len(tunable_keys) != len(set(tunable_keys)):
|
||||
issues.append("tunable parameter keys must be unique")
|
||||
|
||||
for parameter in model.tunable_parameters:
|
||||
if parameter.parameter_class not in _ALLOWED_PARAMETER_CLASSES:
|
||||
issues.append(f"unsupported parameter_class '{parameter.parameter_class}'")
|
||||
if (
|
||||
parameter.parameter_class == "customer_tunable"
|
||||
and not parameter.options
|
||||
and parameter.min_value is None
|
||||
and parameter.max_value is None
|
||||
):
|
||||
issues.append(
|
||||
f"customer_tunable parameter '{parameter.key}' must define bounds or options"
|
||||
)
|
||||
|
||||
commitment_ids = [commitment.id for commitment in model.commitments]
|
||||
if len(commitment_ids) != len(set(commitment_ids)):
|
||||
issues.append("commitment ids must be unique")
|
||||
|
||||
return issues
|
||||
696
adaptive_pricing_core/provider_publication.py
Normal file
696
adaptive_pricing_core/provider_publication.py
Normal file
@@ -0,0 +1,696 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal
|
||||
|
||||
from .pricing_models import PricingModel
|
||||
|
||||
PublicationArtifactKind = Literal["product", "meter", "price", "commitment", "configuration"]
|
||||
ArtifactMappingStatus = Literal["exact", "approximate", "unsupported"]
|
||||
PublicationOperationKind = Literal["create", "update", "noop", "retire", "rollback"]
|
||||
DriftSeverity = Literal["info", "warn", "error"]
|
||||
|
||||
|
||||
def _serialize_value(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {
|
||||
key: _serialize_value(getattr(value, key))
|
||||
for key in value.__dataclass_fields__
|
||||
}
|
||||
if isinstance(value, tuple):
|
||||
return [_serialize_value(item) for item in value]
|
||||
if isinstance(value, list):
|
||||
return [_serialize_value(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _serialize_value(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _payload_signature(payload: Any) -> str:
|
||||
return json.dumps(_serialize_value(payload), sort_keys=True)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CatalogProduct:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
currency: str
|
||||
lifecycle_phase: str
|
||||
active_pricing_model_id: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishableProduct:
|
||||
key: str
|
||||
product_id: str
|
||||
name: str
|
||||
description: str
|
||||
currency: str
|
||||
lifecycle_phase: str
|
||||
active: bool
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishableMeter:
|
||||
key: str
|
||||
meter_id: str
|
||||
name: str
|
||||
event_name: str
|
||||
unit: str
|
||||
aggregation: str = "sum"
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishablePrice:
|
||||
key: str
|
||||
price_id: str
|
||||
product_key: str
|
||||
component_id: str
|
||||
component_kind: str
|
||||
label: str
|
||||
currency: str
|
||||
billing_treatment: str
|
||||
cadence: str | None = None
|
||||
amount: Decimal | None = None
|
||||
unit_price: Decimal | None = None
|
||||
included_units: Decimal | None = None
|
||||
meter_key: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishableCommitment:
|
||||
key: str
|
||||
commitment_id: str
|
||||
kind: str
|
||||
value: str
|
||||
unit: str | None = None
|
||||
description: str = ""
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishableConfiguration:
|
||||
key: str
|
||||
configuration_id: str
|
||||
product_key: str
|
||||
model_id: str
|
||||
model_name: str
|
||||
segment: str | None
|
||||
price_keys: tuple[str, ...]
|
||||
commitment_keys: tuple[str, ...]
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicationBundle:
|
||||
bundle_id: str
|
||||
model_id: str
|
||||
model_name: str
|
||||
product: PublishableProduct
|
||||
meters: tuple[PublishableMeter, ...]
|
||||
prices: tuple[PublishablePrice, ...]
|
||||
commitments: tuple[PublishableCommitment, ...]
|
||||
configurations: tuple[PublishableConfiguration, ...]
|
||||
provider_hints: dict[str, Any] = field(default_factory=dict)
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderMappedArtifact:
|
||||
provider: str
|
||||
source_key: str
|
||||
source_kind: PublicationArtifactKind
|
||||
provider_id: str
|
||||
provider_object_type: str
|
||||
mapping_status: ArtifactMappingStatus
|
||||
payload: dict[str, Any]
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderPublicationPackage:
|
||||
provider: str
|
||||
bundle_id: str
|
||||
model_id: str
|
||||
model_name: str
|
||||
artifacts: tuple[ProviderMappedArtifact, ...]
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DriftFinding:
|
||||
provider_id: str
|
||||
provider_object_type: str
|
||||
severity: DriftSeverity
|
||||
summary: str
|
||||
expected: dict[str, Any] = field(default_factory=dict)
|
||||
actual: dict[str, Any] = field(default_factory=dict)
|
||||
suggested_action: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicationOperation:
|
||||
kind: PublicationOperationKind
|
||||
provider_id: str
|
||||
provider_object_type: str
|
||||
source_key: str | None
|
||||
source_kind: PublicationArtifactKind | None
|
||||
mapping_status: ArtifactMappingStatus | None
|
||||
summary: str
|
||||
desired_payload: dict[str, Any] = field(default_factory=dict)
|
||||
current_payload: dict[str, Any] = field(default_factory=dict)
|
||||
desired_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
current_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
desired_notes: tuple[str, ...] = ()
|
||||
current_notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublishedProviderArtifact:
|
||||
provider: str
|
||||
source_key: str
|
||||
source_kind: PublicationArtifactKind
|
||||
provider_id: str
|
||||
provider_object_type: str
|
||||
mapping_status: ArtifactMappingStatus
|
||||
payload: dict[str, Any]
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
notes: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicationRevision:
|
||||
revision_id: str
|
||||
model_id: str
|
||||
model_name: str
|
||||
summary: str
|
||||
operations: tuple[PublicationOperation, ...]
|
||||
snapshot: tuple[PublishedProviderArtifact, ...]
|
||||
replaced_revision_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderPublicationState:
|
||||
provider: str
|
||||
active_revision_id: str | None = None
|
||||
active_model_id: str | None = None
|
||||
artifacts: tuple[PublishedProviderArtifact, ...] = ()
|
||||
revisions: tuple[PublicationRevision, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicationPlan:
|
||||
provider: str
|
||||
bundle_id: str
|
||||
model_id: str
|
||||
model_name: str
|
||||
operations: tuple[PublicationOperation, ...]
|
||||
drift: tuple[DriftFinding, ...]
|
||||
unsupported_artifacts: tuple[ProviderMappedArtifact, ...]
|
||||
summary: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PublicationApplyResult:
|
||||
plan: PublicationPlan
|
||||
revision: PublicationRevision
|
||||
state: ProviderPublicationState
|
||||
summary: str
|
||||
|
||||
|
||||
def build_publication_bundle(
|
||||
product: CatalogProduct,
|
||||
model: PricingModel,
|
||||
*,
|
||||
configuration_id: str | None = None,
|
||||
segment: str | None = None,
|
||||
) -> PublicationBundle:
|
||||
product_key = f"product:{product.id}"
|
||||
publishable_product = PublishableProduct(
|
||||
key=product_key,
|
||||
product_id=product.id,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
currency=product.currency,
|
||||
lifecycle_phase=product.lifecycle_phase,
|
||||
active=model.status != "retired",
|
||||
metadata={
|
||||
**product.metadata,
|
||||
"adaptive_pricing_model_id": model.id,
|
||||
"adaptive_pricing_model_status": model.status,
|
||||
},
|
||||
)
|
||||
|
||||
meters: list[PublishableMeter] = []
|
||||
prices: list[PublishablePrice] = []
|
||||
for component in model.charge_components:
|
||||
meter_key: str | None = None
|
||||
if component.kind == "usage" and component.meter:
|
||||
meter_key = f"meter:{model.id}:{component.id}"
|
||||
meters.append(
|
||||
PublishableMeter(
|
||||
key=meter_key,
|
||||
meter_id=component.meter,
|
||||
name=component.label or component.meter,
|
||||
event_name=component.meter,
|
||||
unit=component.unit or "usage_unit",
|
||||
metadata={
|
||||
"adaptive_pricing_model_id": model.id,
|
||||
"source_component_id": component.id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
prices.append(
|
||||
PublishablePrice(
|
||||
key=f"price:{model.id}:{component.id}",
|
||||
price_id=f"{model.id}:{component.id}",
|
||||
product_key=product_key,
|
||||
component_id=component.id,
|
||||
component_kind=component.kind,
|
||||
label=component.label or component.id,
|
||||
currency=model.currency,
|
||||
billing_treatment=component.billing_treatment or "recurring",
|
||||
cadence=component.cadence or (
|
||||
model.access_fee_cadence if component.kind in {"access", "support", "discount", "risk_adjustment"} else None
|
||||
),
|
||||
amount=component.amount,
|
||||
unit_price=component.unit_price,
|
||||
included_units=component.included_units,
|
||||
meter_key=meter_key,
|
||||
metadata={
|
||||
**component.metadata,
|
||||
"adaptive_pricing_model_id": model.id,
|
||||
"source_component_id": component.id,
|
||||
"source_component_kind": component.kind,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
commitments = tuple(
|
||||
PublishableCommitment(
|
||||
key=f"commitment:{model.id}:{commitment.id}",
|
||||
commitment_id=commitment.id,
|
||||
kind=commitment.kind,
|
||||
value=commitment.value,
|
||||
unit=commitment.unit,
|
||||
description=commitment.description,
|
||||
metadata={"adaptive_pricing_model_id": model.id},
|
||||
)
|
||||
for commitment in model.commitments
|
||||
)
|
||||
|
||||
configuration = PublishableConfiguration(
|
||||
key=f"configuration:{configuration_id or model.id}",
|
||||
configuration_id=configuration_id or model.id,
|
||||
product_key=product_key,
|
||||
model_id=model.id,
|
||||
model_name=model.name,
|
||||
segment=segment,
|
||||
price_keys=tuple(price.key for price in prices),
|
||||
commitment_keys=tuple(commitment.key for commitment in commitments),
|
||||
metadata={
|
||||
"adaptive_pricing_model_id": model.id,
|
||||
"lifecycle_phase": model.lifecycle_phase,
|
||||
},
|
||||
)
|
||||
|
||||
notes = (
|
||||
"Publication bundles preserve the internal pricing model as the source of truth.",
|
||||
"Provider mappings may mark artifacts exact, approximate, or unsupported without mutating the bundle.",
|
||||
)
|
||||
|
||||
return PublicationBundle(
|
||||
bundle_id=f"bundle:{model.id}",
|
||||
model_id=model.id,
|
||||
model_name=model.name,
|
||||
product=publishable_product,
|
||||
meters=tuple(meters),
|
||||
prices=tuple(prices),
|
||||
commitments=commitments,
|
||||
configurations=(configuration,),
|
||||
provider_hints=model.provider_hints,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
|
||||
def _published_artifact(mapped: ProviderMappedArtifact) -> PublishedProviderArtifact:
|
||||
return PublishedProviderArtifact(
|
||||
provider=mapped.provider,
|
||||
source_key=mapped.source_key,
|
||||
source_kind=mapped.source_kind,
|
||||
provider_id=mapped.provider_id,
|
||||
provider_object_type=mapped.provider_object_type,
|
||||
mapping_status=mapped.mapping_status,
|
||||
payload=mapped.payload,
|
||||
metadata=mapped.metadata,
|
||||
notes=mapped.notes,
|
||||
)
|
||||
|
||||
|
||||
def _artifact_changed(
|
||||
desired: ProviderMappedArtifact,
|
||||
current: PublishedProviderArtifact,
|
||||
) -> bool:
|
||||
return any(
|
||||
[
|
||||
desired.mapping_status != current.mapping_status,
|
||||
desired.provider_object_type != current.provider_object_type,
|
||||
_payload_signature(desired.payload) != _payload_signature(current.payload),
|
||||
_payload_signature(desired.metadata) != _payload_signature(current.metadata),
|
||||
desired.notes != current.notes,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _diff_summary(
|
||||
desired: ProviderMappedArtifact,
|
||||
current: PublishedProviderArtifact,
|
||||
) -> DriftFinding:
|
||||
return DriftFinding(
|
||||
provider_id=desired.provider_id,
|
||||
provider_object_type=desired.provider_object_type,
|
||||
severity="warn",
|
||||
summary="Provider shadow state differs from desired pricing definition.",
|
||||
expected={
|
||||
"payload": desired.payload,
|
||||
"metadata": desired.metadata,
|
||||
"mapping_status": desired.mapping_status,
|
||||
"notes": list(desired.notes),
|
||||
},
|
||||
actual={
|
||||
"payload": current.payload,
|
||||
"metadata": current.metadata,
|
||||
"mapping_status": current.mapping_status,
|
||||
"notes": list(current.notes),
|
||||
},
|
||||
suggested_action="Publish the desired definition again or reconcile the provider-side drift.",
|
||||
)
|
||||
|
||||
|
||||
def plan_publication(
|
||||
package: ProviderPublicationPackage,
|
||||
current_state: ProviderPublicationState | None = None,
|
||||
) -> PublicationPlan:
|
||||
current_state = current_state or ProviderPublicationState(provider=package.provider)
|
||||
current_index = {artifact.provider_id: artifact for artifact in current_state.artifacts}
|
||||
desired_publishable = tuple(
|
||||
artifact for artifact in package.artifacts if artifact.mapping_status != "unsupported"
|
||||
)
|
||||
unsupported = tuple(
|
||||
artifact for artifact in package.artifacts if artifact.mapping_status == "unsupported"
|
||||
)
|
||||
|
||||
operations: list[PublicationOperation] = []
|
||||
drift: list[DriftFinding] = []
|
||||
desired_ids = {artifact.provider_id for artifact in desired_publishable}
|
||||
|
||||
for artifact in desired_publishable:
|
||||
current = current_index.get(artifact.provider_id)
|
||||
if current is None:
|
||||
operations.append(
|
||||
PublicationOperation(
|
||||
kind="create",
|
||||
provider_id=artifact.provider_id,
|
||||
provider_object_type=artifact.provider_object_type,
|
||||
source_key=artifact.source_key,
|
||||
source_kind=artifact.source_kind,
|
||||
mapping_status=artifact.mapping_status,
|
||||
summary="Create provider artifact from canonical pricing definition.",
|
||||
desired_payload=artifact.payload,
|
||||
desired_metadata=artifact.metadata,
|
||||
desired_notes=artifact.notes,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
if _artifact_changed(artifact, current):
|
||||
operations.append(
|
||||
PublicationOperation(
|
||||
kind="update",
|
||||
provider_id=artifact.provider_id,
|
||||
provider_object_type=artifact.provider_object_type,
|
||||
source_key=artifact.source_key,
|
||||
source_kind=artifact.source_kind,
|
||||
mapping_status=artifact.mapping_status,
|
||||
summary="Update provider artifact to match canonical pricing definition.",
|
||||
desired_payload=artifact.payload,
|
||||
current_payload=current.payload,
|
||||
desired_metadata=artifact.metadata,
|
||||
current_metadata=current.metadata,
|
||||
desired_notes=artifact.notes,
|
||||
current_notes=current.notes,
|
||||
)
|
||||
)
|
||||
drift.append(_diff_summary(artifact, current))
|
||||
continue
|
||||
|
||||
operations.append(
|
||||
PublicationOperation(
|
||||
kind="noop",
|
||||
provider_id=artifact.provider_id,
|
||||
provider_object_type=artifact.provider_object_type,
|
||||
source_key=artifact.source_key,
|
||||
source_kind=artifact.source_kind,
|
||||
mapping_status=artifact.mapping_status,
|
||||
summary="Provider artifact already matches canonical pricing definition.",
|
||||
desired_payload=artifact.payload,
|
||||
current_payload=current.payload,
|
||||
desired_metadata=artifact.metadata,
|
||||
current_metadata=current.metadata,
|
||||
desired_notes=artifact.notes,
|
||||
current_notes=current.notes,
|
||||
)
|
||||
)
|
||||
|
||||
for artifact in current_state.artifacts:
|
||||
if artifact.provider_id in desired_ids:
|
||||
continue
|
||||
operations.append(
|
||||
PublicationOperation(
|
||||
kind="retire",
|
||||
provider_id=artifact.provider_id,
|
||||
provider_object_type=artifact.provider_object_type,
|
||||
source_key=artifact.source_key,
|
||||
source_kind=artifact.source_kind,
|
||||
mapping_status=artifact.mapping_status,
|
||||
summary="Retire managed provider artifact no longer present in the desired pricing definition.",
|
||||
current_payload=artifact.payload,
|
||||
current_metadata=artifact.metadata,
|
||||
current_notes=artifact.notes,
|
||||
)
|
||||
)
|
||||
drift.append(
|
||||
DriftFinding(
|
||||
provider_id=artifact.provider_id,
|
||||
provider_object_type=artifact.provider_object_type,
|
||||
severity="warn",
|
||||
summary="Managed provider artifact exists in shadow state but not in the desired pricing definition.",
|
||||
actual={
|
||||
"payload": artifact.payload,
|
||||
"metadata": artifact.metadata,
|
||||
"mapping_status": artifact.mapping_status,
|
||||
"notes": list(artifact.notes),
|
||||
},
|
||||
suggested_action="Retire the artifact or republish the desired model.",
|
||||
)
|
||||
)
|
||||
|
||||
summary = (
|
||||
f"{package.provider}: {sum(op.kind == 'create' for op in operations)} create, "
|
||||
f"{sum(op.kind == 'update' for op in operations)} update, "
|
||||
f"{sum(op.kind == 'retire' for op in operations)} retire, "
|
||||
f"{sum(op.kind == 'noop' for op in operations)} noop, "
|
||||
f"{len(unsupported)} unsupported."
|
||||
)
|
||||
|
||||
return PublicationPlan(
|
||||
provider=package.provider,
|
||||
bundle_id=package.bundle_id,
|
||||
model_id=package.model_id,
|
||||
model_name=package.model_name,
|
||||
operations=tuple(operations),
|
||||
drift=tuple(drift),
|
||||
unsupported_artifacts=unsupported,
|
||||
summary=summary,
|
||||
)
|
||||
|
||||
|
||||
def _next_revision_id(state: ProviderPublicationState) -> str:
|
||||
return f"{state.provider}-rev-{len(state.revisions) + 1:04d}"
|
||||
|
||||
|
||||
def _next_active_model_id(artifacts: list[PublishedProviderArtifact]) -> str | None:
|
||||
product = next((artifact for artifact in artifacts if artifact.source_kind == "product"), None)
|
||||
if product is None:
|
||||
return None
|
||||
if product.metadata.get("model_id"):
|
||||
return str(product.metadata["model_id"])
|
||||
payload_metadata = product.payload.get("metadata", {})
|
||||
if payload_metadata.get("adaptive_pricing_model_id"):
|
||||
return str(payload_metadata["adaptive_pricing_model_id"])
|
||||
return None
|
||||
|
||||
|
||||
def apply_publication(
|
||||
package: ProviderPublicationPackage,
|
||||
current_state: ProviderPublicationState | None = None,
|
||||
) -> PublicationApplyResult:
|
||||
current_state = current_state or ProviderPublicationState(provider=package.provider)
|
||||
plan = plan_publication(package, current_state)
|
||||
desired_index = {
|
||||
artifact.provider_id: artifact
|
||||
for artifact in package.artifacts
|
||||
if artifact.mapping_status != "unsupported"
|
||||
}
|
||||
artifact_index = {artifact.provider_id: artifact for artifact in current_state.artifacts}
|
||||
|
||||
for operation in plan.operations:
|
||||
if operation.kind in {"create", "update", "noop"}:
|
||||
artifact_index[operation.provider_id] = _published_artifact(
|
||||
desired_index[operation.provider_id]
|
||||
)
|
||||
elif operation.kind == "retire":
|
||||
artifact_index.pop(operation.provider_id, None)
|
||||
|
||||
snapshot = tuple(sorted(artifact_index.values(), key=lambda artifact: artifact.provider_id))
|
||||
revision = PublicationRevision(
|
||||
revision_id=_next_revision_id(current_state),
|
||||
model_id=package.model_id,
|
||||
model_name=package.model_name,
|
||||
summary=plan.summary,
|
||||
operations=plan.operations,
|
||||
snapshot=snapshot,
|
||||
replaced_revision_id=current_state.active_revision_id,
|
||||
)
|
||||
state = ProviderPublicationState(
|
||||
provider=package.provider,
|
||||
active_revision_id=revision.revision_id,
|
||||
active_model_id=_next_active_model_id(list(snapshot)),
|
||||
artifacts=snapshot,
|
||||
revisions=current_state.revisions + (revision,),
|
||||
)
|
||||
return PublicationApplyResult(
|
||||
plan=plan,
|
||||
revision=revision,
|
||||
state=state,
|
||||
summary=plan.summary,
|
||||
)
|
||||
|
||||
|
||||
def rollback_publication(
|
||||
state: ProviderPublicationState,
|
||||
revision_id: str,
|
||||
) -> PublicationApplyResult:
|
||||
revision = next((item for item in state.revisions if item.revision_id == revision_id), None)
|
||||
if revision is None:
|
||||
raise ValueError(f"unknown revision_id '{revision_id}'")
|
||||
|
||||
rollback_operation = PublicationOperation(
|
||||
kind="rollback",
|
||||
provider_id=revision.revision_id,
|
||||
provider_object_type="revision",
|
||||
source_key=None,
|
||||
source_kind=None,
|
||||
mapping_status=None,
|
||||
summary=f"Rollback provider shadow state to {revision.revision_id}.",
|
||||
)
|
||||
new_revision = PublicationRevision(
|
||||
revision_id=_next_revision_id(state),
|
||||
model_id=revision.model_id,
|
||||
model_name=revision.model_name,
|
||||
summary=f"Rolled back provider shadow state to {revision.revision_id}.",
|
||||
operations=(rollback_operation,),
|
||||
snapshot=revision.snapshot,
|
||||
replaced_revision_id=state.active_revision_id,
|
||||
)
|
||||
new_state = ProviderPublicationState(
|
||||
provider=state.provider,
|
||||
active_revision_id=new_revision.revision_id,
|
||||
active_model_id=revision.model_id,
|
||||
artifacts=revision.snapshot,
|
||||
revisions=state.revisions + (new_revision,),
|
||||
)
|
||||
return PublicationApplyResult(
|
||||
plan=PublicationPlan(
|
||||
provider=state.provider,
|
||||
bundle_id=f"rollback:{revision.revision_id}",
|
||||
model_id=revision.model_id,
|
||||
model_name=revision.model_name,
|
||||
operations=(rollback_operation,),
|
||||
drift=(),
|
||||
unsupported_artifacts=(),
|
||||
summary=new_revision.summary,
|
||||
),
|
||||
revision=new_revision,
|
||||
state=new_state,
|
||||
summary=new_revision.summary,
|
||||
)
|
||||
|
||||
|
||||
def provider_state_to_dict(state: ProviderPublicationState) -> dict[str, Any]:
|
||||
return _serialize_value(state)
|
||||
|
||||
|
||||
def _operation_from_dict(raw: dict[str, Any]) -> PublicationOperation:
|
||||
return PublicationOperation(
|
||||
kind=raw["kind"],
|
||||
provider_id=raw["provider_id"],
|
||||
provider_object_type=raw["provider_object_type"],
|
||||
source_key=raw.get("source_key"),
|
||||
source_kind=raw.get("source_kind"),
|
||||
mapping_status=raw.get("mapping_status"),
|
||||
summary=raw["summary"],
|
||||
desired_payload=dict(raw.get("desired_payload", {})),
|
||||
current_payload=dict(raw.get("current_payload", {})),
|
||||
desired_metadata=dict(raw.get("desired_metadata", {})),
|
||||
current_metadata=dict(raw.get("current_metadata", {})),
|
||||
desired_notes=tuple(raw.get("desired_notes", [])),
|
||||
current_notes=tuple(raw.get("current_notes", [])),
|
||||
)
|
||||
|
||||
|
||||
def _artifact_from_dict(raw: dict[str, Any]) -> PublishedProviderArtifact:
|
||||
return PublishedProviderArtifact(
|
||||
provider=raw["provider"],
|
||||
source_key=raw["source_key"],
|
||||
source_kind=raw["source_kind"],
|
||||
provider_id=raw["provider_id"],
|
||||
provider_object_type=raw["provider_object_type"],
|
||||
mapping_status=raw["mapping_status"],
|
||||
payload=dict(raw.get("payload", {})),
|
||||
metadata=dict(raw.get("metadata", {})),
|
||||
notes=tuple(raw.get("notes", [])),
|
||||
)
|
||||
|
||||
|
||||
def _revision_from_dict(raw: dict[str, Any]) -> PublicationRevision:
|
||||
return PublicationRevision(
|
||||
revision_id=raw["revision_id"],
|
||||
model_id=raw["model_id"],
|
||||
model_name=raw["model_name"],
|
||||
summary=raw["summary"],
|
||||
operations=tuple(_operation_from_dict(item) for item in raw.get("operations", [])),
|
||||
snapshot=tuple(_artifact_from_dict(item) for item in raw.get("snapshot", [])),
|
||||
replaced_revision_id=raw.get("replaced_revision_id"),
|
||||
)
|
||||
|
||||
|
||||
def provider_state_from_dict(raw: dict[str, Any]) -> ProviderPublicationState:
|
||||
return ProviderPublicationState(
|
||||
provider=raw["provider"],
|
||||
active_revision_id=raw.get("active_revision_id"),
|
||||
active_model_id=raw.get("active_model_id"),
|
||||
artifacts=tuple(_artifact_from_dict(item) for item in raw.get("artifacts", [])),
|
||||
revisions=tuple(_revision_from_dict(item) for item in raw.get("revisions", [])),
|
||||
)
|
||||
330
adaptive_pricing_core/stripe_provider.py
Normal file
330
adaptive_pricing_core/stripe_provider.py
Normal file
@@ -0,0 +1,330 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .provider_publication import (
|
||||
ProviderMappedArtifact,
|
||||
ProviderPublicationPackage,
|
||||
PublicationBundle,
|
||||
PublishableCommitment,
|
||||
PublishableConfiguration,
|
||||
PublishableMeter,
|
||||
PublishablePrice,
|
||||
)
|
||||
|
||||
|
||||
def _lookup_key(*parts: str) -> str:
|
||||
return "--".join(
|
||||
part.replace(":", "-").replace("_", "-")
|
||||
for part in parts
|
||||
if part
|
||||
)
|
||||
|
||||
|
||||
def _stripe_interval(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value.lower()
|
||||
if normalized in {"monthly", "month"}:
|
||||
return "month"
|
||||
if normalized in {"yearly", "annual", "year"}:
|
||||
return "year"
|
||||
return normalized
|
||||
|
||||
|
||||
def _stripe_hints(bundle: PublicationBundle) -> dict[str, Any]:
|
||||
return dict(bundle.provider_hints.get("stripe", {}))
|
||||
|
||||
|
||||
def _product_provider_id(bundle: PublicationBundle) -> str:
|
||||
hints = _stripe_hints(bundle)
|
||||
return hints.get("product_lookup_key") or _lookup_key("product", bundle.product.product_id)
|
||||
|
||||
|
||||
def _meter_provider_id(bundle: PublicationBundle, meter: PublishableMeter) -> str:
|
||||
hints = _stripe_hints(bundle)
|
||||
if hints.get("meter_name") and len(bundle.meters) == 1:
|
||||
return str(hints["meter_name"])
|
||||
return _lookup_key("meter", bundle.model_id, meter.meter_id)
|
||||
|
||||
|
||||
def _product_artifact(bundle: PublicationBundle) -> ProviderMappedArtifact:
|
||||
hints = _stripe_hints(bundle)
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=bundle.product.key,
|
||||
source_kind="product",
|
||||
provider_id=_product_provider_id(bundle),
|
||||
provider_object_type="product",
|
||||
mapping_status="exact",
|
||||
payload={
|
||||
"lookup_key": _product_provider_id(bundle),
|
||||
"name": bundle.product.name,
|
||||
"description": bundle.product.description,
|
||||
"active": bundle.product.active,
|
||||
"metadata": {
|
||||
**bundle.product.metadata,
|
||||
"collection_method": hints.get("collection_method", "charge_automatically"),
|
||||
"source_of_truth": "adaptive-pricing",
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=("Stripe product mapping is direct for catalog identity and metadata.",),
|
||||
)
|
||||
|
||||
|
||||
def _meter_artifact(bundle: PublicationBundle, meter: PublishableMeter) -> ProviderMappedArtifact:
|
||||
provider_id = _meter_provider_id(bundle, meter)
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=meter.key,
|
||||
source_kind="meter",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="billing_meter",
|
||||
mapping_status="exact",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"display_name": meter.name,
|
||||
"event_name": meter.event_name,
|
||||
"default_aggregation": {"formula": meter.aggregation},
|
||||
"unit_label": meter.unit,
|
||||
"metadata": {
|
||||
**meter.metadata,
|
||||
"source_of_truth": "adaptive-pricing",
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=("Stripe meter mapping is direct for metered usage identifiers.",),
|
||||
)
|
||||
|
||||
|
||||
def _fixed_price_payload(bundle: PublicationBundle, price: PublishablePrice) -> dict[str, Any]:
|
||||
payload = {
|
||||
"lookup_key": _lookup_key("price", bundle.model_id, price.component_id),
|
||||
"product": _product_provider_id(bundle),
|
||||
"currency": price.currency.lower(),
|
||||
"nickname": price.label,
|
||||
"unit_amount_decimal": str(price.amount or "0"),
|
||||
"metadata": {
|
||||
**price.metadata,
|
||||
"source_of_truth": "adaptive-pricing",
|
||||
},
|
||||
}
|
||||
if price.billing_treatment != "one_time" and price.cadence:
|
||||
payload["recurring"] = {"interval": _stripe_interval(price.cadence)}
|
||||
return payload
|
||||
|
||||
|
||||
def _discount_artifact(bundle: PublicationBundle, price: PublishablePrice) -> ProviderMappedArtifact:
|
||||
provider_id = _lookup_key("coupon", bundle.model_id, price.component_id)
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=price.key,
|
||||
source_kind="price",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="coupon",
|
||||
mapping_status="approximate",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"amount_off_decimal": str(abs(price.amount or 0)),
|
||||
"currency": price.currency.lower(),
|
||||
"name": price.label,
|
||||
"metadata": {
|
||||
**price.metadata,
|
||||
"source_of_truth": "adaptive-pricing",
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=(
|
||||
"Stripe coupons approximate discount components because attachment to subscriptions and eligibility rules lives outside the price object.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _usage_price_artifact(
|
||||
bundle: PublicationBundle,
|
||||
price: PublishablePrice,
|
||||
) -> ProviderMappedArtifact:
|
||||
provider_id = _lookup_key("price", bundle.model_id, price.component_id)
|
||||
if price.meter_key is None or price.unit_price is None:
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=price.key,
|
||||
source_kind="price",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="price",
|
||||
mapping_status="unsupported",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"reason": "usage component lacks a billable per-unit price or mapped meter",
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=(
|
||||
"Stripe publication cannot create a metered price without both a meter and a per-unit charge.",
|
||||
),
|
||||
)
|
||||
|
||||
meter_provider_id = _lookup_key("meter", bundle.model_id, price.component_id)
|
||||
if len(bundle.meters) == 1:
|
||||
meter_provider_id = _meter_provider_id(bundle, bundle.meters[0])
|
||||
status = "approximate" if price.included_units not in (None, 0) else "exact"
|
||||
notes = [
|
||||
"Stripe metered price mapping is direct for per-unit overage billing.",
|
||||
]
|
||||
if status == "approximate":
|
||||
notes.append(
|
||||
"Included usage allowance requires supplemental credits, invoice adjustments, or custom entitlement logic outside the Stripe price object."
|
||||
)
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=price.key,
|
||||
source_kind="price",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="price",
|
||||
mapping_status=status,
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"product": _product_provider_id(bundle),
|
||||
"currency": price.currency.lower(),
|
||||
"nickname": price.label,
|
||||
"billing_scheme": "per_unit",
|
||||
"unit_amount_decimal": str(price.unit_price),
|
||||
"recurring": {
|
||||
"interval": _stripe_interval(price.cadence) or "month",
|
||||
"usage_type": "metered",
|
||||
},
|
||||
"meter": meter_provider_id,
|
||||
"metadata": {
|
||||
**price.metadata,
|
||||
"included_units": str(price.included_units) if price.included_units is not None else None,
|
||||
"source_of_truth": "adaptive-pricing",
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=tuple(notes),
|
||||
)
|
||||
|
||||
|
||||
def _price_artifact(bundle: PublicationBundle, price: PublishablePrice) -> ProviderMappedArtifact:
|
||||
provider_id = _lookup_key("price", bundle.model_id, price.component_id)
|
||||
if price.component_kind == "usage":
|
||||
return _usage_price_artifact(bundle, price)
|
||||
if price.component_kind == "discount":
|
||||
return _discount_artifact(bundle, price)
|
||||
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=price.key,
|
||||
source_kind="price",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="price",
|
||||
mapping_status="exact",
|
||||
payload=_fixed_price_payload(bundle, price),
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=("Stripe price mapping is direct for fixed recurring or one-time charges.",),
|
||||
)
|
||||
|
||||
|
||||
def _commitment_artifact(
|
||||
bundle: PublicationBundle,
|
||||
commitment: PublishableCommitment,
|
||||
) -> ProviderMappedArtifact:
|
||||
provider_id = _lookup_key("commitment", bundle.model_id, commitment.commitment_id)
|
||||
if commitment.kind == "contract_duration":
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=commitment.key,
|
||||
source_kind="commitment",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="metadata_binding",
|
||||
mapping_status="approximate",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"metadata": {
|
||||
**commitment.metadata,
|
||||
"contract_duration_value": commitment.value,
|
||||
"contract_duration_unit": commitment.unit,
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=(
|
||||
"Stripe can store contract duration metadata, but enforcement still relies on subscription schedule policy or external contract workflow.",
|
||||
),
|
||||
)
|
||||
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=commitment.key,
|
||||
source_kind="commitment",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="metadata_binding",
|
||||
mapping_status="unsupported",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"kind": commitment.kind,
|
||||
"value": commitment.value,
|
||||
"unit": commitment.unit,
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=(
|
||||
"Stripe metadata alone cannot enforce this commitment semantics; it remains an internal pricing and contract artifact.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _configuration_artifact(
|
||||
bundle: PublicationBundle,
|
||||
configuration: PublishableConfiguration,
|
||||
) -> ProviderMappedArtifact:
|
||||
provider_id = _lookup_key("configuration", configuration.configuration_id)
|
||||
return ProviderMappedArtifact(
|
||||
provider="stripe",
|
||||
source_key=configuration.key,
|
||||
source_kind="configuration",
|
||||
provider_id=provider_id,
|
||||
provider_object_type="metadata_binding",
|
||||
mapping_status="approximate",
|
||||
payload={
|
||||
"lookup_key": provider_id,
|
||||
"metadata": {
|
||||
**configuration.metadata,
|
||||
"configuration_id": configuration.configuration_id,
|
||||
"segment": configuration.segment,
|
||||
"price_keys": list(configuration.price_keys),
|
||||
"commitment_keys": list(configuration.commitment_keys),
|
||||
},
|
||||
},
|
||||
metadata={"model_id": bundle.model_id},
|
||||
notes=(
|
||||
"Customer or default pricing configurations can be recorded in Stripe metadata, but they are not first-class Stripe catalog objects.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def map_bundle_to_stripe(bundle: PublicationBundle) -> ProviderPublicationPackage:
|
||||
artifacts: list[ProviderMappedArtifact] = [_product_artifact(bundle)]
|
||||
artifacts.extend(_meter_artifact(bundle, meter) for meter in bundle.meters)
|
||||
artifacts.extend(_price_artifact(bundle, price) for price in bundle.prices)
|
||||
artifacts.extend(_commitment_artifact(bundle, commitment) for commitment in bundle.commitments)
|
||||
artifacts.extend(
|
||||
_configuration_artifact(bundle, configuration)
|
||||
for configuration in bundle.configurations
|
||||
)
|
||||
|
||||
notes = [
|
||||
"Stripe remains an execution backend; adaptive-pricing stays the source of truth.",
|
||||
"Exact mappings create publishable Stripe shadow artifacts; approximate mappings require supplemental operational logic.",
|
||||
]
|
||||
if _stripe_hints(bundle).get("metered_usage_strategy") == "future_adapter":
|
||||
notes.append(
|
||||
"The pricing model declares a future metered-usage strategy hint, so Stripe publication keeps the usage allowance semantics descriptive rather than executable."
|
||||
)
|
||||
|
||||
return ProviderPublicationPackage(
|
||||
provider="stripe",
|
||||
bundle_id=bundle.bundle_id,
|
||||
model_id=bundle.model_id,
|
||||
model_name=bundle.model_name,
|
||||
artifacts=tuple(artifacts),
|
||||
notes=tuple(notes),
|
||||
)
|
||||
78
docs/BoundaryValidation.md
Normal file
78
docs/BoundaryValidation.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Boundary Validation
|
||||
|
||||
Status: implementation-facing MVP for `ADAPTIVE-WP-0004`.
|
||||
|
||||
## Purpose
|
||||
|
||||
This document describes the first explicit boundary engine now available in
|
||||
`adaptive_pricing_core.boundary_engine`.
|
||||
|
||||
The engine turns pricing-policy intent into inspectable validation outcomes
|
||||
instead of leaving viability checks implicit in dashboard review.
|
||||
|
||||
## Inputs
|
||||
|
||||
The validator accepts:
|
||||
|
||||
- a canonical `PricingModel`
|
||||
- a `PricingConfiguration` describing expected usage, fee assumptions, cost
|
||||
allocation, optional price overrides, and commitment terms
|
||||
- a `BoundaryPolicy` defining hard and soft limits
|
||||
|
||||
## Constraint Types
|
||||
|
||||
Current MVP constraints cover:
|
||||
|
||||
- segment eligibility
|
||||
- expected usage variance limit
|
||||
- payment fee ceiling
|
||||
- cost-floor coverage
|
||||
- minimum margin
|
||||
- target-margin approval threshold
|
||||
- discount exposure ceiling
|
||||
- discount approval threshold
|
||||
- commitment-backed concession enforcement
|
||||
|
||||
Hard constraints reject a configuration. Soft constraints mark it as valid only
|
||||
with approval.
|
||||
|
||||
## Commitment Logic
|
||||
|
||||
The engine treats a concession as any configuration that weakens seller
|
||||
economics relative to the model baseline under the same scenario assumptions.
|
||||
|
||||
A concession is considered meaningfully backed only when at least one of these
|
||||
protections is present:
|
||||
|
||||
- minimum monthly turnover at or above modeled monthly revenue
|
||||
- prepayment covering at least one modeled month
|
||||
- guaranteed platform fee at or above modeled monthly revenue
|
||||
- customer-funded onboarding that neutralizes onboarding cost
|
||||
- materially longer contract duration
|
||||
- reduced cancellation flexibility
|
||||
|
||||
## Outputs
|
||||
|
||||
Validation returns a `ValidationResult` with:
|
||||
|
||||
- `decision`: `accepted`, `requires_approval`, or `rejected`
|
||||
- `valid` and `requires_approval`
|
||||
- a human-readable summary
|
||||
- machine-readable configuration snapshot
|
||||
- machine-readable economics metrics
|
||||
- per-constraint results with reasons, thresholds, and suggested actions
|
||||
|
||||
## Coulomb Adapter
|
||||
|
||||
The Coulomb observatory exposes this engine through
|
||||
`observatory.boundary.build_boundary_validation()`.
|
||||
|
||||
That adapter currently uses:
|
||||
|
||||
- observed per-period payment fee rate
|
||||
- observed AI usage cost
|
||||
- observed per-member infrastructure cost allocation
|
||||
- conservative default policy thresholds
|
||||
|
||||
This is intentionally an MVP policy surface. Later milestones can replace these
|
||||
defaults with seller-managed governance data and richer LTV-aware constraints.
|
||||
99
docs/ComparableCustomerLTV.md
Normal file
99
docs/ComparableCustomerLTV.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Comparable Customer LTV
|
||||
|
||||
Status: implementation-facing MVP for `ADAPTIVE-WP-0005`.
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the first operational form of
|
||||
`average_comparable_customer_lifetime_value`.
|
||||
|
||||
The goal is to compare pricing configurations using expected seller economics
|
||||
over time instead of only the current-period observatory snapshot.
|
||||
|
||||
## Core Definition
|
||||
|
||||
For the current MVP:
|
||||
|
||||
`average_comparable_customer_lifetime_value`
|
||||
|
||||
means discounted expected seller margin for a comparable customer profile over a
|
||||
finite horizon, minus acquisition and upfront seller investment costs.
|
||||
|
||||
Inputs include:
|
||||
|
||||
- validated monthly pricing economics from the boundary engine
|
||||
- comparable-customer usage expectations
|
||||
- churn and default risk assumptions
|
||||
- contract duration and commitment protections
|
||||
- seller acquisition and upfront investment costs
|
||||
- a seller-configurable discount rate and required-improvement factor
|
||||
|
||||
## Reference Model Selection
|
||||
|
||||
The comparison engine selects:
|
||||
|
||||
`most_favorable_predefined_model`
|
||||
|
||||
as the highest-LTV valid predefined model available to the comparable-customer
|
||||
profile. If no valid model exists, it falls back to the highest-LTV eligible
|
||||
predefined model so the comparison still produces an inspectable anchor.
|
||||
|
||||
Eligibility is currently supplied by the simulation profile rather than derived
|
||||
solely from model metadata.
|
||||
|
||||
## Required Improvement Semantics
|
||||
|
||||
For non-reference configurations:
|
||||
|
||||
```text
|
||||
average_comparable_customer_lifetime_value(candidate)
|
||||
>= average_comparable_customer_lifetime_value(reference)
|
||||
× required_improvement_factor
|
||||
```
|
||||
|
||||
When the reference LTV is positive, the threshold is multiplicative.
|
||||
|
||||
When the reference LTV is negative, the engine switches to additive improvement
|
||||
semantics so the candidate must become less negative by the requested
|
||||
percentage. This avoids the invalid outcome where multiplying a negative value
|
||||
would reward a worse configuration.
|
||||
|
||||
## Risk Model
|
||||
|
||||
Current risk handling is intentionally simple and explicit:
|
||||
|
||||
- monthly churn risk applies after committed months expire
|
||||
- monthly default risk applies throughout the horizon
|
||||
- prepayment and guaranteed-fee commitments reduce default exposure
|
||||
- reduced cancellation flexibility lowers modeled churn exposure
|
||||
|
||||
This is a policy approximation, not a retention model trained from history.
|
||||
|
||||
## Sensitivity Model
|
||||
|
||||
Each comparison runs the base case plus named sensitivity cases. The current
|
||||
Coulomb adapter includes:
|
||||
|
||||
- usage downside
|
||||
- usage upside
|
||||
- risk spike
|
||||
|
||||
Sensitivity output reports:
|
||||
|
||||
- scenario LTV
|
||||
- delta versus base LTV
|
||||
- whether the configuration remains accepted, approval-only, or rejected
|
||||
|
||||
## Coulomb Calibration
|
||||
|
||||
The Coulomb observatory currently calibrates the generic engine with:
|
||||
|
||||
- observed payment-fee rate from `payment_records.json`
|
||||
- observed AI usage unit cost from `usage_records.json`
|
||||
- segment profiles from `ltv_scenarios.json`
|
||||
- profile-specific fixed-cost allocation overrides for comparable future
|
||||
customers
|
||||
|
||||
Those fixed-cost overrides are deliberate: the current single-member pilot cost
|
||||
structure is too distorted to act as a reusable comparable-customer baseline on
|
||||
its own.
|
||||
93
docs/CustomerTuningSolver.md
Normal file
93
docs/CustomerTuningSolver.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Customer-Tuning Solver Prototype
|
||||
|
||||
Status: MVP for `ADAPTIVE-WP-0006`.
|
||||
|
||||
## Purpose
|
||||
|
||||
This milestone adds the first executable customer-tuning flow described in
|
||||
`INTENT.md`.
|
||||
|
||||
The solver now accepts selected customer-tunable inputs, solves the remaining
|
||||
usage-price parameter, validates the tuned configuration against boundary
|
||||
constraints, and checks seller-side comparable-customer LTV against the best
|
||||
available predefined reference model.
|
||||
|
||||
## Generic Solver Contract
|
||||
|
||||
Core module: `adaptive_pricing_core/customer_tuning.py`
|
||||
|
||||
Inputs:
|
||||
|
||||
- a baseline `PricingConfiguration`
|
||||
- the comparable-customer profile
|
||||
- boundary policy
|
||||
- LTV policy
|
||||
- a `CustomerTuningRequest`
|
||||
- the set of predefined reference configurations available to that profile
|
||||
|
||||
Current request fields:
|
||||
|
||||
- `included_units`
|
||||
- `contract_duration_months`
|
||||
- `minimum_monthly_turnover`
|
||||
- `prepaid_amount`
|
||||
- `guaranteed_platform_fee`
|
||||
- `customer_funded_onboarding`
|
||||
- `reduced_cancellation_flexibility`
|
||||
- preference: `lower_usage_price` or `seller_ltv`
|
||||
- approval mode: `self_serve_only` or `allow_approval`
|
||||
|
||||
Current solved field:
|
||||
|
||||
- `usage_unit_price`
|
||||
|
||||
## Decision Logic
|
||||
|
||||
For each candidate usage price in the search range, the solver:
|
||||
|
||||
1. builds a tuned `PricingConfiguration`
|
||||
2. runs boundary validation
|
||||
3. estimates `average_comparable_customer_lifetime_value`
|
||||
4. compares the tuned result with the best predefined reference model for the
|
||||
profile
|
||||
|
||||
A tuned configuration is only accepted when:
|
||||
|
||||
- boundary validation is valid
|
||||
- no seller approval is required when the request is `self_serve_only`
|
||||
- tuned comparable-customer LTV meets the configured improvement threshold
|
||||
|
||||
The solver returns structured output including:
|
||||
|
||||
- accepted / rejected / requires approval decision
|
||||
- solved configuration
|
||||
- reference model and required LTV threshold
|
||||
- binding constraints
|
||||
- chosen trade-offs
|
||||
- explanation text
|
||||
|
||||
## Coulomb Pilot
|
||||
|
||||
Pilot module: `projects/coulomb-pricing/observatory/tuning.py`
|
||||
|
||||
Pilot request catalog:
|
||||
|
||||
- `projects/coulomb-pricing/data/tuning_requests.json`
|
||||
|
||||
The Coulomb pilot currently targets `membership-plus-overage` against the
|
||||
`small-team` comparable-customer profile.
|
||||
|
||||
Two pilot requests are shipped:
|
||||
|
||||
- a seller-safe lower-usage-price request that succeeds
|
||||
- a high-included-usage request that is rejected for self-serve
|
||||
|
||||
## Current Modeling Note
|
||||
|
||||
The observatory simulation path still scales default hybrid included usage by
|
||||
`members_per_customer`.
|
||||
|
||||
The tuning pilot interprets request-level `included_tokens` values as total
|
||||
package allowances, then maps them into canonical configuration fields before
|
||||
running the solver. This keeps the prototype aligned with the catalog’s tunable
|
||||
bounds while avoiding a broader simulation recalibration inside this milestone.
|
||||
85
docs/GovernanceWorkflows.md
Normal file
85
docs/GovernanceWorkflows.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Governance Workflows
|
||||
|
||||
Status: MVP for `ADAPTIVE-WP-0008`.
|
||||
|
||||
## Purpose
|
||||
|
||||
This milestone turns pricing outputs into governed workflows instead of
|
||||
standalone metrics.
|
||||
|
||||
The repository now exposes:
|
||||
|
||||
- a governance policy model
|
||||
- governed seller recommendations
|
||||
- a customer-facing safe-tuning contract surface
|
||||
- pricing health checks
|
||||
- provider-publication audit and revision surfaces
|
||||
|
||||
## Core And Adapter Layers
|
||||
|
||||
Generic core:
|
||||
|
||||
- `adaptive_pricing_core/governance.py`
|
||||
|
||||
Coulomb adapter:
|
||||
|
||||
- `projects/coulomb-pricing/observatory/governance.py`
|
||||
- `projects/coulomb-pricing/data/governance_policy.json`
|
||||
|
||||
## Governance Policy
|
||||
|
||||
The policy model covers:
|
||||
|
||||
- approval thresholds
|
||||
- customer-visible price-change rules
|
||||
- experiment capacity
|
||||
- candidate rollout limits
|
||||
- provider execution limits
|
||||
- customer communication ownership
|
||||
- grandfathering and notice expectations
|
||||
- customer-visible tuning enablement
|
||||
|
||||
For Coulomb, the current policy keeps customer-visible tuning disabled and
|
||||
requires approval for candidate rollouts and approximate Stripe mappings.
|
||||
|
||||
## Recommendation Workflow
|
||||
|
||||
Recommendations now include:
|
||||
|
||||
- recommendation type: research, simulation, model change, or execution
|
||||
- rationale
|
||||
- confidence
|
||||
- risks
|
||||
- supporting observations
|
||||
- governance decision
|
||||
- approval requirements
|
||||
|
||||
This satisfies the PRD requirement that recommendations be explainable and
|
||||
distinguish between evidence gathering, simulation, model design, and execution.
|
||||
|
||||
## Safe-Tuning Contract
|
||||
|
||||
The governance surface exposes a structured contract for customer-tunable
|
||||
pricing:
|
||||
|
||||
- allowed tunable parameters
|
||||
- a trade-off lexicon
|
||||
- pilot examples
|
||||
- whether a model is customer-visible or still pilot-only
|
||||
|
||||
For the current Coulomb MVP, the contract exists only as a pilot surface for
|
||||
`membership-plus-overage`; accepted examples are still seller-assisted rather
|
||||
than self-serve.
|
||||
|
||||
## Health And Audit
|
||||
|
||||
The dashboard payload now includes:
|
||||
|
||||
- pricing health checks
|
||||
- provider execution readiness checks
|
||||
- tuning pilot health
|
||||
- experiment capacity checks
|
||||
- provider revision history and active revision metadata
|
||||
|
||||
These surfaces are intended to help both humans and agents decide whether the
|
||||
next safe step is research, simulation, approval, execution, or rollback.
|
||||
113
docs/ImplementationRoadmap.md
Normal file
113
docs/ImplementationRoadmap.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Adaptive Pricing Implementation Roadmap
|
||||
|
||||
Status: draft, implementation-facing.
|
||||
|
||||
## Purpose
|
||||
|
||||
This roadmap translates the strategic goals in `INTENT.md` into an executable
|
||||
implementation sequence for the current repository state.
|
||||
|
||||
The root research roadmap in `research/PricingResearchRoadmap.md` remains the
|
||||
conceptual and research-first planning artifact. This document starts from the
|
||||
current implementation reality: a Coulomb-specific economic observatory MVP
|
||||
under `projects/coulomb-pricing/observatory/`.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
The repository already provides:
|
||||
|
||||
- ledger-backed economics and liquidity tracking
|
||||
- cost-floor, value-range, and market-context views
|
||||
- file-based Stripe / Bubble / OpenRouter imports
|
||||
- scenario comparison for a small set of candidate pricing models
|
||||
- rules-based pricing recommendations
|
||||
- a local dashboard API and UI
|
||||
|
||||
The repository does not yet provide:
|
||||
|
||||
- a canonical, generic pricing-model schema
|
||||
- a validation engine for pricing constraints and commitments
|
||||
- comparable customer LTV estimation
|
||||
- a customer-tuning solver
|
||||
- outbound payment-provider execution
|
||||
- governance workflows for publishing and changing pricing
|
||||
|
||||
## Sequencing Principles
|
||||
|
||||
- Preserve the observatory MVP as a proving ground while extracting generic core logic.
|
||||
- Build schema and validation before solver or provider execution.
|
||||
- Keep the internal pricing model as the source of truth; execution adapters come later.
|
||||
- Require explainability at each stage: validation, simulation, tuning, and publish.
|
||||
- Convert repository planning into milestone workplans rather than one large umbrella plan.
|
||||
|
||||
## Milestones
|
||||
|
||||
| Milestone | Goal | Primary workplan | Dependencies | Exit signal |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| M0 | Economic observatory baseline | `ADAPTIVE-WP-0002` | done | Coulomb ledger, dashboard, simulator, recommendations |
|
||||
| M1 | Extract canonical pricing core and schema | `ADAPTIVE-WP-0003` | M0 | Generic schema and validator adopted by Coulomb data |
|
||||
| M2 | Add boundary engine and explainable validation | `ADAPTIVE-WP-0004` | M1 | Pricing configurations are validated against explicit constraints |
|
||||
| M3 | Upgrade economics to comparable-customer LTV and richer simulation | `ADAPTIVE-WP-0005` | M1, M2 | Simulations compare models using segment/risk-aware economics |
|
||||
| M4 | Implement customer-tuning solver prototype | `ADAPTIVE-WP-0006` | M2, M3 | Tuned configurations can be proposed, evaluated, and explained |
|
||||
| M5 | Add provider abstraction and Stripe publication flow | `ADAPTIVE-WP-0007` | M1, M2, M3 | Internal pricing definitions can publish Stripe artifacts safely |
|
||||
| M6 | Add governance and recommendation workflows | `ADAPTIVE-WP-0008` | M4, M5 | Pricing changes become auditable, explainable, and operational |
|
||||
|
||||
## Milestone Details
|
||||
|
||||
### M1 — Canonical Pricing Core And Schema
|
||||
|
||||
Create a reusable pricing core that can represent more than the current
|
||||
subscription-only observatory model. Expand beyond the current narrow
|
||||
`PricingModel` structure to include charge components, commitments, tunable
|
||||
parameters, eligibility, and provider-mapping metadata.
|
||||
|
||||
### M2 — Boundary Engine And Explainable Validation
|
||||
|
||||
Turn strategic boundary conditions into testable rules. A pricing configuration
|
||||
must be accepted or rejected by explicit logic, not only by dashboard review.
|
||||
This milestone defines hard and soft constraints, invalid-configuration reasons,
|
||||
and commitment-backed discount semantics.
|
||||
|
||||
### M3 — Comparable Customer LTV And Richer Simulation
|
||||
|
||||
Extend the current period snapshot and fixed-assumption simulator into an engine
|
||||
that can compare pricing configurations across customer segments, usage
|
||||
forecasts, risk classes, and contract conditions. This is the economics core
|
||||
needed by any seller-safe adaptive system.
|
||||
|
||||
### M4 — Customer-Tuning Solver Prototype
|
||||
|
||||
Expose a customer/seller configuration interface where selected parameters are
|
||||
tunable and the solver adjusts the rest while preserving seller economics. This
|
||||
milestone is the first actual realization of the adaptive-pricing thesis in
|
||||
`INTENT.md`.
|
||||
|
||||
### M5 — Provider Abstraction And Stripe Publication
|
||||
|
||||
Add an outbound execution layer that maps internal pricing definitions to Stripe
|
||||
artifacts. This is where the repo moves from observatory-only to operational
|
||||
pricing infrastructure.
|
||||
|
||||
### M6 — Governance And Recommendation Workflows
|
||||
|
||||
Operationalize the engine with approval policies, publish/change workflows,
|
||||
auditable recommendations, experiment guardrails, and customer-facing safe
|
||||
tuning contracts.
|
||||
|
||||
## Workplan Set
|
||||
|
||||
- `ADAPTIVE-WP-0003` — Canonical pricing core and schema extraction
|
||||
- `ADAPTIVE-WP-0004` — Boundary engine and explainable validation
|
||||
- `ADAPTIVE-WP-0005` — Comparable customer LTV and simulation upgrade
|
||||
- `ADAPTIVE-WP-0006` — Customer-tuning solver prototype
|
||||
- `ADAPTIVE-WP-0007` — Provider abstraction and Stripe publication
|
||||
- `ADAPTIVE-WP-0008` — Governance and recommendation workflows
|
||||
|
||||
## Near-Term Recommendation
|
||||
|
||||
Start with `ADAPTIVE-WP-0003`.
|
||||
|
||||
The current implementation bottleneck is not UI or provider execution. It is
|
||||
the absence of a sufficiently expressive internal pricing model. Until that
|
||||
schema exists, later milestones would hard-code MVP assumptions into layers that
|
||||
should remain generic.
|
||||
113
docs/PricingModelSchema.md
Normal file
113
docs/PricingModelSchema.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Pricing Model Schema
|
||||
|
||||
Status: draft, implementation-facing.
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the canonical pricing-model schema now used by the
|
||||
repository runtime. It is the implementation companion to the conceptual
|
||||
vocabulary in `research/PricingOntology.md`.
|
||||
|
||||
The schema is designed to:
|
||||
|
||||
- preserve compatibility with the Coulomb observatory MVP
|
||||
- represent richer pricing structures than a single subscription amount
|
||||
- support later validation, solver, and provider-publication milestones
|
||||
|
||||
## Model Shape
|
||||
|
||||
Each pricing model contains:
|
||||
|
||||
- identity and lifecycle metadata
|
||||
- normalized recurring access-fee fields for compatibility
|
||||
- explicit charge components
|
||||
- commitments
|
||||
- tunable parameters
|
||||
- eligibility and provider hints
|
||||
- free-form metadata for deployment-specific details
|
||||
|
||||
## Canonical Fields
|
||||
|
||||
```yaml
|
||||
id: string
|
||||
name: string
|
||||
model_type: flat_subscription | hybrid_subscription_usage | ...
|
||||
lifecycle_phase: exploration | introduction | growth | maturity | saturation | decline
|
||||
currency: EUR | USD | ...
|
||||
status: active | candidate | retired
|
||||
description: string
|
||||
|
||||
# Compatibility fields derived from the access component when omitted
|
||||
access_fee_amount: decimal
|
||||
access_fee_cadence: monthly | annual | one_time | ...
|
||||
included_usage: string | null
|
||||
overage_meter: string | null
|
||||
|
||||
charge_components:
|
||||
- id: string
|
||||
kind: access | setup | usage | support | discount | risk_adjustment
|
||||
amount: decimal | null
|
||||
cadence: string | null
|
||||
meter: string | null
|
||||
unit: string | null
|
||||
unit_price: decimal | null
|
||||
included_units: decimal | null
|
||||
label: string | null
|
||||
billing_treatment: recurring | metered | included | one_time | ...
|
||||
metadata: {}
|
||||
|
||||
commitments:
|
||||
- id: string
|
||||
kind: minimum_turnover | contract_duration | prepayment | committed_usage | ...
|
||||
value: string
|
||||
unit: string | null
|
||||
description: string
|
||||
|
||||
tunable_parameters:
|
||||
- key: string
|
||||
parameter_class: fixed | seller_controlled | customer_tunable | calculated | constrained | provider
|
||||
data_type: string
|
||||
description: string
|
||||
default_value: string | null
|
||||
min_value: decimal | null
|
||||
max_value: decimal | null
|
||||
options: []
|
||||
|
||||
eligibility:
|
||||
- string
|
||||
|
||||
provider_hints: {}
|
||||
metadata: {}
|
||||
```
|
||||
|
||||
## Parameter Classes
|
||||
|
||||
- `fixed`: immutable in the selected model
|
||||
- `seller_controlled`: adjustable only by the seller or internal workflow
|
||||
- `customer_tunable`: intended to become solver-visible customer choice
|
||||
- `calculated`: derived from other fields or economics
|
||||
- `constrained`: externally set but bounded by validation rules
|
||||
- `provider`: implementation-only parameter for execution backends
|
||||
|
||||
## Validation Rules
|
||||
|
||||
Current runtime validation enforces:
|
||||
|
||||
- model ids are unique
|
||||
- charge component ids are unique within a model
|
||||
- exactly one `access` charge component exists
|
||||
- access components define amount and cadence
|
||||
- usage components define a meter
|
||||
- `hybrid_subscription_usage` models include a usage charge component
|
||||
- tunable parameter keys are unique
|
||||
- `customer_tunable` parameters declare bounds or enumerated options
|
||||
- commitment ids are unique
|
||||
|
||||
## Transitional Compatibility
|
||||
|
||||
The Coulomb observatory still consumes `access_fee_amount`, `access_fee_cadence`,
|
||||
`included_usage`, and `overage_meter`. The canonical loader back-fills these
|
||||
from `charge_components` when the explicit top-level fields are omitted.
|
||||
|
||||
This keeps the current observatory stable while later milestones replace
|
||||
hard-coded observatory assumptions with generic pricing-core behavior.
|
||||
114
docs/StripePublication.md
Normal file
114
docs/StripePublication.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# Stripe Publication
|
||||
|
||||
Status: MVP for `ADAPTIVE-WP-0007`.
|
||||
|
||||
## Purpose
|
||||
|
||||
This milestone adds the first outbound execution layer for pricing models.
|
||||
|
||||
The implementation keeps `adaptive-pricing` as the source of truth and treats
|
||||
Stripe as an execution backend. In this repository, publication targets a
|
||||
file-backed Stripe shadow state rather than the live Stripe API.
|
||||
|
||||
## Core Modules
|
||||
|
||||
- `adaptive_pricing_core/provider_publication.py`
|
||||
- `adaptive_pricing_core/stripe_provider.py`
|
||||
|
||||
The provider-publication core defines:
|
||||
|
||||
- provider-neutral publishable artifacts
|
||||
- publication plans and operations
|
||||
- drift findings
|
||||
- revisioned shadow state
|
||||
- rollback mechanics
|
||||
|
||||
The Stripe mapper translates publishable artifacts into Stripe-oriented objects
|
||||
and marks each mapping as:
|
||||
|
||||
- `exact`
|
||||
- `approximate`
|
||||
- `unsupported`
|
||||
|
||||
## Publishable Artifact Model
|
||||
|
||||
Current provider-neutral artifacts:
|
||||
|
||||
- product
|
||||
- meter
|
||||
- price
|
||||
- commitment
|
||||
- configuration
|
||||
|
||||
Current Stripe-oriented object types:
|
||||
|
||||
- `product`
|
||||
- `billing_meter`
|
||||
- `price`
|
||||
- `coupon`
|
||||
- `metadata_binding`
|
||||
|
||||
`metadata_binding` is used for execution-adjacent information that Stripe can
|
||||
store as metadata but does not treat as a first-class pricing object.
|
||||
|
||||
## Mapping Semantics
|
||||
|
||||
Current exact mappings:
|
||||
|
||||
- catalog product identity and metadata
|
||||
- fixed recurring and one-time prices
|
||||
- metered usage prices without bundled allowance semantics
|
||||
- Stripe meter definitions
|
||||
|
||||
Current approximate mappings:
|
||||
|
||||
- metered prices that also imply included usage
|
||||
- discount components mapped as coupon-like artifacts
|
||||
- contract-duration commitments carried as metadata or schedule-adjacent data
|
||||
- configuration artifacts carried as metadata
|
||||
|
||||
Current unsupported mappings:
|
||||
|
||||
- included-usage-only components without a billable per-unit overage price
|
||||
- commitment semantics such as prepayment or minimum turnover when Stripe alone
|
||||
cannot enforce them
|
||||
|
||||
## Coulomb Adapter
|
||||
|
||||
Project adapter:
|
||||
|
||||
- `projects/coulomb-pricing/observatory/publication.py`
|
||||
- `projects/coulomb-pricing/observatory/publish.py`
|
||||
|
||||
Default local shadow-state path:
|
||||
|
||||
- `projects/coulomb-pricing/data/provider_state/stripe-publication.json`
|
||||
|
||||
Preview:
|
||||
|
||||
```bash
|
||||
cd projects/coulomb-pricing
|
||||
python3 -m observatory.publish --model-id flat-899-eur-monthly
|
||||
```
|
||||
|
||||
Apply to the local shadow state:
|
||||
|
||||
```bash
|
||||
cd projects/coulomb-pricing
|
||||
python3 -m observatory.publish --model-id flat-899-eur-monthly --apply
|
||||
```
|
||||
|
||||
Rollback:
|
||||
|
||||
```bash
|
||||
cd projects/coulomb-pricing
|
||||
python3 -m observatory.publish --rollback stripe-rev-0001
|
||||
```
|
||||
|
||||
## Current Scope Limit
|
||||
|
||||
This milestone does not call the live Stripe API.
|
||||
|
||||
It establishes the internal publication model, Stripe object mapping,
|
||||
idempotent shadow-state synchronization, drift detection, and rollback path so
|
||||
live API execution can be layered on without making Stripe the source of truth.
|
||||
19
projects/coulomb-pricing/Makefile
Normal file
19
projects/coulomb-pricing/Makefile
Normal file
@@ -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
|
||||
@@ -2,11 +2,75 @@
|
||||
|
||||
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/archived/260622-ADAPTIVE-WP-0002-economic-observatory-mvp.md`
|
||||
(finished 2026-06-22).
|
||||
|
||||
**Execution tracking:** `workplans/ADAPTIVE-WP-0002-economic-observatory-mvp.md`
|
||||
Liquidity and cost requirements: `REQUIREMENTS.md`.
|
||||
UI workflow (whynot-design): `docs/UI-WORKFLOW.md`.
|
||||
|
||||
**Strategic positioning:** Adaptive Pricing is the pricing capability layer for
|
||||
Coulomb offerings and related product ecosystems.
|
||||
## Economic Observatory
|
||||
|
||||
The `observatory/` package reads **expense and payment record ledgers** and
|
||||
computes all totals programmatically (`ledger.py` → `economics.py`).
|
||||
|
||||
| Ledger | File |
|
||||
|--------|------|
|
||||
| Budget (€1,000 start) | `data/budget.json` |
|
||||
| Expense records | `data/expense_records.json` |
|
||||
| Payment records | `data/payment_records.json` |
|
||||
| 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
|
||||
**€8.99/mo** (from Mar 2026). Member **tegwick** pays **€8.99/mo** (Stripe fee
|
||||
**€0.44**, net payout **€8.55** to binky-hedgehog) from November 2025. Customer
|
||||
cost-pass-through billing is not active.
|
||||
|
||||
### Commands
|
||||
|
||||
```bash
|
||||
cd projects/coulomb-pricing
|
||||
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
|
||||
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 `<wn-*>` components) from `ui/vendor/whynot-design/`. See
|
||||
`docs/UI-WORKFLOW.md` for the implementation process.
|
||||
|
||||
### 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.
|
||||
|
||||
### Stripe Publication (shadow state)
|
||||
|
||||
```bash
|
||||
cd projects/coulomb-pricing
|
||||
python3 -m observatory.publish --model-id flat-899-eur-monthly
|
||||
python3 -m observatory.publish --model-id flat-899-eur-monthly --apply
|
||||
python3 -m observatory.publish --rollback stripe-rev-0001
|
||||
```
|
||||
|
||||
These commands preview, apply, and roll back the local Stripe shadow state used
|
||||
by the provider-publication MVP. The live Stripe API is still outside this
|
||||
milestone.
|
||||
|
||||
83
projects/coulomb-pricing/REQUIREMENTS.md
Normal file
83
projects/coulomb-pricing/REQUIREMENTS.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Coulomb MVP — Liquidity & Cost Requirements
|
||||
|
||||
## Context
|
||||
|
||||
Coulomb Social carries **operator infrastructure costs** (domains, virtual
|
||||
servers, payment-processing fees, future OpenRouter usage) while **customer
|
||||
cost-pass-through billing is not active yet**. Members pay a flat subscription; they are not billed for
|
||||
underlying platform spend.
|
||||
|
||||
The Economic Observatory must make the resulting **liquidity burn** visible.
|
||||
|
||||
## Requirements
|
||||
|
||||
### LQ-001 — Expense record ledger (source of truth)
|
||||
|
||||
Capture every operator expense as an individual record in
|
||||
`data/expense_records.json`. Fields: `period`, `vendor`, `amount`, `currency`,
|
||||
`cost_class`, `source`.
|
||||
|
||||
**Never store hand-calculated monthly or cumulative totals in data files.**
|
||||
All aggregations must be computed programmatically by `observatory/ledger.py`.
|
||||
|
||||
### LQ-002 — Payment record ledger
|
||||
|
||||
Capture member subscription payments in `data/payment_records.json` with gross,
|
||||
fees, and net amounts per period. Payment-processing totals are summed from
|
||||
`fees_amount` — not duplicated as expense records.
|
||||
|
||||
### LQ-003 — Budget tracking
|
||||
|
||||
Maintain an operator liquidity budget (initial: **€1,000**) and compute
|
||||
remaining budget after cumulative **infrastructure** spend minus cumulative net
|
||||
member payments received.
|
||||
|
||||
### LQ-004 — Liquidity position
|
||||
|
||||
Report whether the project is **burning**, **neutral**, or **generating**
|
||||
liquidity each period:
|
||||
|
||||
- `period_net = net_member_payments - infrastructure_cost`
|
||||
- `cumulative_net = sum(period_net)`
|
||||
- `remaining_budget = initial_budget + cumulative_net`
|
||||
|
||||
**No double-counting:** payment-processing fees are deducted from net member
|
||||
payments. They are summed separately for economics reporting but must **not** be
|
||||
subtracted again in the liquidity formula.
|
||||
|
||||
- `total_platform_cost = infrastructure_cost + payment_processing_cost` (for
|
||||
gross-margin economics vs gross revenue)
|
||||
- `cumulative_total_platform_cost` is informational; liquidity burn uses
|
||||
`cumulative_infrastructure_cost` only
|
||||
|
||||
### LQ-005 — No customer cost billing (MVP boundary)
|
||||
|
||||
Do not allocate platform costs to customer invoices in MVP. Cost attribution
|
||||
(OpenRouter per member, usage overage) is observatory-only until a later phase
|
||||
introduces customer-visible credits or usage billing.
|
||||
|
||||
### LQ-006 — Historical dashboard
|
||||
|
||||
Economics Dashboard must show:
|
||||
|
||||
- Current-period economics (revenue, platform cost, margin)
|
||||
- Cumulative liquidity summary (budget, burn, remaining)
|
||||
- Monthly history table computed from ledgers
|
||||
|
||||
### LQ-007 — Deterministic calculation engine
|
||||
|
||||
All economics and liquidity figures must be produced by the Python observatory
|
||||
package (`ledger.py`, `economics.py`). LLM or manual arithmetic must not be the
|
||||
source of aggregated totals.
|
||||
|
||||
## Data sources (current)
|
||||
|
||||
| Ledger | Path |
|
||||
|--------|------|
|
||||
| Budget | `data/budget.json` |
|
||||
| Expense records | `data/expense_records.json` |
|
||||
| Payment records | `data/payment_records.json` |
|
||||
| Membership | `data/membership.json` |
|
||||
|
||||
Future sprints replace manual records with Bubble, Stripe, and OpenRouter imports
|
||||
while preserving the same ledger schema and calculation rules.
|
||||
7
projects/coulomb-pricing/data/budget.json
Normal file
7
projects/coulomb-pricing/data/budget.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"currency": "EUR",
|
||||
"initial_budget": "1000.00",
|
||||
"started": "2025-01",
|
||||
"note": "Operator liquidity pool for Coulomb Social MVP platform spend before the offering is cash-flow positive."
|
||||
}
|
||||
11
projects/coulomb-pricing/data/credit_wallets.json
Normal file
11
projects/coulomb-pricing/data/credit_wallets.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
586
projects/coulomb-pricing/data/expense_records.json
Normal file
586
projects/coulomb-pricing/data/expense_records.json
Normal file
@@ -0,0 +1,586 @@
|
||||
{
|
||||
"version": 2,
|
||||
"records": [
|
||||
{
|
||||
"id": "exp-domain-social-2025-01",
|
||||
"period": "2025-01",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-01",
|
||||
"period": "2025-01",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-01",
|
||||
"period": "2025-01",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-02",
|
||||
"period": "2025-02",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-02",
|
||||
"period": "2025-02",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-02",
|
||||
"period": "2025-02",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-03",
|
||||
"period": "2025-03",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-03",
|
||||
"period": "2025-03",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-03",
|
||||
"period": "2025-03",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-04",
|
||||
"period": "2025-04",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-04",
|
||||
"period": "2025-04",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-04",
|
||||
"period": "2025-04",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-05",
|
||||
"period": "2025-05",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-05",
|
||||
"period": "2025-05",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-05",
|
||||
"period": "2025-05",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-06",
|
||||
"period": "2025-06",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-06",
|
||||
"period": "2025-06",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-06",
|
||||
"period": "2025-06",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-07",
|
||||
"period": "2025-07",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-07",
|
||||
"period": "2025-07",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-07",
|
||||
"period": "2025-07",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-08",
|
||||
"period": "2025-08",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-08",
|
||||
"period": "2025-08",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-08",
|
||||
"period": "2025-08",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-09",
|
||||
"period": "2025-09",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-09",
|
||||
"period": "2025-09",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-09",
|
||||
"period": "2025-09",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-10",
|
||||
"period": "2025-10",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-10",
|
||||
"period": "2025-10",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-10",
|
||||
"period": "2025-10",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-11",
|
||||
"period": "2025-11",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-11",
|
||||
"period": "2025-11",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-11",
|
||||
"period": "2025-11",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2025-12",
|
||||
"period": "2025-12",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2025-12",
|
||||
"period": "2025-12",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2025-12",
|
||||
"period": "2025-12",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2026-01",
|
||||
"period": "2026-01",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2026-01",
|
||||
"period": "2026-01",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2026-01",
|
||||
"period": "2026-01",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2026-02",
|
||||
"period": "2026-02",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2026-02",
|
||||
"period": "2026-02",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2026-02",
|
||||
"period": "2026-02",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2026-03",
|
||||
"period": "2026-03",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2026-03",
|
||||
"period": "2026-03",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2026-03",
|
||||
"period": "2026-03",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-railiance01-2026-03",
|
||||
"period": "2026-03",
|
||||
"vendor": "hosting.railiance01",
|
||||
"description": "railiance01 virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "8.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2026-04",
|
||||
"period": "2026-04",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2026-04",
|
||||
"period": "2026-04",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2026-04",
|
||||
"period": "2026-04",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-railiance01-2026-04",
|
||||
"period": "2026-04",
|
||||
"vendor": "hosting.railiance01",
|
||||
"description": "railiance01 virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "8.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2026-05",
|
||||
"period": "2026-05",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2026-05",
|
||||
"period": "2026-05",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2026-05",
|
||||
"period": "2026-05",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-railiance01-2026-05",
|
||||
"period": "2026-05",
|
||||
"vendor": "hosting.railiance01",
|
||||
"description": "railiance01 virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "8.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-social-2026-06",
|
||||
"period": "2026-06",
|
||||
"vendor": "domain.coulomb.social",
|
||||
"description": "coulomb.social (.social) 3.75 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.75",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-domain-pro-2026-06",
|
||||
"period": "2026-06",
|
||||
"vendor": "domain.coulomb.pro",
|
||||
"description": "coulomb.pro (.pro) 3.00 EUR/mo incl. 0.14 EUR/yr ICANN",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "3.00",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-coulombcore-2026-06",
|
||||
"period": "2026-06",
|
||||
"vendor": "hosting.coulombcore",
|
||||
"description": "coulombcore virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "13.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
},
|
||||
{
|
||||
"id": "exp-hosting-railiance01-2026-06",
|
||||
"period": "2026-06",
|
||||
"vendor": "hosting.railiance01",
|
||||
"description": "railiance01 virtual server hosting",
|
||||
"cost_class": "infrastructure",
|
||||
"amount": "8.99",
|
||||
"currency": "EUR",
|
||||
"source": "invoice"
|
||||
}
|
||||
],
|
||||
"note": "Infrastructure expense ledger: domains and virtual server hosting. Totals computed by observatory/ledger.py."
|
||||
}
|
||||
24
projects/coulomb-pricing/data/governance_policy.json
Normal file
24
projects/coulomb-pricing/data/governance_policy.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"policy_id": "coulomb-governance-v1",
|
||||
"max_self_serve_discount_pct": "10",
|
||||
"max_customer_visible_price_increase_pct": "15",
|
||||
"max_active_experiments": 2,
|
||||
"max_concurrent_candidate_rollouts": 1,
|
||||
"require_approval_for_candidate_rollout": true,
|
||||
"require_approval_for_approximate_provider_mapping": true,
|
||||
"block_unsupported_provider_artifacts": true,
|
||||
"drift_blocks_execution": true,
|
||||
"require_approval_for_price_change": true,
|
||||
"require_customer_notice_for_price_increase": true,
|
||||
"customer_notice_days": 30,
|
||||
"grandfather_existing_customers": true,
|
||||
"customer_visible_tuning_enabled": false,
|
||||
"customer_visible_tuning_requires_active_model": true,
|
||||
"communication_owner_role": "operator",
|
||||
"default_approver_role": "operator",
|
||||
"metadata": {
|
||||
"active_experiment_count": 0,
|
||||
"candidate_rollout_count": 0,
|
||||
"policy_scope": "coulomb-social-mvp"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"usage": [
|
||||
{
|
||||
"period": "2026-06",
|
||||
"user_id": "member-tegwick",
|
||||
"model": "anthropic/claude-3-haiku",
|
||||
"tokens": 48200,
|
||||
"cost_usd": "0.06"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
26
projects/coulomb-pricing/data/infrastructure/domains.json
Normal file
26
projects/coulomb-pricing/data/infrastructure/domains.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"version": 1,
|
||||
"currency": "EUR",
|
||||
"domains": [
|
||||
{
|
||||
"name": "coulomb.social",
|
||||
"tld": ".social",
|
||||
"monthly_eur": "3.75",
|
||||
"icann_fee_annual_eur": "0.14",
|
||||
"billing_period": "2026-01-31/2027-01-30",
|
||||
"annual_total_eur": "45.00",
|
||||
"vat_rate": "19.0"
|
||||
},
|
||||
{
|
||||
"name": "coulomb.pro",
|
||||
"tld": ".pro",
|
||||
"monthly_eur": "3.00",
|
||||
"icann_fee_annual_eur": "0.14",
|
||||
"billing_period": "2026-01-31/2027-01-30",
|
||||
"annual_total_eur": "36.00",
|
||||
"vat_rate": "19.0"
|
||||
}
|
||||
],
|
||||
"monthly_total_eur": "6.75",
|
||||
"note": "Reference catalog from registrar invoices. Expense ledger rows live in expense_records.json."
|
||||
}
|
||||
13
projects/coulomb-pricing/data/infrastructure/stripe.json
Normal file
13
projects/coulomb-pricing/data/infrastructure/stripe.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"currency": "EUR",
|
||||
"payout_account": "binky-hedgehog",
|
||||
"membership": {
|
||||
"product": "coulomb.social-membership",
|
||||
"gross_monthly_eur": "8.99",
|
||||
"fee_monthly_eur": "0.44",
|
||||
"net_payout_monthly_eur": "8.55",
|
||||
"member_username": "tegwick"
|
||||
},
|
||||
"note": "Reference from actual Stripe payouts. Payment rows live in payment_records.json."
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"version": 1,
|
||||
"currency": "EUR",
|
||||
"servers": [
|
||||
{
|
||||
"name": "coulombcore",
|
||||
"monthly_eur": "13.99",
|
||||
"started": "2025-01",
|
||||
"description": "Virtual server hosting for coulombcore"
|
||||
},
|
||||
{
|
||||
"name": "railiance01",
|
||||
"monthly_eur": "8.99",
|
||||
"started": "2026-03",
|
||||
"description": "Virtual server hosting for railiance01"
|
||||
}
|
||||
],
|
||||
"note": "Reference catalog. Monthly expense rows live in expense_records.json."
|
||||
}
|
||||
76
projects/coulomb-pricing/data/ltv_scenarios.json
Normal file
76
projects/coulomb-pricing/data/ltv_scenarios.json
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"version": 1,
|
||||
"currency": "EUR",
|
||||
"horizon_months": 24,
|
||||
"monthly_discount_rate_pct": "1.0",
|
||||
"required_improvement_factor": "1.05",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "solo-builder",
|
||||
"name": "Solo builder",
|
||||
"segment": "coulomb-social-members",
|
||||
"eligible_model_ids": [
|
||||
"flat-899-eur-monthly",
|
||||
"membership-plus-credits",
|
||||
"membership-plus-overage"
|
||||
],
|
||||
"members_per_customer": 1,
|
||||
"expected_monthly_usage_units": "48200",
|
||||
"usage_variance_pct": "20",
|
||||
"monthly_churn_pct": "6.0",
|
||||
"monthly_default_pct": "1.0",
|
||||
"monthly_support_cost": "0.25",
|
||||
"monthly_risk_cost": "0.10",
|
||||
"acquisition_cost": "2.00",
|
||||
"upfront_investment_cost": "0.00",
|
||||
"allocated_fixed_cost": "5.00",
|
||||
"notes": "Calibrated from the current founding-member usage record and payment ledger."
|
||||
},
|
||||
{
|
||||
"id": "small-team",
|
||||
"name": "Small product team",
|
||||
"segment": "coulomb-social-members",
|
||||
"eligible_model_ids": [
|
||||
"flat-899-eur-monthly",
|
||||
"membership-plus-credits",
|
||||
"membership-plus-overage"
|
||||
],
|
||||
"members_per_customer": 3,
|
||||
"expected_monthly_usage_units": "180000",
|
||||
"usage_variance_pct": "35",
|
||||
"monthly_churn_pct": "3.5",
|
||||
"monthly_default_pct": "1.0",
|
||||
"monthly_support_cost": "1.50",
|
||||
"monthly_risk_cost": "0.20",
|
||||
"acquisition_cost": "8.00",
|
||||
"upfront_investment_cost": "1.50",
|
||||
"allocated_fixed_cost": "12.00",
|
||||
"notes": "Hypothesis scenario for a higher-usage small team considering a multi-seat relationship."
|
||||
}
|
||||
],
|
||||
"sensitivity_cases": [
|
||||
{
|
||||
"id": "usage-downside",
|
||||
"name": "Usage downside",
|
||||
"usage_multiplier": "0.75",
|
||||
"monthly_churn_delta_pct": "1.5",
|
||||
"monthly_risk_cost_delta": "0.05"
|
||||
},
|
||||
{
|
||||
"id": "usage-upside",
|
||||
"name": "Usage upside",
|
||||
"usage_multiplier": "1.35",
|
||||
"usage_variance_delta_pct": "10.0"
|
||||
},
|
||||
{
|
||||
"id": "risk-spike",
|
||||
"name": "Risk spike",
|
||||
"usage_multiplier": "1.00",
|
||||
"monthly_churn_delta_pct": "3.0",
|
||||
"monthly_default_delta_pct": "1.0",
|
||||
"monthly_support_cost_delta": "0.25",
|
||||
"monthly_risk_cost_delta": "0.20"
|
||||
}
|
||||
],
|
||||
"notes": "First-pass comparable-customer LTV assumptions for the Coulomb observatory. These scenarios are meant for simulation and comparison, not billing execution."
|
||||
}
|
||||
56
projects/coulomb-pricing/data/market_signals.json
Normal file
56
projects/coulomb-pricing/data/market_signals.json
Normal file
@@ -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."
|
||||
}
|
||||
17
projects/coulomb-pricing/data/membership.json
Normal file
17
projects/coulomb-pricing/data/membership.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"snapshot_date": "2026-06-22",
|
||||
"members": [
|
||||
{
|
||||
"id": "member-tegwick",
|
||||
"username": "tegwick",
|
||||
"external_id": null,
|
||||
"status": "active",
|
||||
"joined_at": "2025-11-03",
|
||||
"plan_id": "flat-899-eur-monthly",
|
||||
"source": "stripe",
|
||||
"note": "Sole paying member; coulomb.social membership 8.99 EUR/mo"
|
||||
}
|
||||
],
|
||||
"note": "Infrastructure costs from January 2025; member payments from November 2025."
|
||||
}
|
||||
118
projects/coulomb-pricing/data/payment_records.json
Normal file
118
projects/coulomb-pricing/data/payment_records.json
Normal file
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"version": 2,
|
||||
"records": [
|
||||
{
|
||||
"id": "pay-2025-11",
|
||||
"period": "2025-11",
|
||||
"gross_amount": "8.99",
|
||||
"fees_amount": "0.44",
|
||||
"refunds_amount": "0.00",
|
||||
"net_amount": "8.55",
|
||||
"currency": "EUR",
|
||||
"source": "stripe",
|
||||
"member_count": 1,
|
||||
"member_username": "tegwick",
|
||||
"product": "coulomb.social-membership",
|
||||
"payout_account": "binky-hedgehog"
|
||||
},
|
||||
{
|
||||
"id": "pay-2025-12",
|
||||
"period": "2025-12",
|
||||
"gross_amount": "8.99",
|
||||
"fees_amount": "0.44",
|
||||
"refunds_amount": "0.00",
|
||||
"net_amount": "8.55",
|
||||
"currency": "EUR",
|
||||
"source": "stripe",
|
||||
"member_count": 1,
|
||||
"member_username": "tegwick",
|
||||
"product": "coulomb.social-membership",
|
||||
"payout_account": "binky-hedgehog"
|
||||
},
|
||||
{
|
||||
"id": "pay-2026-01",
|
||||
"period": "2026-01",
|
||||
"gross_amount": "8.99",
|
||||
"fees_amount": "0.44",
|
||||
"refunds_amount": "0.00",
|
||||
"net_amount": "8.55",
|
||||
"currency": "EUR",
|
||||
"source": "stripe",
|
||||
"member_count": 1,
|
||||
"member_username": "tegwick",
|
||||
"product": "coulomb.social-membership",
|
||||
"payout_account": "binky-hedgehog"
|
||||
},
|
||||
{
|
||||
"id": "pay-2026-02",
|
||||
"period": "2026-02",
|
||||
"gross_amount": "8.99",
|
||||
"fees_amount": "0.44",
|
||||
"refunds_amount": "0.00",
|
||||
"net_amount": "8.55",
|
||||
"currency": "EUR",
|
||||
"source": "stripe",
|
||||
"member_count": 1,
|
||||
"member_username": "tegwick",
|
||||
"product": "coulomb.social-membership",
|
||||
"payout_account": "binky-hedgehog"
|
||||
},
|
||||
{
|
||||
"id": "pay-2026-03",
|
||||
"period": "2026-03",
|
||||
"gross_amount": "8.99",
|
||||
"fees_amount": "0.44",
|
||||
"refunds_amount": "0.00",
|
||||
"net_amount": "8.55",
|
||||
"currency": "EUR",
|
||||
"source": "stripe",
|
||||
"member_count": 1,
|
||||
"member_username": "tegwick",
|
||||
"product": "coulomb.social-membership",
|
||||
"payout_account": "binky-hedgehog"
|
||||
},
|
||||
{
|
||||
"id": "pay-2026-04",
|
||||
"period": "2026-04",
|
||||
"gross_amount": "8.99",
|
||||
"fees_amount": "0.44",
|
||||
"refunds_amount": "0.00",
|
||||
"net_amount": "8.55",
|
||||
"currency": "EUR",
|
||||
"source": "stripe",
|
||||
"member_count": 1,
|
||||
"member_username": "tegwick",
|
||||
"product": "coulomb.social-membership",
|
||||
"payout_account": "binky-hedgehog"
|
||||
},
|
||||
{
|
||||
"id": "pay-2026-05",
|
||||
"period": "2026-05",
|
||||
"gross_amount": "8.99",
|
||||
"fees_amount": "0.44",
|
||||
"refunds_amount": "0.00",
|
||||
"net_amount": "8.55",
|
||||
"currency": "EUR",
|
||||
"source": "stripe",
|
||||
"member_count": 1,
|
||||
"member_username": "tegwick",
|
||||
"product": "coulomb.social-membership",
|
||||
"payout_account": "binky-hedgehog"
|
||||
},
|
||||
{
|
||||
"id": "pay-2026-06",
|
||||
"period": "2026-06",
|
||||
"gross_amount": "8.99",
|
||||
"fees_amount": "0.44",
|
||||
"refunds_amount": "0.00",
|
||||
"net_amount": "8.55",
|
||||
"currency": "EUR",
|
||||
"source": "stripe",
|
||||
"member_count": 1,
|
||||
"member_username": "tegwick",
|
||||
"product": "coulomb.social-membership",
|
||||
"payout_account": "binky-hedgehog"
|
||||
}
|
||||
],
|
||||
"note": "Stripe payment ledger for tegwick coulomb.social membership (8.99 EUR gross, 0.44 EUR fees, 8.55 EUR net payout to binky-hedgehog)."
|
||||
}
|
||||
206
projects/coulomb-pricing/data/pricing-models.json
Normal file
206
projects/coulomb-pricing/data/pricing-models.json
Normal file
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"version": 1,
|
||||
"models": [
|
||||
{
|
||||
"id": "flat-899-eur-monthly",
|
||||
"name": "Standard Membership",
|
||||
"model_type": "flat_subscription",
|
||||
"lifecycle_phase": "growth",
|
||||
"currency": "EUR",
|
||||
"description": "Current flat membership offer for Coulomb Social.",
|
||||
"access_fee_amount": "8.99",
|
||||
"access_fee_cadence": "monthly",
|
||||
"included_usage": "unlimited_repository_access",
|
||||
"status": "active",
|
||||
"charge_components": [
|
||||
{
|
||||
"id": "membership-access",
|
||||
"kind": "access",
|
||||
"amount": "8.99",
|
||||
"cadence": "monthly",
|
||||
"label": "Standard membership access fee",
|
||||
"billing_treatment": "recurring",
|
||||
"metadata": {
|
||||
"included_usage": "unlimited_repository_access"
|
||||
}
|
||||
}
|
||||
],
|
||||
"commitments": [
|
||||
{
|
||||
"id": "baseline-term",
|
||||
"kind": "contract_duration",
|
||||
"value": "1",
|
||||
"unit": "month",
|
||||
"description": "Baseline self-serve monthly term."
|
||||
}
|
||||
],
|
||||
"tunable_parameters": [],
|
||||
"eligibility": [
|
||||
"coulomb-social-members"
|
||||
],
|
||||
"provider_hints": {
|
||||
"stripe": {
|
||||
"collection_method": "charge_automatically"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"catalog_version": "canonical-v1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "membership-plus-credits",
|
||||
"name": "Membership + AI Credits",
|
||||
"model_type": "hybrid_subscription_usage",
|
||||
"lifecycle_phase": "exploration",
|
||||
"currency": "EUR",
|
||||
"description": "Candidate model bundling recurring access with a monthly AI allowance.",
|
||||
"access_fee_amount": "8.99",
|
||||
"access_fee_cadence": "monthly",
|
||||
"included_usage": "monthly_ai_credit_allowance",
|
||||
"status": "candidate",
|
||||
"charge_components": [
|
||||
{
|
||||
"id": "membership-access",
|
||||
"kind": "access",
|
||||
"amount": "8.99",
|
||||
"cadence": "monthly",
|
||||
"label": "Membership base fee",
|
||||
"billing_treatment": "recurring"
|
||||
},
|
||||
{
|
||||
"id": "ai-credit-allowance",
|
||||
"kind": "usage",
|
||||
"meter": "openrouter_tokens",
|
||||
"unit": "tokens",
|
||||
"included_units": "100000",
|
||||
"label": "Included monthly AI token allowance",
|
||||
"billing_treatment": "included",
|
||||
"metadata": {
|
||||
"included_usage": "monthly_ai_credit_allowance"
|
||||
}
|
||||
}
|
||||
],
|
||||
"commitments": [
|
||||
{
|
||||
"id": "credit-prepay-window",
|
||||
"kind": "prepayment",
|
||||
"value": "1",
|
||||
"unit": "month",
|
||||
"description": "Allowance resets monthly in the observatory prototype."
|
||||
}
|
||||
],
|
||||
"tunable_parameters": [
|
||||
{
|
||||
"key": "included_tokens",
|
||||
"parameter_class": "seller_controlled",
|
||||
"data_type": "integer",
|
||||
"description": "Included OpenRouter token allowance for the monthly bundle.",
|
||||
"default_value": "100000",
|
||||
"min_value": "50000",
|
||||
"max_value": "500000"
|
||||
},
|
||||
{
|
||||
"key": "monthly_allowance_eur",
|
||||
"parameter_class": "calculated",
|
||||
"data_type": "decimal",
|
||||
"description": "Observatory-only euro allowance derived from provider usage cost.",
|
||||
"default_value": "2.00"
|
||||
}
|
||||
],
|
||||
"eligibility": [
|
||||
"coulomb-social-members"
|
||||
],
|
||||
"provider_hints": {
|
||||
"stripe": {
|
||||
"metered_usage_strategy": "future_adapter"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"catalog_version": "canonical-v1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "membership-plus-overage",
|
||||
"name": "Membership + Overage",
|
||||
"model_type": "hybrid_subscription_usage",
|
||||
"lifecycle_phase": "exploration",
|
||||
"currency": "EUR",
|
||||
"description": "Candidate model pairing recurring access with included tokens and metered overage.",
|
||||
"access_fee_amount": "8.99",
|
||||
"access_fee_cadence": "monthly",
|
||||
"included_usage": "monthly_ai_credit_allowance",
|
||||
"overage_meter": "openrouter_tokens",
|
||||
"status": "candidate",
|
||||
"charge_components": [
|
||||
{
|
||||
"id": "membership-access",
|
||||
"kind": "access",
|
||||
"amount": "8.99",
|
||||
"cadence": "monthly",
|
||||
"label": "Membership base fee",
|
||||
"billing_treatment": "recurring"
|
||||
},
|
||||
{
|
||||
"id": "ai-overage-usage",
|
||||
"kind": "usage",
|
||||
"meter": "openrouter_tokens",
|
||||
"unit": "tokens",
|
||||
"included_units": "100000",
|
||||
"unit_price": "0.002",
|
||||
"label": "OpenRouter token overage",
|
||||
"billing_treatment": "metered",
|
||||
"metadata": {
|
||||
"included_usage": "monthly_ai_credit_allowance"
|
||||
}
|
||||
}
|
||||
],
|
||||
"commitments": [
|
||||
{
|
||||
"id": "baseline-term",
|
||||
"kind": "contract_duration",
|
||||
"value": "1",
|
||||
"unit": "month",
|
||||
"description": "Baseline monthly term; solver can later trade this against usage economics."
|
||||
}
|
||||
],
|
||||
"tunable_parameters": [
|
||||
{
|
||||
"key": "included_tokens",
|
||||
"parameter_class": "customer_tunable",
|
||||
"data_type": "integer",
|
||||
"description": "Customer-selectable included token allowance within seller-approved bounds.",
|
||||
"default_value": "100000",
|
||||
"min_value": "50000",
|
||||
"max_value": "300000"
|
||||
},
|
||||
{
|
||||
"key": "contract_duration_months",
|
||||
"parameter_class": "customer_tunable",
|
||||
"data_type": "integer",
|
||||
"description": "Longer term can support improved usage pricing in later solver milestones.",
|
||||
"default_value": "1",
|
||||
"min_value": "1",
|
||||
"max_value": "12"
|
||||
},
|
||||
{
|
||||
"key": "overage_unit_price",
|
||||
"parameter_class": "calculated",
|
||||
"data_type": "decimal",
|
||||
"description": "Current observatory overage rate derived from scenario assumptions.",
|
||||
"default_value": "0.002"
|
||||
}
|
||||
],
|
||||
"eligibility": [
|
||||
"coulomb-social-members"
|
||||
],
|
||||
"provider_hints": {
|
||||
"stripe": {
|
||||
"meter_name": "openrouter_tokens"
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"catalog_version": "canonical-v1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
12
projects/coulomb-pricing/data/product.json
Normal file
12
projects/coulomb-pricing/data/product.json
Normal 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"
|
||||
}
|
||||
}
|
||||
38
projects/coulomb-pricing/data/tuning_requests.json
Normal file
38
projects/coulomb-pricing/data/tuning_requests.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"version": 1,
|
||||
"requests": [
|
||||
{
|
||||
"id": "small-team-lower-usage-price",
|
||||
"name": "Small team lower usage price",
|
||||
"profile_id": "small-team",
|
||||
"model_id": "membership-plus-overage",
|
||||
"preference": "lower_usage_price",
|
||||
"approval_mode": "self_serve_only",
|
||||
"selected_tunables": {
|
||||
"included_tokens": "50000",
|
||||
"contract_duration_months": 3
|
||||
},
|
||||
"search_policy": {
|
||||
"min_usage_unit_price": "0.0005",
|
||||
"usage_unit_price_step": "0.0001"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "small-team-high-included-bundle",
|
||||
"name": "Small team high included bundle",
|
||||
"profile_id": "small-team",
|
||||
"model_id": "membership-plus-overage",
|
||||
"preference": "lower_usage_price",
|
||||
"approval_mode": "self_serve_only",
|
||||
"selected_tunables": {
|
||||
"included_tokens": "150000",
|
||||
"contract_duration_months": 3
|
||||
},
|
||||
"search_policy": {
|
||||
"min_usage_unit_price": "0.0005",
|
||||
"usage_unit_price_step": "0.0001"
|
||||
}
|
||||
}
|
||||
],
|
||||
"notes": "Customer-tuning pilot requests for the Coulomb hybrid overage prototype."
|
||||
}
|
||||
16
projects/coulomb-pricing/data/usage_records.json
Normal file
16
projects/coulomb-pricing/data/usage_records.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
projects/coulomb-pricing/data/value_range.json
Normal file
40
projects/coulomb-pricing/data/value_range.json
Normal file
@@ -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."
|
||||
}
|
||||
58
projects/coulomb-pricing/docs/UI-WORKFLOW.md
Normal file
58
projects/coulomb-pricing/docs/UI-WORKFLOW.md
Normal file
@@ -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=<tag-or-sha>`).
|
||||
2. **Browse upstream** — inspect `src/elements/` and `src/styles/` in
|
||||
whynot-design for an existing `<wn-*>` 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 `<wn-*>` 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 `<table class="wn-table--native">` 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`.
|
||||
3
projects/coulomb-pricing/observatory/__init__.py
Normal file
3
projects/coulomb-pricing/observatory/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Coulomb Social Economic Observatory — MVP (ledger, API, importers, simulator)."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
3
projects/coulomb-pricing/observatory/__main__.py
Normal file
3
projects/coulomb-pricing/observatory/__main__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .dashboard import main
|
||||
|
||||
raise SystemExit(main())
|
||||
12
projects/coulomb-pricing/observatory/_repo_root.py
Normal file
12
projects/coulomb-pricing/observatory/_repo_root.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
|
||||
|
||||
def ensure_repo_root_on_syspath() -> None:
|
||||
root = str(REPO_ROOT)
|
||||
if root not in sys.path:
|
||||
sys.path.insert(0, root)
|
||||
32
projects/coulomb-pricing/observatory/allocation.py
Normal file
32
projects/coulomb-pricing/observatory/allocation.py
Normal file
@@ -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,
|
||||
}
|
||||
193
projects/coulomb-pricing/observatory/api.py
Normal file
193
projects/coulomb-pricing/observatory/api.py
Normal file
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .economics import build_liquidity_summary, build_snapshot
|
||||
from .load import (
|
||||
default_data_dir,
|
||||
latest_period,
|
||||
load_governance_policy,
|
||||
load_ltv_scenarios,
|
||||
load_budget,
|
||||
load_expense_records,
|
||||
load_market_signals,
|
||||
load_membership,
|
||||
load_monthly_ledger,
|
||||
load_payment_records,
|
||||
load_pricing_models,
|
||||
load_product,
|
||||
load_tuning_requests,
|
||||
load_value_range,
|
||||
)
|
||||
from .allocation import build_cost_allocation
|
||||
from .boundary import build_boundary_validation
|
||||
from .credits import build_credit_summary, load_credit_wallets
|
||||
from .governance import build_governance_policy, build_governance_surfaces
|
||||
from .membership_analytics import build_membership_analytics
|
||||
from .pricing_context import build_cost_floor, build_market_price_view, build_value_range_view
|
||||
from .publication import build_stripe_publication_preview
|
||||
from .recommendations import build_pricing_recommendations
|
||||
from .simulator import build_pricing_simulations
|
||||
from .tuning import build_customer_tuning_pilot
|
||||
from .usage import build_usage_summary, load_usage_records
|
||||
|
||||
|
||||
def _serialize(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__}
|
||||
if isinstance(value, tuple):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, list):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _serialize(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _load_json_catalog(data_dir: Path, name: str) -> dict:
|
||||
path = data_dir / "infrastructure" / name
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def build_dashboard_payload(data_dir: Path | None = None, period: str | None = None) -> dict:
|
||||
root = data_dir or default_data_dir()
|
||||
product = load_product(root)
|
||||
budget = load_budget(root)
|
||||
models = load_pricing_models(root)
|
||||
members = load_membership(root)
|
||||
payments = load_payment_records(root)
|
||||
expenses = load_expense_records(root)
|
||||
ledger = load_monthly_ledger(root)
|
||||
target_period = period or latest_period(ledger)
|
||||
|
||||
snapshot = build_snapshot(target_period, product, models, members, payments, ledger)
|
||||
liquidity = build_liquidity_summary(budget, payments, ledger, target_period)
|
||||
payment_by_period = {record.period: record for record in payments}
|
||||
|
||||
history = []
|
||||
for month in sorted(ledger, key=lambda row: row.period):
|
||||
if month.period > target_period:
|
||||
continue
|
||||
payment = payment_by_period.get(month.period)
|
||||
net_payment = payment.net_amount if payment else Decimal("0")
|
||||
history.append(
|
||||
{
|
||||
"period": month.period,
|
||||
"active_members": month.active_members,
|
||||
"gross_revenue": month.gross_revenue,
|
||||
"infrastructure_cost": month.infrastructure_cost,
|
||||
"payment_processing_cost": month.payment_processing_cost,
|
||||
"total_platform_cost": month.total_platform_cost,
|
||||
"net_payment": net_payment,
|
||||
"net_liquidity": net_payment - month.infrastructure_cost,
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
governance_policy_raw = load_governance_policy(root)
|
||||
ltv_scenarios = load_ltv_scenarios(root)
|
||||
tuning_requests = load_tuning_requests(root)
|
||||
governance_policy = build_governance_policy(governance_policy_raw)
|
||||
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,
|
||||
usage_records=usage_records,
|
||||
scenario_catalog=ltv_scenarios,
|
||||
)
|
||||
customer_tuning = build_customer_tuning_pilot(
|
||||
snapshot,
|
||||
models,
|
||||
usage_records,
|
||||
ltv_scenarios,
|
||||
tuning_requests,
|
||||
)
|
||||
boundary_validation = build_boundary_validation(snapshot, models, usage_records)
|
||||
provider_publication = build_stripe_publication_preview(
|
||||
product,
|
||||
models,
|
||||
root,
|
||||
model_id=product.active_pricing_model_id,
|
||||
)
|
||||
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,
|
||||
boundary_validation=boundary_validation,
|
||||
customer_tuning=customer_tuning,
|
||||
provider_publication=provider_publication,
|
||||
governance_policy=governance_policy_raw,
|
||||
product=product,
|
||||
)
|
||||
governance = build_governance_surfaces(
|
||||
root,
|
||||
product,
|
||||
models,
|
||||
cost_floor,
|
||||
customer_tuning,
|
||||
provider_publication,
|
||||
governance_policy,
|
||||
)
|
||||
|
||||
return _serialize(
|
||||
{
|
||||
"design_reference": "https://claude.ai/design/p/fb2eef8c-c1fc-4c75-bff4-3782552e5511",
|
||||
"period": target_period,
|
||||
"product": product,
|
||||
"budget": budget,
|
||||
"snapshot": snapshot,
|
||||
"liquidity": liquidity,
|
||||
"history": history,
|
||||
"pricing_models": models,
|
||||
"members": members,
|
||||
"payments": payments,
|
||||
"expense_record_count": len(expenses),
|
||||
"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,
|
||||
"customer_tuning": customer_tuning,
|
||||
"boundary_validation": boundary_validation,
|
||||
"credit_wallets": credit_summary,
|
||||
"recommendations": recommendations,
|
||||
"governance": governance,
|
||||
"provider_publication": provider_publication,
|
||||
"infrastructure": {
|
||||
"domains": _load_json_catalog(root, "domains.json"),
|
||||
"virtual_servers": _load_json_catalog(root, "virtual_servers.json"),
|
||||
"stripe": _load_json_catalog(root, "stripe.json"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def payload_json(data_dir: Path | None = None, period: str | None = None) -> str:
|
||||
return json.dumps(build_dashboard_payload(data_dir, period), indent=2)
|
||||
105
projects/coulomb-pricing/observatory/boundary.py
Normal file
105
projects/coulomb-pricing/observatory/boundary.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from .models import EconomicsSnapshot, PricingModel
|
||||
|
||||
from ._repo_root import ensure_repo_root_on_syspath
|
||||
|
||||
ensure_repo_root_on_syspath()
|
||||
|
||||
from adaptive_pricing_core.boundary_engine import ( # noqa: E402
|
||||
BoundaryPolicy,
|
||||
PricingConfiguration,
|
||||
validate_pricing_configuration,
|
||||
)
|
||||
|
||||
|
||||
def _usage_unit_cost(records: list[dict[str, Any]], period: str) -> Decimal:
|
||||
period_rows = [row for row in records if row.get("period") == period]
|
||||
total_tokens = sum(Decimal(str(row.get("tokens", "0"))) for row in period_rows)
|
||||
total_cost = sum(Decimal(str(row.get("cost_eur", "0"))) for row in period_rows)
|
||||
if total_tokens <= Decimal("0"):
|
||||
return Decimal("0")
|
||||
return total_cost / total_tokens
|
||||
|
||||
|
||||
def _total_usage_units(records: list[dict[str, Any]], period: str) -> Decimal:
|
||||
return sum(Decimal(str(row.get("tokens", "0"))) for row in records if row.get("period") == period)
|
||||
|
||||
|
||||
def build_boundary_policy(snapshot: EconomicsSnapshot) -> BoundaryPolicy:
|
||||
return BoundaryPolicy(
|
||||
minimum_margin_pct=Decimal("0"),
|
||||
target_margin_pct=Decimal("15"),
|
||||
max_payment_fee_pct=Decimal("10"),
|
||||
max_expected_usage_variance_pct=Decimal("50"),
|
||||
approval_discount_pct=Decimal("10"),
|
||||
max_discount_pct=Decimal("25"),
|
||||
minimum_contract_duration_for_discount_months=3,
|
||||
minimum_turnover_multiple_for_discount=Decimal("1"),
|
||||
minimum_prepayment_months_for_discount=Decimal("1"),
|
||||
)
|
||||
|
||||
|
||||
def build_boundary_validation(
|
||||
snapshot: EconomicsSnapshot,
|
||||
models: list[PricingModel],
|
||||
usage_records: list[dict[str, Any]],
|
||||
segment: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
fee_rate_pct = Decimal("0")
|
||||
if snapshot.monthly_revenue > Decimal("0"):
|
||||
fee_rate_pct = (
|
||||
snapshot.monthly_payment_processing_cost / snapshot.monthly_revenue
|
||||
) * Decimal("100")
|
||||
|
||||
unit_cost = _usage_unit_cost(usage_records, snapshot.period)
|
||||
usage_units = _total_usage_units(usage_records, snapshot.period)
|
||||
direct_usage_cost = sum(
|
||||
Decimal(str(row.get("cost_eur", "0")))
|
||||
for row in usage_records
|
||||
if row.get("period") == snapshot.period
|
||||
)
|
||||
allocated_fixed_cost = (
|
||||
snapshot.monthly_infrastructure_cost / snapshot.active_members
|
||||
if snapshot.active_members
|
||||
else snapshot.monthly_infrastructure_cost
|
||||
)
|
||||
|
||||
policy = build_boundary_policy(snapshot)
|
||||
model_results = []
|
||||
for model in models:
|
||||
has_usage_component = any(component.kind == "usage" for component in model.charge_components)
|
||||
configuration = PricingConfiguration(
|
||||
model=model,
|
||||
segment=segment or (model.eligibility[0] if model.eligibility else None),
|
||||
expected_usage_units=usage_units if has_usage_component else Decimal("0"),
|
||||
expected_usage_variance_pct=Decimal("25"),
|
||||
allocated_fixed_cost=allocated_fixed_cost,
|
||||
direct_cost_amount=Decimal("0") if has_usage_component else direct_usage_cost,
|
||||
unit_cost=unit_cost if has_usage_component else Decimal("0"),
|
||||
payment_fee_rate_pct=fee_rate_pct,
|
||||
)
|
||||
model_results.append(validate_pricing_configuration(configuration, policy))
|
||||
|
||||
return {
|
||||
"period": snapshot.period,
|
||||
"policy": policy,
|
||||
"assumptions": {
|
||||
"segment": segment or "model-default-eligibility",
|
||||
"observed_usage_units": usage_units,
|
||||
"observed_usage_unit_cost": unit_cost,
|
||||
"direct_usage_cost_for_flat_models": direct_usage_cost,
|
||||
"allocated_fixed_cost_per_member": allocated_fixed_cost,
|
||||
"payment_fee_rate_pct": fee_rate_pct,
|
||||
"expected_usage_variance_pct": Decimal("25"),
|
||||
},
|
||||
"model_results": model_results,
|
||||
"notes": [
|
||||
"This MVP policy uses current observatory economics and conservative defaults rather than a seller-specific governance file.",
|
||||
"Self-serve discounts above 10% require approval; discounts above 25% are rejected.",
|
||||
"Term-only concessions require at least a 3-month contract to count as meaningful commitment support.",
|
||||
],
|
||||
}
|
||||
48
projects/coulomb-pricing/observatory/credits.py
Normal file
48
projects/coulomb-pricing/observatory/credits.py
Normal file
@@ -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.",
|
||||
}
|
||||
170
projects/coulomb-pricing/observatory/dashboard.py
Normal file
170
projects/coulomb-pricing/observatory/dashboard.py
Normal file
@@ -0,0 +1,170 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from .economics import build_liquidity_summary, build_snapshot
|
||||
from .load import (
|
||||
default_data_dir,
|
||||
latest_period,
|
||||
load_budget,
|
||||
load_expense_records,
|
||||
load_membership,
|
||||
load_monthly_ledger,
|
||||
load_payment_records,
|
||||
load_pricing_models,
|
||||
load_product,
|
||||
)
|
||||
from .models import EconomicsSnapshot, LiquiditySummary, MonthlyPlatformCost, PaymentRecord, PricingModel, Product
|
||||
|
||||
|
||||
def _history_rows(
|
||||
monthly_costs: list[MonthlyPlatformCost],
|
||||
payments: list[PaymentRecord],
|
||||
through_period: str,
|
||||
) -> str:
|
||||
payment_by_period = {record.period: record for record in payments}
|
||||
lines: list[str] = []
|
||||
for month in sorted(monthly_costs, key=lambda item: item.period):
|
||||
if month.period > through_period:
|
||||
continue
|
||||
payment = payment_by_period.get(month.period)
|
||||
net_payment = payment.net_amount if payment else Decimal("0")
|
||||
period_net = net_payment - month.infrastructure_cost
|
||||
lines.append(
|
||||
f"| {month.period} | {month.active_members} | {month.gross_revenue} | "
|
||||
f"{month.infrastructure_cost} | {month.payment_processing_cost} | "
|
||||
f"{month.total_platform_cost} | {period_net:.2f} |"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_dashboard(
|
||||
product: Product,
|
||||
models: list[PricingModel],
|
||||
snapshot: EconomicsSnapshot,
|
||||
liquidity: LiquiditySummary,
|
||||
monthly_costs: list[MonthlyPlatformCost],
|
||||
payments: list[PaymentRecord],
|
||||
expense_count: int,
|
||||
) -> 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
|
||||
)
|
||||
history_lines = _history_rows(monthly_costs, payments, snapshot.period)
|
||||
budget_state = (
|
||||
"over budget"
|
||||
if liquidity.remaining_budget < Decimal("0")
|
||||
else "within budget"
|
||||
)
|
||||
|
||||
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})
|
||||
|
||||
> Platform costs accrue to the operator. Customer cost-pass-through billing is
|
||||
> **not active** in MVP — members pay subscription only. All totals are computed
|
||||
> programmatically from expense and payment record ledgers ({expense_count} expense
|
||||
> records).
|
||||
|
||||
## Key Metrics (current period)
|
||||
|
||||
| Metric | Value |
|
||||
|--------|------:|
|
||||
| Active members | {snapshot.active_members} |
|
||||
| Member payments (gross) | {snapshot.monthly_revenue} {snapshot.currency} |
|
||||
| Infrastructure cost | {snapshot.monthly_infrastructure_cost} {snapshot.currency} |
|
||||
| Payment processing cost | {snapshot.monthly_payment_processing_cost} {snapshot.currency} |
|
||||
| Total platform cost | {snapshot.monthly_total_platform_cost} {snapshot.currency} |
|
||||
| Platform cost per member | {snapshot.cost_per_member} {snapshot.currency} |
|
||||
| Period gross margin | {snapshot.gross_margin} {snapshot.currency} |
|
||||
| Period gross margin % | {snapshot.gross_margin_pct}% |
|
||||
| Period net liquidity | {snapshot.period_net_liquidity} {snapshot.currency} ({snapshot.liquidity_status}) |
|
||||
|
||||
_Period net liquidity = net member payments − infrastructure cost (processing fees already netted from payments)._
|
||||
_Revenue source: {snapshot.revenue_source}_
|
||||
|
||||
## Liquidity & Budget (through {liquidity.through_period})
|
||||
|
||||
| Metric | Value |
|
||||
|--------|------:|
|
||||
| Initial budget | {liquidity.initial_budget} {liquidity.currency} |
|
||||
| Cumulative member payments (net) | {liquidity.cumulative_member_payments} {liquidity.currency} |
|
||||
| Cumulative infrastructure cost | {liquidity.cumulative_infrastructure_cost} {liquidity.currency} |
|
||||
| Cumulative payment processing | {liquidity.cumulative_payment_processing_cost} {liquidity.currency} |
|
||||
| Cumulative total platform cost | {liquidity.cumulative_total_platform_cost} {liquidity.currency} |
|
||||
| Cumulative net liquidity | {liquidity.cumulative_net_liquidity} {liquidity.currency} ({liquidity.liquidity_status}) |
|
||||
| Remaining budget | {liquidity.remaining_budget} {liquidity.currency} ({budget_state}) |
|
||||
| Months tracked | {liquidity.months_tracked} |
|
||||
|
||||
## Monthly History
|
||||
|
||||
| Period | Members | Gross revenue | Infrastructure | Processing | Total platform | Net liquidity |
|
||||
|--------|--------:|--------------:|---------------:|-----------:|---------------:|--------------:|
|
||||
{history_lines}
|
||||
|
||||
## Pricing Model Registry
|
||||
|
||||
| ID | Name | Type | Status |
|
||||
|----|------|------|--------|
|
||||
{registry_lines}
|
||||
|
||||
## Registries Loaded
|
||||
|
||||
- Product model (`data/product.json`)
|
||||
- Budget (`data/budget.json`)
|
||||
- Expense records (`data/expense_records.json`) — source of truth for costs
|
||||
- Infrastructure catalog (`data/infrastructure/`) — domain, VPS, and Stripe reference data
|
||||
- Payment records (`data/payment_records.json`)
|
||||
- Membership (`data/membership.json`)
|
||||
- Requirements (`REQUIREMENTS.md`)
|
||||
"""
|
||||
|
||||
|
||||
def generate_dashboard(data_dir: Path | None = None, period: str | None = None) -> str:
|
||||
root = data_dir or default_data_dir()
|
||||
product = load_product(root)
|
||||
budget = load_budget(root)
|
||||
models = load_pricing_models(root)
|
||||
members = load_membership(root)
|
||||
payments = load_payment_records(root)
|
||||
expenses = load_expense_records(root)
|
||||
monthly_costs = load_monthly_ledger(root)
|
||||
target_period = period or latest_period(monthly_costs)
|
||||
|
||||
snapshot = build_snapshot(target_period, product, models, members, payments, monthly_costs)
|
||||
liquidity = build_liquidity_summary(budget, payments, monthly_costs, target_period)
|
||||
return render_dashboard(
|
||||
product, models, snapshot, liquidity, monthly_costs, payments, len(expenses)
|
||||
)
|
||||
|
||||
|
||||
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())
|
||||
155
projects/coulomb-pricing/observatory/economics.py
Normal file
155
projects/coulomb-pricing/observatory/economics.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from .models import (
|
||||
Budget,
|
||||
EconomicsSnapshot,
|
||||
LiquidityStatus,
|
||||
LiquiditySummary,
|
||||
MembershipRecord,
|
||||
MonthlyPlatformCost,
|
||||
PaymentRecord,
|
||||
PricingModel,
|
||||
Product,
|
||||
)
|
||||
|
||||
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 liquidity_status_for(net: Decimal) -> LiquidityStatus:
|
||||
if net < Decimal("0"):
|
||||
return "burning"
|
||||
if net > Decimal("0"):
|
||||
return "generating"
|
||||
return "neutral"
|
||||
|
||||
|
||||
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 payment_for_period(period: str, payments: list[PaymentRecord]) -> PaymentRecord | None:
|
||||
for record in payments:
|
||||
if record.period == period:
|
||||
return record
|
||||
return None
|
||||
|
||||
|
||||
def estimate_monthly_revenue(
|
||||
period: str,
|
||||
product: Product,
|
||||
models: list[PricingModel],
|
||||
members: list[MembershipRecord],
|
||||
payments: list[PaymentRecord],
|
||||
monthly_costs: list[MonthlyPlatformCost],
|
||||
) -> tuple[Decimal, Decimal, str]:
|
||||
recorded = payment_for_period(period, payments)
|
||||
if recorded:
|
||||
return recorded.gross_amount, recorded.net_amount, recorded.source
|
||||
|
||||
month = next((item for item in monthly_costs if item.period == period), None)
|
||||
if month and month.gross_revenue > 0:
|
||||
return month.gross_revenue, month.gross_revenue, "derived_from_ledger"
|
||||
|
||||
model = active_pricing_model(models, product)
|
||||
count = active_members(members)
|
||||
gross = model.access_fee_amount * count
|
||||
return gross, gross, "derived_from_membership"
|
||||
|
||||
|
||||
def periods_through(target: str, monthly_costs: list[MonthlyPlatformCost]) -> list[str]:
|
||||
return sorted(item.period for item in monthly_costs if item.period <= target)
|
||||
|
||||
|
||||
def build_liquidity_summary(
|
||||
budget: Budget,
|
||||
payments: list[PaymentRecord],
|
||||
monthly_costs: list[MonthlyPlatformCost],
|
||||
through_period: str,
|
||||
) -> LiquiditySummary:
|
||||
tracked = periods_through(through_period, monthly_costs)
|
||||
cost_by_period = {item.period: item for item in monthly_costs}
|
||||
|
||||
cumulative_payments = Decimal("0")
|
||||
cumulative_infrastructure = Decimal("0")
|
||||
cumulative_processing = Decimal("0")
|
||||
for period in tracked:
|
||||
month = cost_by_period[period]
|
||||
cumulative_infrastructure += month.infrastructure_cost
|
||||
cumulative_processing += month.payment_processing_cost
|
||||
payment = payment_for_period(period, payments)
|
||||
cumulative_payments += payment.net_amount if payment else Decimal("0")
|
||||
|
||||
cumulative_net = _quantize(cumulative_payments - cumulative_infrastructure)
|
||||
remaining = _quantize(budget.initial_budget + cumulative_net)
|
||||
cumulative_total = _quantize(cumulative_infrastructure + cumulative_processing)
|
||||
|
||||
return LiquiditySummary(
|
||||
currency=budget.currency,
|
||||
through_period=through_period,
|
||||
initial_budget=budget.initial_budget,
|
||||
cumulative_member_payments=_quantize(cumulative_payments),
|
||||
cumulative_infrastructure_cost=_quantize(cumulative_infrastructure),
|
||||
cumulative_payment_processing_cost=_quantize(cumulative_processing),
|
||||
cumulative_total_platform_cost=cumulative_total,
|
||||
cumulative_net_liquidity=cumulative_net,
|
||||
remaining_budget=remaining,
|
||||
liquidity_status=liquidity_status_for(cumulative_net),
|
||||
months_tracked=len(tracked),
|
||||
)
|
||||
|
||||
|
||||
def build_snapshot(
|
||||
period: str,
|
||||
product: Product,
|
||||
models: list[PricingModel],
|
||||
members: list[MembershipRecord],
|
||||
payments: list[PaymentRecord],
|
||||
monthly_costs: list[MonthlyPlatformCost],
|
||||
) -> EconomicsSnapshot:
|
||||
month = next(item for item in monthly_costs if item.period == period)
|
||||
count = month.active_members if month.active_members else active_members(members)
|
||||
gross_revenue, net_revenue, revenue_source = estimate_monthly_revenue(
|
||||
period, product, models, members, payments, monthly_costs
|
||||
)
|
||||
infrastructure = month.infrastructure_cost
|
||||
processing = month.payment_processing_cost
|
||||
total_platform = month.total_platform_cost
|
||||
cost_per_member = _quantize(total_platform / count) if count else Decimal("0.00")
|
||||
gross_margin = _quantize(gross_revenue - total_platform)
|
||||
margin_pct = (
|
||||
_quantize((gross_margin / gross_revenue) * Decimal("100"), PCTPLACES)
|
||||
if gross_revenue
|
||||
else Decimal("-100.0") if total_platform else Decimal("0.0")
|
||||
)
|
||||
period_net = _quantize(net_revenue - infrastructure)
|
||||
|
||||
return EconomicsSnapshot(
|
||||
period=period,
|
||||
currency=product.currency,
|
||||
active_members=count,
|
||||
monthly_revenue=_quantize(gross_revenue),
|
||||
monthly_infrastructure_cost=infrastructure,
|
||||
monthly_payment_processing_cost=processing,
|
||||
monthly_total_platform_cost=total_platform,
|
||||
cost_per_member=cost_per_member,
|
||||
gross_margin=gross_margin,
|
||||
gross_margin_pct=margin_pct,
|
||||
pricing_model_count=len(models),
|
||||
revenue_source=revenue_source,
|
||||
period_net_liquidity=period_net,
|
||||
liquidity_status=liquidity_status_for(period_net),
|
||||
)
|
||||
772
projects/coulomb-pricing/observatory/governance.py
Normal file
772
projects/coulomb-pricing/observatory/governance.py
Normal file
@@ -0,0 +1,772 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ._repo_root import ensure_repo_root_on_syspath
|
||||
from .publication import default_stripe_state_path, load_stripe_publication_state
|
||||
|
||||
ensure_repo_root_on_syspath()
|
||||
|
||||
from adaptive_pricing_core.governance import ( # noqa: E402
|
||||
ApprovalRequirement,
|
||||
GovernanceAssessment,
|
||||
GovernancePolicy,
|
||||
GovernanceRisk,
|
||||
HealthCheck,
|
||||
SafeTuningContract,
|
||||
SafeTuningExample,
|
||||
SafeTuningParameter,
|
||||
SellerRecommendation,
|
||||
SupportingObservation,
|
||||
governance_policy_from_dict,
|
||||
)
|
||||
|
||||
TWOPLACES = Decimal("0.01")
|
||||
|
||||
|
||||
def _serialize(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__}
|
||||
if isinstance(value, tuple):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, list):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _serialize(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _money(value: Decimal) -> Decimal:
|
||||
return value.quantize(TWOPLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _decimal(value: Decimal | str | int | float | None) -> Decimal:
|
||||
if value in (None, ""):
|
||||
return Decimal("0")
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def build_governance_policy(raw: dict[str, Any]) -> GovernancePolicy:
|
||||
return governance_policy_from_dict(raw)
|
||||
|
||||
|
||||
def _publication_assessment(
|
||||
product: Any,
|
||||
provider_publication: dict[str, Any],
|
||||
policy: GovernancePolicy,
|
||||
) -> GovernanceAssessment:
|
||||
artifact_counts = provider_publication.get("artifact_counts", {})
|
||||
approximate = int(artifact_counts.get("approximate", 0))
|
||||
unsupported = int(artifact_counts.get("unsupported", 0))
|
||||
drift_count = len(provider_publication.get("plan", {}).get("drift", []))
|
||||
model_id = provider_publication.get("model_id")
|
||||
|
||||
approvals: list[ApprovalRequirement] = []
|
||||
risks: list[GovernanceRisk] = []
|
||||
observations = [
|
||||
SupportingObservation(
|
||||
id="provider-artifact-counts",
|
||||
title="Provider mapping counts",
|
||||
summary=(
|
||||
f"{artifact_counts.get('exact', 0)} exact, "
|
||||
f"{approximate} approximate, {unsupported} unsupported artifacts."
|
||||
),
|
||||
source_ref="provider_publication.artifact_counts",
|
||||
),
|
||||
SupportingObservation(
|
||||
id="provider-drift-count",
|
||||
title="Provider drift findings",
|
||||
summary=f"{drift_count} provider drift findings in the current publication preview.",
|
||||
source_ref="provider_publication.plan.drift",
|
||||
),
|
||||
]
|
||||
|
||||
if model_id != getattr(product, "active_pricing_model_id", None):
|
||||
approvals.append(
|
||||
ApprovalRequirement(
|
||||
id="candidate-rollout-approval",
|
||||
title="Candidate model rollout approval",
|
||||
approver_role=policy.default_approver_role,
|
||||
reason="The provider publication target is not the currently active product pricing model.",
|
||||
)
|
||||
)
|
||||
risks.append(
|
||||
GovernanceRisk(
|
||||
id="candidate-rollout",
|
||||
severity="medium",
|
||||
summary="The publication target is a candidate model rather than the active production model.",
|
||||
mitigation="Keep the change in shadow state or route through explicit rollout approval.",
|
||||
)
|
||||
)
|
||||
|
||||
if approximate > 0:
|
||||
risks.append(
|
||||
GovernanceRisk(
|
||||
id="approximate-provider-mapping",
|
||||
severity="medium",
|
||||
summary="Some provider mappings are approximate rather than fully executable in Stripe.",
|
||||
mitigation="Supplement Stripe publication with operational contract logic or human review.",
|
||||
)
|
||||
)
|
||||
if policy.require_approval_for_approximate_provider_mapping:
|
||||
approvals.append(
|
||||
ApprovalRequirement(
|
||||
id="approximate-provider-approval",
|
||||
title="Approximate provider mapping approval",
|
||||
approver_role=policy.default_approver_role,
|
||||
reason="Customer-visible rollout would rely on approximate Stripe mappings.",
|
||||
)
|
||||
)
|
||||
|
||||
if unsupported > 0:
|
||||
risks.append(
|
||||
GovernanceRisk(
|
||||
id="unsupported-provider-artifacts",
|
||||
severity="high",
|
||||
summary="Some pricing artifacts cannot be executed in Stripe by the current publisher.",
|
||||
mitigation="Block rollout until the unsupported artifacts are removed or implemented.",
|
||||
)
|
||||
)
|
||||
|
||||
if drift_count > 0:
|
||||
risks.append(
|
||||
GovernanceRisk(
|
||||
id="provider-drift",
|
||||
severity="high",
|
||||
summary="The provider shadow state differs from the desired pricing definition.",
|
||||
mitigation="Reconcile drift before rollout or retire the unmanaged artifact state.",
|
||||
)
|
||||
)
|
||||
|
||||
if unsupported > 0 and policy.block_unsupported_provider_artifacts:
|
||||
return GovernanceAssessment(
|
||||
decision="blocked",
|
||||
summary="Blocked: unsupported Stripe mappings remain in the provider publication plan.",
|
||||
approvals=tuple(approvals),
|
||||
risks=tuple(risks),
|
||||
supporting_observations=tuple(observations),
|
||||
notes=(
|
||||
"Shadow-state publication may still proceed, but customer-visible execution should remain blocked.",
|
||||
),
|
||||
)
|
||||
|
||||
if drift_count > 0 and policy.drift_blocks_execution:
|
||||
return GovernanceAssessment(
|
||||
decision="blocked",
|
||||
summary="Blocked: provider drift must be reconciled before execution.",
|
||||
approvals=tuple(approvals),
|
||||
risks=tuple(risks),
|
||||
supporting_observations=tuple(observations),
|
||||
)
|
||||
|
||||
if approvals:
|
||||
return GovernanceAssessment(
|
||||
decision="approval_required",
|
||||
summary="Approval required before customer-visible execution.",
|
||||
approvals=tuple(approvals),
|
||||
risks=tuple(risks),
|
||||
supporting_observations=tuple(observations),
|
||||
)
|
||||
|
||||
return GovernanceAssessment(
|
||||
decision="proceed",
|
||||
summary="Provider publication is execution-ready under the current governance policy.",
|
||||
approvals=(),
|
||||
risks=tuple(risks),
|
||||
supporting_observations=tuple(observations),
|
||||
)
|
||||
|
||||
|
||||
def _risk_from_boundary_result(result: dict[str, Any]) -> GovernanceRisk:
|
||||
severity = "high" if result["status"] == "fail" else "medium"
|
||||
return GovernanceRisk(
|
||||
id=result["id"],
|
||||
severity=severity,
|
||||
summary=result["summary"],
|
||||
mitigation=result.get("suggested_action") or result["reason"],
|
||||
)
|
||||
|
||||
|
||||
def build_pricing_recommendation_workflow(
|
||||
cost_floor: dict[str, Any],
|
||||
value_range: dict[str, Any],
|
||||
market_price: dict[str, Any],
|
||||
simulations: dict[str, Any],
|
||||
usage_summary: dict[str, Any],
|
||||
*,
|
||||
boundary_validation: dict[str, Any] | None = None,
|
||||
customer_tuning: dict[str, Any] | None = None,
|
||||
provider_publication: dict[str, Any] | None = None,
|
||||
governance_policy: GovernancePolicy | None = None,
|
||||
product: Any | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
policy = governance_policy or GovernancePolicy(policy_id="default-governance-policy")
|
||||
publication_assessment = _publication_assessment(
|
||||
product,
|
||||
provider_publication or {},
|
||||
policy,
|
||||
) if provider_publication and product is not None else GovernanceAssessment(
|
||||
decision="proceed",
|
||||
summary="No provider publication assessment was supplied.",
|
||||
approvals=(),
|
||||
risks=(),
|
||||
supporting_observations=(),
|
||||
)
|
||||
|
||||
recommendations: list[SellerRecommendation] = []
|
||||
margin_pct = _decimal(cost_floor.get("gross_margin_pct"))
|
||||
active_price = _decimal(value_range.get("current_price_eur"))
|
||||
cost_per_member = _decimal(cost_floor.get("cost_per_member"))
|
||||
ai_spend = _decimal(usage_summary.get("total_ai_spend_eur"))
|
||||
|
||||
if margin_pct < Decimal("10"):
|
||||
approvals = []
|
||||
risks = [
|
||||
GovernanceRisk(
|
||||
id="customer-communication",
|
||||
severity="medium",
|
||||
summary="Changing the membership price affects existing customer expectations.",
|
||||
mitigation=(
|
||||
f"Route communication through {policy.communication_owner_role} and honor "
|
||||
f"{policy.customer_notice_days}-day notice if price increases are tested."
|
||||
),
|
||||
)
|
||||
]
|
||||
if policy.require_approval_for_price_change:
|
||||
approvals.append(
|
||||
ApprovalRequirement(
|
||||
id="price-change-approval",
|
||||
title="Price change approval",
|
||||
approver_role=policy.default_approver_role,
|
||||
reason="The recommendation would change customer-visible pricing.",
|
||||
)
|
||||
)
|
||||
recommendations.append(
|
||||
SellerRecommendation(
|
||||
id="margin-pressure",
|
||||
recommendation_type="model_change",
|
||||
priority="high",
|
||||
title="Margin below 10%",
|
||||
rationale=f"Gross margin is {margin_pct}% at the current price.",
|
||||
suggested_action="Review infrastructure cost or test a higher access fee within value-range bands.",
|
||||
confidence=Decimal("0.92"),
|
||||
governance=GovernanceAssessment(
|
||||
decision="approval_required" if approvals else "proceed",
|
||||
summary="Approval required before a customer-visible price change." if approvals else "Margin remediation can proceed to the next workflow stage.",
|
||||
approvals=tuple(approvals),
|
||||
risks=tuple(risks),
|
||||
supporting_observations=(
|
||||
SupportingObservation(
|
||||
id="gross-margin",
|
||||
title="Current gross margin",
|
||||
summary=f"Observed gross margin is {margin_pct}%.",
|
||||
source_ref="cost_floor.gross_margin_pct",
|
||||
value=str(margin_pct),
|
||||
),
|
||||
SupportingObservation(
|
||||
id="cost-per-member",
|
||||
title="Observed cost per member",
|
||||
summary=f"Current cost per member is {cost_per_member} EUR.",
|
||||
source_ref="cost_floor.cost_per_member",
|
||||
value=str(cost_per_member),
|
||||
),
|
||||
),
|
||||
),
|
||||
risks=tuple(risks),
|
||||
supporting_observations=(
|
||||
SupportingObservation(
|
||||
id="active-price",
|
||||
title="Current list price",
|
||||
summary=f"Current list price is {active_price} EUR.",
|
||||
source_ref="value_range.current_price_eur",
|
||||
value=str(active_price),
|
||||
),
|
||||
),
|
||||
related_model_ids=((product.active_pricing_model_id,) if product is not None else ()),
|
||||
)
|
||||
)
|
||||
|
||||
if ai_spend > Decimal("0") and cost_per_member > Decimal("0"):
|
||||
ai_ratio = _money((ai_spend / cost_per_member) * Decimal("100"))
|
||||
if ai_ratio > Decimal("15"):
|
||||
best = simulations.get("best_ltv_scenario_id") or simulations.get("best_margin_scenario_id")
|
||||
recommendations.append(
|
||||
SellerRecommendation(
|
||||
id="usage-pricing-signal",
|
||||
recommendation_type="simulation",
|
||||
priority="medium",
|
||||
title="AI cost becoming material",
|
||||
rationale=f"AI spend is {ai_ratio}% of current cost per member.",
|
||||
suggested_action=f"Evaluate hybrid model '{best}' in the simulator before customer-visible changes.",
|
||||
confidence=Decimal("0.78"),
|
||||
governance=GovernanceAssessment(
|
||||
decision="proceed",
|
||||
summary="Simulation work can proceed without approval.",
|
||||
approvals=(),
|
||||
risks=(
|
||||
GovernanceRisk(
|
||||
id="usage-forecast-uncertainty",
|
||||
severity="medium",
|
||||
summary="Usage-cost signals come from a small current sample.",
|
||||
mitigation="Keep the next step at simulation or controlled pilot scope until more usage data is available.",
|
||||
),
|
||||
),
|
||||
supporting_observations=(
|
||||
SupportingObservation(
|
||||
id="ai-ratio",
|
||||
title="AI cost ratio",
|
||||
summary=f"AI cost represents {ai_ratio}% of current cost per member.",
|
||||
source_ref="usage.total_ai_spend_eur",
|
||||
value=str(ai_ratio),
|
||||
),
|
||||
),
|
||||
),
|
||||
risks=(
|
||||
GovernanceRisk(
|
||||
id="pilot-scope",
|
||||
severity="low",
|
||||
summary="Hybrid pricing adds operational complexity before rollout automation is mature.",
|
||||
mitigation="Restrict the recommendation to simulation or small pilot scope.",
|
||||
),
|
||||
),
|
||||
supporting_observations=(
|
||||
SupportingObservation(
|
||||
id="best-ltv-scenario",
|
||||
title="Best LTV scenario",
|
||||
summary=f"Current simulator best LTV scenario is {best}.",
|
||||
source_ref="pricing_simulations.best_ltv_scenario_id",
|
||||
value=str(best),
|
||||
),
|
||||
),
|
||||
related_model_ids=(str(best),) if best else (),
|
||||
)
|
||||
)
|
||||
|
||||
accepted_tuning_ids = tuple((customer_tuning or {}).get("accepted_request_ids", []))
|
||||
if accepted_tuning_ids:
|
||||
request = next(
|
||||
(
|
||||
item
|
||||
for item in (customer_tuning or {}).get("requests", [])
|
||||
if item.get("id") == accepted_tuning_ids[0]
|
||||
),
|
||||
None,
|
||||
)
|
||||
outcome = request.get("result", {}) if request else {}
|
||||
approvals = list(publication_assessment.approvals)
|
||||
risks = list(publication_assessment.risks)
|
||||
if not policy.customer_visible_tuning_enabled:
|
||||
approvals.append(
|
||||
ApprovalRequirement(
|
||||
id="customer-visible-tuning-disabled",
|
||||
title="Customer-visible tuning enablement approval",
|
||||
approver_role=policy.default_approver_role,
|
||||
reason="The governance policy still treats customer-visible tuning as pilot-only.",
|
||||
)
|
||||
)
|
||||
recommendations.append(
|
||||
SellerRecommendation(
|
||||
id="pilot-tuning-offer",
|
||||
recommendation_type="model_change",
|
||||
priority="medium",
|
||||
title="Pilot a seller-safe tuned hybrid offer",
|
||||
rationale=(
|
||||
f"Request '{accepted_tuning_ids[0]}' produced an accepted tuned configuration with "
|
||||
f"LTV {outcome.get('average_comparable_customer_lifetime_value')} EUR."
|
||||
),
|
||||
suggested_action="Use the accepted tuning result as a controlled seller-assisted offer, not a self-serve rollout.",
|
||||
confidence=Decimal("0.81"),
|
||||
governance=GovernanceAssessment(
|
||||
decision="approval_required",
|
||||
summary="Approval required before exposing tuned pricing to customers.",
|
||||
approvals=tuple(approvals),
|
||||
risks=tuple(risks),
|
||||
supporting_observations=(
|
||||
SupportingObservation(
|
||||
id="accepted-tuning-request",
|
||||
title="Accepted tuning request",
|
||||
summary=outcome.get("explanation", "A tuned configuration passed the current solver and LTV checks."),
|
||||
source_ref=f"customer_tuning.requests[{accepted_tuning_ids[0]}]",
|
||||
),
|
||||
),
|
||||
notes=("The current policy exposes tuned pricing only through seller-assisted workflows.",),
|
||||
),
|
||||
risks=tuple(risks),
|
||||
supporting_observations=(
|
||||
SupportingObservation(
|
||||
id="tuning-request-id",
|
||||
title="Accepted tuning request id",
|
||||
summary=f"Accepted request: {accepted_tuning_ids[0]}.",
|
||||
source_ref="customer_tuning.accepted_request_ids",
|
||||
),
|
||||
),
|
||||
related_model_ids=(request.get("model_id"),) if request else (),
|
||||
related_profile_ids=(request.get("profile_id"),) if request else (),
|
||||
)
|
||||
)
|
||||
|
||||
if market_price.get("market_high_eur") and active_price < _decimal(market_price.get("market_high_eur")):
|
||||
headroom = _decimal(value_range.get("aggregate_high_eur")) - active_price
|
||||
if headroom > Decimal("5"):
|
||||
active_experiment_count = int(policy.metadata.get("active_experiment_count", 0))
|
||||
experiment_decision = "proceed" if active_experiment_count < policy.max_active_experiments else "blocked"
|
||||
approvals = ()
|
||||
risks = ()
|
||||
if experiment_decision == "blocked":
|
||||
risks = (
|
||||
GovernanceRisk(
|
||||
id="experiment-capacity",
|
||||
severity="medium",
|
||||
summary="The configured experiment capacity is exhausted.",
|
||||
mitigation="Close or review existing experiments before starting another one.",
|
||||
),
|
||||
)
|
||||
recommendations.append(
|
||||
SellerRecommendation(
|
||||
id="value-headroom",
|
||||
recommendation_type="research",
|
||||
priority="low",
|
||||
title="Value headroom above list price",
|
||||
rationale=f"Aggregate value band high is {value_range.get('aggregate_high_eur')} EUR vs {active_price} EUR list.",
|
||||
suggested_action="Run a staged price experiment within the solo-builder segment band.",
|
||||
confidence=Decimal("0.63"),
|
||||
governance=GovernanceAssessment(
|
||||
decision=experiment_decision,
|
||||
summary=(
|
||||
"Experiment capacity is available."
|
||||
if experiment_decision == "proceed"
|
||||
else "Blocked until experiment capacity is freed."
|
||||
),
|
||||
approvals=approvals,
|
||||
risks=risks,
|
||||
supporting_observations=(
|
||||
SupportingObservation(
|
||||
id="experiment-capacity",
|
||||
title="Experiment capacity",
|
||||
summary=f"{active_experiment_count} active experiments vs cap {policy.max_active_experiments}.",
|
||||
source_ref="governance_policy.metadata.active_experiment_count",
|
||||
),
|
||||
),
|
||||
),
|
||||
risks=risks,
|
||||
supporting_observations=(
|
||||
SupportingObservation(
|
||||
id="value-headroom-evidence",
|
||||
title="Value headroom estimate",
|
||||
summary=f"Observed value headroom is {headroom} EUR.",
|
||||
source_ref="value_range.aggregate_high_eur",
|
||||
value=str(headroom),
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if provider_publication and product is not None and publication_assessment.decision != "proceed":
|
||||
recommendations.append(
|
||||
SellerRecommendation(
|
||||
id="execution-governance-gate",
|
||||
recommendation_type="execution",
|
||||
priority="medium",
|
||||
title="Keep provider execution behind governance gates",
|
||||
rationale=publication_assessment.summary,
|
||||
suggested_action="Resolve publication blockers or route the rollout through explicit approval before customer-visible execution.",
|
||||
confidence=Decimal("0.88"),
|
||||
governance=publication_assessment,
|
||||
risks=publication_assessment.risks,
|
||||
supporting_observations=publication_assessment.supporting_observations,
|
||||
related_model_ids=(provider_publication.get("model_id"),),
|
||||
notes=publication_assessment.notes,
|
||||
)
|
||||
)
|
||||
|
||||
if not recommendations:
|
||||
recommendations.append(
|
||||
SellerRecommendation(
|
||||
id="hold-course",
|
||||
recommendation_type="research",
|
||||
priority="low",
|
||||
title="Hold current pricing",
|
||||
rationale="No urgent margin, usage, or competitive signals currently justify a governed change workflow.",
|
||||
suggested_action="Continue observatory tracking and re-run after the next ledger period.",
|
||||
confidence=Decimal("0.71"),
|
||||
governance=GovernanceAssessment(
|
||||
decision="proceed",
|
||||
summary="No immediate governed pricing action is required.",
|
||||
approvals=(),
|
||||
risks=(),
|
||||
supporting_observations=(),
|
||||
),
|
||||
risks=(),
|
||||
supporting_observations=(),
|
||||
)
|
||||
)
|
||||
|
||||
return _serialize(recommendations)
|
||||
|
||||
|
||||
def build_safe_tuning_contracts(
|
||||
models: list[Any],
|
||||
customer_tuning: dict[str, Any],
|
||||
policy: GovernancePolicy,
|
||||
active_model_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
tradeoff_lexicon = {
|
||||
"lower_included_usage": "Lower included usage can unlock lower variable pricing while protecting seller economics.",
|
||||
"higher_included_usage": "Higher included usage increases predictability but may require stronger commitment or a higher usage price.",
|
||||
"lower_usage_price": "Lower usage price is only offered when the solver can preserve seller-side LTV.",
|
||||
"higher_usage_price": "Higher usage price may be the trade-off for keeping monthly access fees and flexibility unchanged.",
|
||||
"longer_contract_duration": "Longer commitment can support better unit economics.",
|
||||
"minimum_monthly_turnover": "Minimum turnover protects the seller against under-utilization risk.",
|
||||
"prepayment": "Prepayment reduces default risk and can support better pricing.",
|
||||
"guaranteed_platform_fee": "A guaranteed fee protects base platform economics.",
|
||||
"customer_funded_onboarding": "Customer-funded onboarding offsets seller setup effort.",
|
||||
"reduced_cancellation_flexibility": "Reduced cancellation flexibility supports stronger lifetime value assumptions.",
|
||||
}
|
||||
|
||||
requests_by_model: dict[str, list[dict[str, Any]]] = {}
|
||||
for item in customer_tuning.get("requests", []):
|
||||
if item.get("model_id"):
|
||||
requests_by_model.setdefault(item["model_id"], []).append(item)
|
||||
|
||||
contracts: list[SafeTuningContract] = []
|
||||
for model in models:
|
||||
tunables = [parameter for parameter in model.tunable_parameters if parameter.parameter_class == "customer_tunable"]
|
||||
if not tunables:
|
||||
continue
|
||||
examples: list[SafeTuningExample] = []
|
||||
for item in requests_by_model.get(model.id, []):
|
||||
outcome = item.get("result", {})
|
||||
decision = outcome.get("decision", item.get("decision", "rejected"))
|
||||
customer_visible = (
|
||||
policy.customer_visible_tuning_enabled
|
||||
and (not policy.customer_visible_tuning_requires_active_model or model.id == active_model_id)
|
||||
and decision == "accepted"
|
||||
)
|
||||
examples.append(
|
||||
SafeTuningExample(
|
||||
id=item["id"],
|
||||
title=item["name"],
|
||||
outcome=decision,
|
||||
summary=outcome.get("explanation", "No tuning explanation available."),
|
||||
customer_message=(
|
||||
"This configuration is available through a seller-assisted pilot."
|
||||
if decision == "accepted"
|
||||
else "This configuration is outside the current safe self-serve range."
|
||||
),
|
||||
visible_to_customer=customer_visible,
|
||||
tradeoffs=tuple(outcome.get("tradeoffs", [])),
|
||||
)
|
||||
)
|
||||
|
||||
contracts.append(
|
||||
SafeTuningContract(
|
||||
model_id=model.id,
|
||||
model_name=model.name,
|
||||
mode=(
|
||||
"customer_visible"
|
||||
if policy.customer_visible_tuning_enabled and model.id == active_model_id
|
||||
else "pilot_only"
|
||||
),
|
||||
customer_visible=(
|
||||
policy.customer_visible_tuning_enabled
|
||||
and (not policy.customer_visible_tuning_requires_active_model or model.id == active_model_id)
|
||||
),
|
||||
tunable_parameters=tuple(
|
||||
SafeTuningParameter(
|
||||
key=parameter.key,
|
||||
label=parameter.key.replace("_", " ").title(),
|
||||
description=parameter.description,
|
||||
data_type=parameter.data_type,
|
||||
default_value=parameter.default_value,
|
||||
min_value=str(parameter.min_value) if parameter.min_value is not None else None,
|
||||
max_value=str(parameter.max_value) if parameter.max_value is not None else None,
|
||||
)
|
||||
for parameter in tunables
|
||||
),
|
||||
tradeoff_lexicon=tradeoff_lexicon,
|
||||
examples=tuple(examples),
|
||||
notes=(
|
||||
"Customer-visible tuning remains policy-governed even when the solver can find a safe configuration.",
|
||||
"Examples describe current pilot outcomes and should not be treated as automatically executable offers.",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return _serialize(contracts)
|
||||
|
||||
|
||||
def build_governance_health_checks(
|
||||
cost_floor: dict[str, Any],
|
||||
customer_tuning: dict[str, Any],
|
||||
provider_publication: dict[str, Any],
|
||||
policy: GovernancePolicy,
|
||||
) -> list[dict[str, Any]]:
|
||||
checks: list[HealthCheck] = []
|
||||
margin_pct = _decimal(cost_floor.get("gross_margin_pct"))
|
||||
if margin_pct < Decimal("0"):
|
||||
checks.append(
|
||||
HealthCheck(
|
||||
id="margin-health",
|
||||
title="Margin health",
|
||||
status="fail",
|
||||
summary="Current gross margin is negative.",
|
||||
value=str(margin_pct),
|
||||
threshold="0",
|
||||
suggested_action="Do not execute customer-visible pricing changes without correcting the current economics gap.",
|
||||
)
|
||||
)
|
||||
elif margin_pct < Decimal("10"):
|
||||
checks.append(
|
||||
HealthCheck(
|
||||
id="margin-health",
|
||||
title="Margin health",
|
||||
status="warn",
|
||||
summary="Current gross margin is below the 10% operating comfort threshold.",
|
||||
value=str(margin_pct),
|
||||
threshold="10",
|
||||
suggested_action="Prioritize simulation and model-change recommendations before expansion.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks.append(
|
||||
HealthCheck(
|
||||
id="margin-health",
|
||||
title="Margin health",
|
||||
status="pass",
|
||||
summary="Current gross margin is within the configured comfort threshold.",
|
||||
value=str(margin_pct),
|
||||
threshold="10",
|
||||
)
|
||||
)
|
||||
|
||||
artifact_counts = provider_publication.get("artifact_counts", {})
|
||||
unsupported = int(artifact_counts.get("unsupported", 0))
|
||||
drift_count = len(provider_publication.get("plan", {}).get("drift", []))
|
||||
approximate = int(artifact_counts.get("approximate", 0))
|
||||
if unsupported > 0 or drift_count > 0:
|
||||
checks.append(
|
||||
HealthCheck(
|
||||
id="provider-execution-health",
|
||||
title="Provider execution health",
|
||||
status="fail",
|
||||
summary="Stripe execution preview is not rollout-ready under the current governance policy.",
|
||||
value=f"{unsupported} unsupported / {drift_count} drift",
|
||||
threshold="0 unsupported / 0 drift",
|
||||
suggested_action="Resolve unsupported mappings or drift before attempting execution.",
|
||||
)
|
||||
)
|
||||
elif approximate > 0:
|
||||
checks.append(
|
||||
HealthCheck(
|
||||
id="provider-execution-health",
|
||||
title="Provider execution health",
|
||||
status="warn",
|
||||
summary="Stripe execution preview depends on approximate mappings.",
|
||||
value=str(approximate),
|
||||
threshold="0",
|
||||
suggested_action="Keep rollout gated behind approval and supplemental operational controls.",
|
||||
)
|
||||
)
|
||||
else:
|
||||
checks.append(
|
||||
HealthCheck(
|
||||
id="provider-execution-health",
|
||||
title="Provider execution health",
|
||||
status="pass",
|
||||
summary="Stripe execution preview is exact and drift-free.",
|
||||
)
|
||||
)
|
||||
|
||||
accepted_count = len(customer_tuning.get("accepted_request_ids", []))
|
||||
checks.append(
|
||||
HealthCheck(
|
||||
id="tuning-pilot-health",
|
||||
title="Tuning pilot health",
|
||||
status="pass" if accepted_count > 0 else "warn",
|
||||
summary=(
|
||||
"At least one tuned offer currently passes the solver and governance pilot checks."
|
||||
if accepted_count > 0
|
||||
else "No tuned pilot request currently passes the solver."
|
||||
),
|
||||
value=str(accepted_count),
|
||||
threshold=">=1 accepted pilot",
|
||||
suggested_action=None if accepted_count > 0 else "Revise the pilot requests or relax no-longer-needed constraints after review.",
|
||||
)
|
||||
)
|
||||
|
||||
active_experiment_count = int(policy.metadata.get("active_experiment_count", 0))
|
||||
checks.append(
|
||||
HealthCheck(
|
||||
id="experiment-capacity",
|
||||
title="Experiment capacity",
|
||||
status="pass" if active_experiment_count < policy.max_active_experiments else "warn",
|
||||
summary=f"{active_experiment_count} active experiments vs cap {policy.max_active_experiments}.",
|
||||
value=str(active_experiment_count),
|
||||
threshold=str(policy.max_active_experiments),
|
||||
suggested_action=(
|
||||
None
|
||||
if active_experiment_count < policy.max_active_experiments
|
||||
else "Close or review an active experiment before starting another governed experiment."
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return _serialize(checks)
|
||||
|
||||
|
||||
def build_governance_surfaces(
|
||||
data_dir: Path,
|
||||
product: Any,
|
||||
models: list[Any],
|
||||
cost_floor: dict[str, Any],
|
||||
customer_tuning: dict[str, Any],
|
||||
provider_publication: dict[str, Any],
|
||||
policy: GovernancePolicy,
|
||||
) -> dict[str, Any]:
|
||||
state = load_stripe_publication_state(default_stripe_state_path(data_dir))
|
||||
publication_assessment = _publication_assessment(product, provider_publication, policy)
|
||||
|
||||
audit_surface = {
|
||||
"provider": "stripe",
|
||||
"active_revision_id": state.active_revision_id,
|
||||
"active_model_id": state.active_model_id,
|
||||
"revision_count": len(state.revisions),
|
||||
"recent_revisions": [
|
||||
{
|
||||
"revision_id": revision.revision_id,
|
||||
"model_id": revision.model_id,
|
||||
"summary": revision.summary,
|
||||
"operation_count": len(revision.operations),
|
||||
"replaced_revision_id": revision.replaced_revision_id,
|
||||
}
|
||||
for revision in state.revisions[-5:]
|
||||
],
|
||||
}
|
||||
|
||||
return _serialize(
|
||||
{
|
||||
"policy": policy,
|
||||
"publication_assessment": publication_assessment,
|
||||
"health_checks": build_governance_health_checks(
|
||||
cost_floor,
|
||||
customer_tuning,
|
||||
provider_publication,
|
||||
policy,
|
||||
),
|
||||
"safe_tuning_contracts": build_safe_tuning_contracts(
|
||||
models,
|
||||
customer_tuning,
|
||||
policy,
|
||||
product.active_pricing_model_id,
|
||||
),
|
||||
"audit_surface": audit_surface,
|
||||
"notes": [
|
||||
"Governance surfaces are policy-driven and machine-readable so both humans and agents can reason about pricing changes.",
|
||||
"Execution recommendations remain distinct from shadow-state publication and from customer-visible rollout approval.",
|
||||
],
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
"""File-based importers for Bubble, Stripe, and OpenRouter exports."""
|
||||
13
projects/coulomb-pricing/observatory/importers/_io.py
Normal file
13
projects/coulomb-pricing/observatory/importers/_io.py
Normal file
@@ -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")
|
||||
55
projects/coulomb-pricing/observatory/importers/bubble.py
Normal file
55
projects/coulomb-pricing/observatory/importers/bubble.py
Normal file
@@ -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())
|
||||
57
projects/coulomb-pricing/observatory/importers/openrouter.py
Normal file
57
projects/coulomb-pricing/observatory/importers/openrouter.py
Normal file
@@ -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())
|
||||
54
projects/coulomb-pricing/observatory/importers/stripe.py
Normal file
54
projects/coulomb-pricing/observatory/importers/stripe.py
Normal file
@@ -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())
|
||||
121
projects/coulomb-pricing/observatory/ledger.py
Normal file
121
projects/coulomb-pricing/observatory/ledger.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
from .models import (
|
||||
Budget,
|
||||
ExpenseRecord,
|
||||
MembershipRecord,
|
||||
MonthlyPlatformCost,
|
||||
PaymentRecord,
|
||||
)
|
||||
|
||||
TWOPLACES = Decimal("0.01")
|
||||
|
||||
|
||||
def _quantize(value: Decimal) -> Decimal:
|
||||
return value.quantize(TWOPLACES, 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"))
|
||||
raise ValueError(f"unsupported expense currency: {currency}")
|
||||
|
||||
|
||||
def aggregate_infrastructure_by_period(
|
||||
expenses: list[ExpenseRecord],
|
||||
fx_rates: dict[str, Decimal],
|
||||
reporting_currency: str = "EUR",
|
||||
) -> dict[str, Decimal]:
|
||||
if reporting_currency != "EUR":
|
||||
raise ValueError("only EUR reporting currency is supported")
|
||||
|
||||
totals: dict[str, Decimal] = {}
|
||||
for record in expenses:
|
||||
if record.cost_class != "infrastructure":
|
||||
continue
|
||||
totals[record.period] = totals.get(record.period, Decimal("0")) + convert_to_eur(
|
||||
record.amount, record.currency, fx_rates
|
||||
)
|
||||
return {period: _quantize(total) for period, total in totals.items()}
|
||||
|
||||
|
||||
def payment_processing_by_period(payments: list[PaymentRecord]) -> dict[str, Decimal]:
|
||||
totals: dict[str, Decimal] = {}
|
||||
for record in payments:
|
||||
totals[record.period] = totals.get(record.period, Decimal("0")) + record.fees_amount
|
||||
return {period: _quantize(total) for period, total in totals.items()}
|
||||
|
||||
|
||||
def _period_sort_key(period: str) -> tuple[int, int]:
|
||||
year, month = period.split("-")
|
||||
return int(year), int(month)
|
||||
|
||||
|
||||
def periods_from_budget_through_latest(
|
||||
budget: Budget,
|
||||
expenses: list[ExpenseRecord],
|
||||
payments: list[PaymentRecord],
|
||||
) -> list[str]:
|
||||
candidates = {budget.started}
|
||||
candidates.update(record.period for record in expenses)
|
||||
candidates.update(record.period for record in payments)
|
||||
latest = max(candidates, key=_period_sort_key)
|
||||
periods: list[str] = []
|
||||
year, month = map(int, budget.started.split("-"))
|
||||
end_year, end_month = map(int, latest.split("-"))
|
||||
while (year, month) <= (end_year, end_month):
|
||||
periods.append(f"{year}-{month:02d}")
|
||||
month += 1
|
||||
if month > 12:
|
||||
month = 1
|
||||
year += 1
|
||||
return periods
|
||||
|
||||
|
||||
def active_members_for_period(period: str, members: list[MembershipRecord]) -> int:
|
||||
period_start = f"{period}-01"
|
||||
count = 0
|
||||
for member in members:
|
||||
if member.joined_at[:7] > period:
|
||||
continue
|
||||
if member.churned_at and member.churned_at[:7] < period:
|
||||
continue
|
||||
if member.status == "active" or (
|
||||
member.churned_at and member.churned_at[:7] >= period
|
||||
):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def build_monthly_ledger(
|
||||
budget: Budget,
|
||||
expenses: list[ExpenseRecord],
|
||||
payments: list[PaymentRecord],
|
||||
members: list[MembershipRecord],
|
||||
fx_rates: dict[str, Decimal],
|
||||
) -> list[MonthlyPlatformCost]:
|
||||
infrastructure = aggregate_infrastructure_by_period(expenses, fx_rates)
|
||||
processing = payment_processing_by_period(payments)
|
||||
payment_by_period = {record.period: record for record in payments}
|
||||
rows: list[MonthlyPlatformCost] = []
|
||||
|
||||
for period in periods_from_budget_through_latest(budget, expenses, payments):
|
||||
payment = payment_by_period.get(period)
|
||||
rows.append(
|
||||
MonthlyPlatformCost(
|
||||
period=period,
|
||||
infrastructure_cost=infrastructure.get(period, Decimal("0.00")),
|
||||
payment_processing_cost=processing.get(period, Decimal("0.00")),
|
||||
active_members=(
|
||||
payment.member_count
|
||||
if payment and payment.member_count
|
||||
else active_members_for_period(period, members)
|
||||
),
|
||||
gross_revenue=payment.gross_amount if payment else Decimal("0.00"),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
162
projects/coulomb-pricing/observatory/load.py
Normal file
162
projects/coulomb-pricing/observatory/load.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from ._repo_root import ensure_repo_root_on_syspath
|
||||
ensure_repo_root_on_syspath()
|
||||
|
||||
from adaptive_pricing_core.pricing_models import load_pricing_models as load_canonical_pricing_models
|
||||
from .ledger import build_monthly_ledger
|
||||
from .models import (
|
||||
Budget,
|
||||
ExpenseRecord,
|
||||
MembershipRecord,
|
||||
MonthlyPlatformCost,
|
||||
PaymentRecord,
|
||||
PricingModel,
|
||||
Product,
|
||||
)
|
||||
|
||||
|
||||
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_budget(data_dir: Path | None = None) -> Budget:
|
||||
raw = _read_json((data_dir or default_data_dir()) / "budget.json")
|
||||
return Budget(
|
||||
currency=raw["currency"],
|
||||
initial_budget=_money(raw["initial_budget"]),
|
||||
started=raw["started"],
|
||||
)
|
||||
|
||||
|
||||
def load_pricing_models(data_dir: Path | None = None) -> list[PricingModel]:
|
||||
return load_canonical_pricing_models((data_dir or default_data_dir()) / "pricing-models.json")
|
||||
|
||||
|
||||
def load_fx_rates(data_dir: Path | None = None) -> dict[str, Decimal]:
|
||||
raw = _read_json((data_dir or default_data_dir()) / "expense_records.json")
|
||||
return {pair: _money(rate) for pair, rate in raw.get("fx_rates", {}).items()} if raw.get(
|
||||
"fx_rates"
|
||||
) else {}
|
||||
|
||||
|
||||
def load_expense_records(data_dir: Path | None = None) -> list[ExpenseRecord]:
|
||||
raw = _read_json((data_dir or default_data_dir()) / "expense_records.json")
|
||||
return [
|
||||
ExpenseRecord(
|
||||
id=item["id"],
|
||||
period=item["period"],
|
||||
vendor=item["vendor"],
|
||||
description=item["description"],
|
||||
cost_class=item["cost_class"],
|
||||
amount=_money(item["amount"]),
|
||||
currency=item["currency"],
|
||||
source=item["source"],
|
||||
)
|
||||
for item in raw["records"]
|
||||
]
|
||||
|
||||
|
||||
def load_payment_records(data_dir: Path | None = None) -> list[PaymentRecord]:
|
||||
root = data_dir or default_data_dir()
|
||||
path = root / "payment_records.json"
|
||||
if not path.exists():
|
||||
# Backward compatibility with legacy revenue.json
|
||||
path = root / "revenue.json"
|
||||
raw = _read_json(path)
|
||||
items = raw.get("records", raw.get("entries", []))
|
||||
return [
|
||||
PaymentRecord(
|
||||
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"],
|
||||
member_count=item.get("member_count", 0),
|
||||
member_username=item.get("member_username"),
|
||||
payout_account=item.get("payout_account"),
|
||||
)
|
||||
for item in items
|
||||
]
|
||||
|
||||
|
||||
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_ltv_scenarios(data_dir: Path | None = None) -> dict:
|
||||
return _read_json((data_dir or default_data_dir()) / "ltv_scenarios.json")
|
||||
|
||||
|
||||
def load_governance_policy(data_dir: Path | None = None) -> dict:
|
||||
path = (data_dir or default_data_dir()) / "governance_policy.json"
|
||||
if not path.exists():
|
||||
return {}
|
||||
return _read_json(path)
|
||||
|
||||
|
||||
def load_tuning_requests(data_dir: Path | None = None) -> dict:
|
||||
path = (data_dir or default_data_dir()) / "tuning_requests.json"
|
||||
if not path.exists():
|
||||
return {}
|
||||
return _read_json(path)
|
||||
|
||||
|
||||
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"]
|
||||
]
|
||||
|
||||
|
||||
def load_monthly_ledger(data_dir: Path | None = None) -> list[MonthlyPlatformCost]:
|
||||
root = data_dir or default_data_dir()
|
||||
return build_monthly_ledger(
|
||||
load_budget(root),
|
||||
load_expense_records(root),
|
||||
load_payment_records(root),
|
||||
load_membership(root),
|
||||
load_fx_rates(root),
|
||||
)
|
||||
|
||||
|
||||
def latest_period(monthly_costs: list[MonthlyPlatformCost]) -> str:
|
||||
return max(item.period for item in monthly_costs)
|
||||
210
projects/coulomb-pricing/observatory/ltv.py
Normal file
210
projects/coulomb-pricing/observatory/ltv.py
Normal file
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from ._repo_root import ensure_repo_root_on_syspath
|
||||
from .boundary import build_boundary_policy
|
||||
from .models import EconomicsSnapshot, PricingModel
|
||||
|
||||
ensure_repo_root_on_syspath()
|
||||
|
||||
from adaptive_pricing_core.boundary_engine import PricingConfiguration # noqa: E402
|
||||
from adaptive_pricing_core.comparable_ltv import ( # noqa: E402
|
||||
ComparableCustomerProfile,
|
||||
LTVPolicy,
|
||||
SensitivityCase,
|
||||
compare_pricing_configurations,
|
||||
)
|
||||
|
||||
|
||||
def _serialize(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__}
|
||||
if isinstance(value, tuple):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, list):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _serialize(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _decimal(value: Decimal | str | int | float | None) -> Decimal:
|
||||
if value in (None, ""):
|
||||
return Decimal("0")
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _usage_component(model: PricingModel):
|
||||
return next((component for component in model.charge_components if component.kind == "usage"), None)
|
||||
|
||||
|
||||
def _included_units(model: PricingModel, members_per_customer: int) -> Decimal | None:
|
||||
usage = _usage_component(model)
|
||||
if not usage or usage.included_units is None:
|
||||
return None
|
||||
return usage.included_units * Decimal(members_per_customer)
|
||||
|
||||
|
||||
def _usage_unit_price(model: PricingModel) -> Decimal | None:
|
||||
usage = _usage_component(model)
|
||||
if not usage or usage.unit_price is None:
|
||||
return None
|
||||
return usage.unit_price
|
||||
|
||||
|
||||
def _usage_unit_cost(records: list[dict[str, Any]], period: str) -> Decimal:
|
||||
period_rows = [row for row in records if row.get("period") == period]
|
||||
total_units = sum(_decimal(row.get("tokens")) for row in period_rows)
|
||||
total_cost = sum(_decimal(row.get("cost_eur")) for row in period_rows)
|
||||
if total_units <= Decimal("0"):
|
||||
return Decimal("0")
|
||||
return total_cost / total_units
|
||||
|
||||
|
||||
def _payment_fee_rate(snapshot: EconomicsSnapshot) -> Decimal:
|
||||
if snapshot.monthly_revenue <= Decimal("0"):
|
||||
return Decimal("0")
|
||||
return (snapshot.monthly_payment_processing_cost / snapshot.monthly_revenue) * Decimal("100")
|
||||
|
||||
|
||||
def _profile(raw: dict[str, Any]) -> ComparableCustomerProfile:
|
||||
return ComparableCustomerProfile(
|
||||
id=raw["id"],
|
||||
name=raw["name"],
|
||||
segment=raw["segment"],
|
||||
eligible_model_ids=tuple(raw.get("eligible_model_ids", [])),
|
||||
members_per_customer=int(raw.get("members_per_customer", 1)),
|
||||
expected_monthly_usage_units=_decimal(raw.get("expected_monthly_usage_units")),
|
||||
usage_variance_pct=_decimal(raw.get("usage_variance_pct")),
|
||||
monthly_churn_pct=_decimal(raw.get("monthly_churn_pct")),
|
||||
monthly_default_pct=_decimal(raw.get("monthly_default_pct")),
|
||||
monthly_support_cost=_decimal(raw.get("monthly_support_cost")),
|
||||
monthly_risk_cost=_decimal(raw.get("monthly_risk_cost")),
|
||||
acquisition_cost=_decimal(raw.get("acquisition_cost")),
|
||||
upfront_investment_cost=_decimal(raw.get("upfront_investment_cost")),
|
||||
allocated_fixed_cost=_decimal(raw["allocated_fixed_cost"]) if raw.get("allocated_fixed_cost") else None,
|
||||
notes=raw.get("notes", ""),
|
||||
)
|
||||
|
||||
|
||||
def _sensitivity_case(raw: dict[str, Any]) -> SensitivityCase:
|
||||
return SensitivityCase(
|
||||
id=raw["id"],
|
||||
name=raw["name"],
|
||||
usage_multiplier=_decimal(raw.get("usage_multiplier", "1")),
|
||||
usage_variance_delta_pct=_decimal(raw.get("usage_variance_delta_pct")),
|
||||
monthly_churn_delta_pct=_decimal(raw.get("monthly_churn_delta_pct")),
|
||||
monthly_default_delta_pct=_decimal(raw.get("monthly_default_delta_pct")),
|
||||
monthly_support_cost_delta=_decimal(raw.get("monthly_support_cost_delta")),
|
||||
monthly_risk_cost_delta=_decimal(raw.get("monthly_risk_cost_delta")),
|
||||
)
|
||||
|
||||
|
||||
def _ltv_policy(raw: dict[str, Any]) -> LTVPolicy:
|
||||
return LTVPolicy(
|
||||
horizon_months=int(raw.get("horizon_months", 24)),
|
||||
monthly_discount_rate_pct=_decimal(raw.get("monthly_discount_rate_pct", "1.0")),
|
||||
required_improvement_factor=_decimal(raw.get("required_improvement_factor", "1.05")),
|
||||
)
|
||||
|
||||
|
||||
def _configuration(
|
||||
model: PricingModel,
|
||||
profile: ComparableCustomerProfile,
|
||||
snapshot: EconomicsSnapshot,
|
||||
usage_unit_cost: Decimal,
|
||||
) -> PricingConfiguration:
|
||||
members_per_customer = max(profile.members_per_customer, 1)
|
||||
per_member_fixed_cost = (
|
||||
snapshot.monthly_infrastructure_cost / snapshot.active_members
|
||||
if snapshot.active_members
|
||||
else snapshot.monthly_infrastructure_cost
|
||||
)
|
||||
allocated_fixed_cost = (
|
||||
profile.allocated_fixed_cost
|
||||
if profile.allocated_fixed_cost is not None
|
||||
else per_member_fixed_cost * Decimal(members_per_customer)
|
||||
)
|
||||
return PricingConfiguration(
|
||||
model=model,
|
||||
segment=profile.segment,
|
||||
expected_usage_units=profile.expected_monthly_usage_units,
|
||||
expected_usage_variance_pct=profile.usage_variance_pct,
|
||||
allocated_fixed_cost=allocated_fixed_cost,
|
||||
unit_cost=usage_unit_cost,
|
||||
support_cost=profile.monthly_support_cost,
|
||||
risk_cost=profile.monthly_risk_cost,
|
||||
payment_fee_rate_pct=_payment_fee_rate(snapshot),
|
||||
access_fee_amount=model.access_fee_amount * Decimal(members_per_customer),
|
||||
included_units=_included_units(model, members_per_customer),
|
||||
usage_unit_price=_usage_unit_price(model),
|
||||
)
|
||||
|
||||
|
||||
def build_ltv_simulations(
|
||||
snapshot: EconomicsSnapshot,
|
||||
models: list[PricingModel],
|
||||
usage_records: list[dict[str, Any]],
|
||||
scenario_catalog: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
policy = _ltv_policy(scenario_catalog)
|
||||
boundary_policy = build_boundary_policy(snapshot)
|
||||
sensitivity_cases = tuple(_sensitivity_case(item) for item in scenario_catalog.get("sensitivity_cases", []))
|
||||
observed_usage_unit_cost = _usage_unit_cost(usage_records, snapshot.period)
|
||||
|
||||
profile_results = []
|
||||
for raw_profile in scenario_catalog.get("profiles", []):
|
||||
profile = _profile(raw_profile)
|
||||
configurations = [
|
||||
_configuration(model, profile, snapshot, observed_usage_unit_cost)
|
||||
for model in models
|
||||
if model.status in ("active", "candidate")
|
||||
]
|
||||
profile_results.append(
|
||||
compare_pricing_configurations(
|
||||
configurations,
|
||||
profile,
|
||||
boundary_policy,
|
||||
policy,
|
||||
sensitivity_cases=sensitivity_cases,
|
||||
)
|
||||
)
|
||||
|
||||
primary = profile_results[0] if profile_results else None
|
||||
primary_scenarios = list(primary.comparisons) if primary else []
|
||||
active_model = next((model for model in models if model.status == "active"), None)
|
||||
best_margin = max(primary_scenarios, key=lambda item: item.base_monthly_margin, default=None)
|
||||
best_ltv = max(
|
||||
primary_scenarios,
|
||||
key=lambda item: item.average_comparable_customer_lifetime_value,
|
||||
default=None,
|
||||
)
|
||||
|
||||
return _serialize({
|
||||
"period": snapshot.period,
|
||||
"currency": snapshot.currency,
|
||||
"required_improvement_factor": policy.required_improvement_factor,
|
||||
"horizon_months": policy.horizon_months,
|
||||
"monthly_discount_rate_pct": policy.monthly_discount_rate_pct,
|
||||
"active_scenario_id": active_model.id if active_model else None,
|
||||
"best_margin_scenario_id": best_margin.model_id if best_margin else None,
|
||||
"best_ltv_scenario_id": best_ltv.model_id if best_ltv else None,
|
||||
"reference_model_id": primary.reference_model_id if primary else None,
|
||||
"primary_profile_id": primary.profile.id if primary else None,
|
||||
"scenarios": primary_scenarios,
|
||||
"profile_comparisons": profile_results,
|
||||
"calibration": {
|
||||
"observed_usage_unit_cost": observed_usage_unit_cost,
|
||||
"observed_payment_fee_rate_pct": _payment_fee_rate(snapshot),
|
||||
"profile_count": len(profile_results),
|
||||
},
|
||||
"notes": [
|
||||
scenario_catalog.get("notes", ""),
|
||||
"Primary scenarios expose the first configured comparable-customer profile for backward-compatible UI consumers.",
|
||||
"Profile comparisons compare candidate models using discounted seller LTV rather than only current-period gross margin.",
|
||||
],
|
||||
})
|
||||
48
projects/coulomb-pricing/observatory/membership_analytics.py
Normal file
48
projects/coulomb-pricing/observatory/membership_analytics.py
Normal file
@@ -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",
|
||||
}
|
||||
120
projects/coulomb-pricing/observatory/models.py
Normal file
120
projects/coulomb-pricing/observatory/models.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from ._repo_root import ensure_repo_root_on_syspath
|
||||
|
||||
ensure_repo_root_on_syspath()
|
||||
|
||||
from adaptive_pricing_core.pricing_models import ( # noqa: E402
|
||||
ChargeComponent,
|
||||
Commitment,
|
||||
PricingModel,
|
||||
PricingModelStatus,
|
||||
TunableParameter,
|
||||
)
|
||||
|
||||
ExpenseClass = Literal["infrastructure", "payment_processing"]
|
||||
MemberStatus = Literal["active", "churned", "paused"]
|
||||
LiquidityStatus = Literal["burning", "neutral", "generating"]
|
||||
|
||||
|
||||
@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 ExpenseRecord:
|
||||
id: str
|
||||
period: str
|
||||
vendor: str
|
||||
description: str
|
||||
cost_class: ExpenseClass
|
||||
amount: Decimal
|
||||
currency: str
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PaymentRecord:
|
||||
id: str
|
||||
period: str
|
||||
gross_amount: Decimal
|
||||
fees_amount: Decimal
|
||||
refunds_amount: Decimal
|
||||
net_amount: Decimal
|
||||
currency: str
|
||||
source: str
|
||||
member_count: int = 0
|
||||
member_username: str | None = None
|
||||
payout_account: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MonthlyPlatformCost:
|
||||
period: str
|
||||
infrastructure_cost: Decimal
|
||||
payment_processing_cost: Decimal
|
||||
active_members: int
|
||||
gross_revenue: Decimal
|
||||
|
||||
@property
|
||||
def total_platform_cost(self) -> Decimal:
|
||||
return self.infrastructure_cost + self.payment_processing_cost
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Budget:
|
||||
currency: str
|
||||
initial_budget: Decimal
|
||||
started: 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_infrastructure_cost: Decimal
|
||||
monthly_payment_processing_cost: Decimal
|
||||
monthly_total_platform_cost: Decimal
|
||||
cost_per_member: Decimal
|
||||
gross_margin: Decimal
|
||||
gross_margin_pct: Decimal
|
||||
pricing_model_count: int
|
||||
revenue_source: str
|
||||
period_net_liquidity: Decimal
|
||||
liquidity_status: LiquidityStatus
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiquiditySummary:
|
||||
currency: str
|
||||
through_period: str
|
||||
initial_budget: Decimal
|
||||
cumulative_member_payments: Decimal
|
||||
cumulative_infrastructure_cost: Decimal
|
||||
cumulative_payment_processing_cost: Decimal
|
||||
cumulative_total_platform_cost: Decimal
|
||||
cumulative_net_liquidity: Decimal
|
||||
remaining_budget: Decimal
|
||||
liquidity_status: LiquidityStatus
|
||||
months_tracked: int
|
||||
93
projects/coulomb-pricing/observatory/pricing_context.py
Normal file
93
projects/coulomb-pricing/observatory/pricing_context.py
Normal file
@@ -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", ""),
|
||||
}
|
||||
159
projects/coulomb-pricing/observatory/publication.py
Normal file
159
projects/coulomb-pricing/observatory/publication.py
Normal file
@@ -0,0 +1,159 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ._repo_root import ensure_repo_root_on_syspath
|
||||
from .models import PricingModel, Product
|
||||
|
||||
ensure_repo_root_on_syspath()
|
||||
|
||||
from adaptive_pricing_core.provider_publication import ( # noqa: E402
|
||||
CatalogProduct,
|
||||
ProviderPublicationState,
|
||||
apply_publication,
|
||||
build_publication_bundle,
|
||||
plan_publication,
|
||||
provider_state_from_dict,
|
||||
provider_state_to_dict,
|
||||
rollback_publication,
|
||||
)
|
||||
from adaptive_pricing_core.stripe_provider import map_bundle_to_stripe # noqa: E402
|
||||
|
||||
|
||||
def _serialize(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__}
|
||||
if isinstance(value, tuple):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, list):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _serialize(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def default_stripe_state_path(data_dir: Path) -> Path:
|
||||
return data_dir / "provider_state" / "stripe-publication.json"
|
||||
|
||||
|
||||
def load_stripe_publication_state(path: Path) -> ProviderPublicationState:
|
||||
if not path.exists():
|
||||
return ProviderPublicationState(provider="stripe")
|
||||
return provider_state_from_dict(json.loads(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def save_stripe_publication_state(path: Path, state: ProviderPublicationState) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(provider_state_to_dict(state), indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _catalog_product(product: Product) -> CatalogProduct:
|
||||
return CatalogProduct(
|
||||
id=product.id,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
currency=product.currency,
|
||||
lifecycle_phase=product.lifecycle_phase,
|
||||
active_pricing_model_id=product.active_pricing_model_id,
|
||||
metadata={"product_channel": "membership"},
|
||||
)
|
||||
|
||||
|
||||
def _target_model(
|
||||
models: list[PricingModel],
|
||||
product: Product,
|
||||
model_id: str | None = None,
|
||||
) -> PricingModel:
|
||||
requested_id = model_id or product.active_pricing_model_id
|
||||
return next(item for item in models if item.id == requested_id)
|
||||
|
||||
|
||||
def build_stripe_publication_preview(
|
||||
product: Product,
|
||||
models: list[PricingModel],
|
||||
data_dir: Path,
|
||||
*,
|
||||
model_id: str | None = None,
|
||||
state_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
model = _target_model(models, product, model_id)
|
||||
bundle = build_publication_bundle(_catalog_product(product), model)
|
||||
package = map_bundle_to_stripe(bundle)
|
||||
state = load_stripe_publication_state(state_path or default_stripe_state_path(data_dir))
|
||||
plan = plan_publication(package, state)
|
||||
|
||||
return _serialize(
|
||||
{
|
||||
"provider": "stripe",
|
||||
"state_path": str(state_path or default_stripe_state_path(data_dir)),
|
||||
"model_id": model.id,
|
||||
"model_name": model.name,
|
||||
"current_state": {
|
||||
"active_revision_id": state.active_revision_id,
|
||||
"active_model_id": state.active_model_id,
|
||||
"artifact_count": len(state.artifacts),
|
||||
"revision_count": len(state.revisions),
|
||||
},
|
||||
"artifact_counts": {
|
||||
"exact": sum(item.mapping_status == "exact" for item in package.artifacts),
|
||||
"approximate": sum(item.mapping_status == "approximate" for item in package.artifacts),
|
||||
"unsupported": sum(item.mapping_status == "unsupported" for item in package.artifacts),
|
||||
},
|
||||
"plan": plan,
|
||||
"notes": package.notes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def publish_to_stripe_shadow(
|
||||
product: Product,
|
||||
models: list[PricingModel],
|
||||
data_dir: Path,
|
||||
*,
|
||||
model_id: str | None = None,
|
||||
state_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
path = state_path or default_stripe_state_path(data_dir)
|
||||
model = _target_model(models, product, model_id)
|
||||
bundle = build_publication_bundle(_catalog_product(product), model)
|
||||
package = map_bundle_to_stripe(bundle)
|
||||
current_state = load_stripe_publication_state(path)
|
||||
result = apply_publication(package, current_state)
|
||||
save_stripe_publication_state(path, result.state)
|
||||
|
||||
return _serialize(
|
||||
{
|
||||
"provider": "stripe",
|
||||
"state_path": str(path),
|
||||
"model_id": model.id,
|
||||
"model_name": model.name,
|
||||
"result": result,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def rollback_stripe_shadow(
|
||||
data_dir: Path,
|
||||
revision_id: str,
|
||||
*,
|
||||
state_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
path = state_path or default_stripe_state_path(data_dir)
|
||||
current_state = load_stripe_publication_state(path)
|
||||
result = rollback_publication(current_state, revision_id)
|
||||
save_stripe_publication_state(path, result.state)
|
||||
return _serialize(
|
||||
{
|
||||
"provider": "stripe",
|
||||
"state_path": str(path),
|
||||
"result": result,
|
||||
}
|
||||
)
|
||||
52
projects/coulomb-pricing/observatory/publish.py
Normal file
52
projects/coulomb-pricing/observatory/publish.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from .load import default_data_dir, load_pricing_models, load_product
|
||||
from .publication import (
|
||||
build_stripe_publication_preview,
|
||||
publish_to_stripe_shadow,
|
||||
rollback_stripe_shadow,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Preview or apply Stripe publication for pricing models")
|
||||
parser.add_argument("--data-dir", type=Path, default=default_data_dir())
|
||||
parser.add_argument("--model-id", help="Pricing model id to preview or publish")
|
||||
parser.add_argument("--provider", default="stripe", choices=["stripe"])
|
||||
parser.add_argument("--state-path", type=Path, help="Override provider shadow-state path")
|
||||
parser.add_argument("--apply", action="store_true", help="Apply the publication plan to the local Stripe shadow state")
|
||||
parser.add_argument("--rollback", help="Rollback the local Stripe shadow state to a prior revision id")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
product = load_product(args.data_dir)
|
||||
models = load_pricing_models(args.data_dir)
|
||||
|
||||
if args.rollback:
|
||||
payload = rollback_stripe_shadow(args.data_dir, args.rollback, state_path=args.state_path)
|
||||
elif args.apply:
|
||||
payload = publish_to_stripe_shadow(
|
||||
product,
|
||||
models,
|
||||
args.data_dir,
|
||||
model_id=args.model_id,
|
||||
state_path=args.state_path,
|
||||
)
|
||||
else:
|
||||
payload = build_stripe_publication_preview(
|
||||
product,
|
||||
models,
|
||||
args.data_dir,
|
||||
model_id=args.model_id,
|
||||
state_path=args.state_path,
|
||||
)
|
||||
|
||||
print(json.dumps(payload, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
33
projects/coulomb-pricing/observatory/recommendations.py
Normal file
33
projects/coulomb-pricing/observatory/recommendations.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .governance import build_pricing_recommendation_workflow, build_governance_policy
|
||||
|
||||
|
||||
def build_pricing_recommendations(
|
||||
cost_floor: dict[str, Any],
|
||||
value_range: dict[str, Any],
|
||||
market_price: dict[str, Any],
|
||||
simulations: dict[str, Any],
|
||||
usage_summary: dict[str, Any],
|
||||
*,
|
||||
boundary_validation: dict[str, Any] | None = None,
|
||||
customer_tuning: dict[str, Any] | None = None,
|
||||
provider_publication: dict[str, Any] | None = None,
|
||||
governance_policy: dict[str, Any] | None = None,
|
||||
product: Any | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
policy = build_governance_policy(governance_policy or {})
|
||||
return build_pricing_recommendation_workflow(
|
||||
cost_floor,
|
||||
value_range,
|
||||
market_price,
|
||||
simulations,
|
||||
usage_summary,
|
||||
boundary_validation=boundary_validation,
|
||||
customer_tuning=customer_tuning or {},
|
||||
provider_publication=provider_publication or {},
|
||||
governance_policy=policy,
|
||||
product=product,
|
||||
)
|
||||
81
projects/coulomb-pricing/observatory/server.py
Normal file
81
projects/coulomb-pricing/observatory/server.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from .api import build_dashboard_payload
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
UI_DIR = ROOT / "ui"
|
||||
|
||||
UI_CONTENT_TYPES = {
|
||||
".css": "text/css",
|
||||
".js": "application/javascript",
|
||||
".json": "application/json",
|
||||
".html": "text/html; charset=utf-8",
|
||||
}
|
||||
|
||||
|
||||
class ObservatoryHandler(BaseHTTPRequestHandler):
|
||||
data_dir: Path = ROOT / "data"
|
||||
|
||||
def _send(self, status: int, body: bytes, content_type: str) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/api/dashboard":
|
||||
query = parse_qs(parsed.query)
|
||||
period = query.get("period", [None])[0]
|
||||
payload = build_dashboard_payload(self.data_dir, period)
|
||||
self._send(200, json.dumps(payload).encode("utf-8"), "application/json")
|
||||
return
|
||||
|
||||
if parsed.path == "/":
|
||||
return self._serve_file(UI_DIR / "index.html", "text/html; charset=utf-8")
|
||||
|
||||
if parsed.path.startswith("/ui/"):
|
||||
relative = parsed.path.removeprefix("/ui/")
|
||||
target = UI_DIR / relative
|
||||
if target.exists() and target.is_file():
|
||||
content_type = UI_CONTENT_TYPES.get(target.suffix, "application/octet-stream")
|
||||
if "charset" not in content_type:
|
||||
content_type = f"{content_type}; charset=utf-8"
|
||||
return self._serve_file(target, content_type)
|
||||
|
||||
self._send(404, b"Not found", "text/plain")
|
||||
|
||||
def _serve_file(self, path: Path, content_type: str) -> None:
|
||||
self._send(200, path.read_bytes(), content_type)
|
||||
|
||||
def log_message(self, format: str, *args) -> None:
|
||||
return
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Coulomb Economic Observatory UI server")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8765)
|
||||
parser.add_argument("--data-dir", type=Path, default=ROOT / "data")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
ObservatoryHandler.data_dir = args.data_dir
|
||||
server = ThreadingHTTPServer((args.host, args.port), ObservatoryHandler)
|
||||
print(f"Economic Observatory UI: http://{args.host}:{args.port}/")
|
||||
print(f"API: http://{args.host}:{args.port}/api/dashboard")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
64
projects/coulomb-pricing/observatory/simulator.py
Normal file
64
projects/coulomb-pricing/observatory/simulator.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from .ltv import build_ltv_simulations
|
||||
from .models import EconomicsSnapshot, PricingModel
|
||||
|
||||
|
||||
def _fallback_catalog(models: list[PricingModel]) -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"currency": "EUR",
|
||||
"horizon_months": 24,
|
||||
"monthly_discount_rate_pct": "1.0",
|
||||
"required_improvement_factor": "1.05",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "observatory-default",
|
||||
"name": "Observatory default",
|
||||
"segment": "coulomb-social-members",
|
||||
"eligible_model_ids": [model.id for model in models if model.status in ("active", "candidate")],
|
||||
"members_per_customer": 1,
|
||||
"expected_monthly_usage_units": "120000",
|
||||
"usage_variance_pct": "25",
|
||||
"monthly_churn_pct": "5.0",
|
||||
"monthly_default_pct": "1.0",
|
||||
"monthly_support_cost": "0.00",
|
||||
"monthly_risk_cost": "0.00",
|
||||
"acquisition_cost": "0.00",
|
||||
"upfront_investment_cost": "0.00",
|
||||
"notes": "Fallback scenario when no explicit LTV scenario catalog is provided."
|
||||
}
|
||||
],
|
||||
"notes": "Fallback scenario catalog generated inside observatory.simulator.",
|
||||
}
|
||||
|
||||
|
||||
def _fallback_usage_records(snapshot: EconomicsSnapshot, ai_cost_per_member: Decimal) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": "fallback-usage",
|
||||
"period": snapshot.period,
|
||||
"member_id": "fallback",
|
||||
"tokens": 120000,
|
||||
"cost_eur": ai_cost_per_member,
|
||||
"source": "fallback",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def build_pricing_simulations(
|
||||
snapshot: EconomicsSnapshot,
|
||||
models: list[PricingModel],
|
||||
ai_cost_per_member: Decimal,
|
||||
usage_records: list[dict[str, Any]] | None = None,
|
||||
scenario_catalog: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return build_ltv_simulations(
|
||||
snapshot,
|
||||
models,
|
||||
usage_records or _fallback_usage_records(snapshot, ai_cost_per_member),
|
||||
scenario_catalog or _fallback_catalog(models),
|
||||
)
|
||||
178
projects/coulomb-pricing/observatory/tuning.py
Normal file
178
projects/coulomb-pricing/observatory/tuning.py
Normal file
@@ -0,0 +1,178 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from ._repo_root import ensure_repo_root_on_syspath
|
||||
from .boundary import build_boundary_policy
|
||||
from .ltv import _configuration, _ltv_policy, _profile, _usage_unit_cost
|
||||
from .models import EconomicsSnapshot, PricingModel
|
||||
|
||||
ensure_repo_root_on_syspath()
|
||||
|
||||
from adaptive_pricing_core.customer_tuning import ( # noqa: E402
|
||||
CustomerTuningRequest,
|
||||
UsagePriceSearchPolicy,
|
||||
solve_customer_tuning,
|
||||
)
|
||||
|
||||
|
||||
def _serialize(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return str(value)
|
||||
if hasattr(value, "__dataclass_fields__"):
|
||||
return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__}
|
||||
if isinstance(value, tuple):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, list):
|
||||
return [_serialize(item) for item in value]
|
||||
if isinstance(value, dict):
|
||||
return {key: _serialize(item) for key, item in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def _decimal(value: Decimal | str | int | float | None) -> Decimal | None:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def _customer_tunable_keys(model: PricingModel) -> set[str]:
|
||||
return {
|
||||
parameter.key
|
||||
for parameter in model.tunable_parameters
|
||||
if parameter.parameter_class == "customer_tunable"
|
||||
}
|
||||
|
||||
|
||||
def _validate_request_surface(model: PricingModel, selected_tunables: dict[str, Any]) -> tuple[str, ...]:
|
||||
tunable_keys = _customer_tunable_keys(model)
|
||||
issues: list[str] = []
|
||||
for key in selected_tunables:
|
||||
if key not in tunable_keys:
|
||||
issues.append(f"{key} is not customer-tunable on {model.id}")
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _request(raw: dict[str, Any]) -> CustomerTuningRequest:
|
||||
selected = raw.get("selected_tunables", {})
|
||||
return CustomerTuningRequest(
|
||||
included_units=_decimal(selected.get("included_tokens")),
|
||||
contract_duration_months=(
|
||||
int(selected["contract_duration_months"])
|
||||
if selected.get("contract_duration_months") is not None
|
||||
else None
|
||||
),
|
||||
minimum_monthly_turnover=_decimal(selected.get("minimum_monthly_turnover")) or Decimal("0"),
|
||||
prepaid_amount=_decimal(selected.get("prepaid_amount")) or Decimal("0"),
|
||||
guaranteed_platform_fee=_decimal(selected.get("guaranteed_platform_fee")) or Decimal("0"),
|
||||
customer_funded_onboarding=_decimal(selected.get("customer_funded_onboarding")) or Decimal("0"),
|
||||
reduced_cancellation_flexibility=raw.get("reduced_cancellation_flexibility"),
|
||||
preference=raw.get("preference", "lower_usage_price"),
|
||||
approval_mode=raw.get("approval_mode", "self_serve_only"),
|
||||
)
|
||||
|
||||
|
||||
def _search_policy(raw: dict[str, Any]) -> UsagePriceSearchPolicy | None:
|
||||
if not raw:
|
||||
return None
|
||||
return UsagePriceSearchPolicy(
|
||||
min_usage_unit_price=_decimal(raw.get("min_usage_unit_price")),
|
||||
max_usage_unit_price=_decimal(raw.get("max_usage_unit_price")),
|
||||
usage_unit_price_step=_decimal(raw.get("usage_unit_price_step")) or Decimal("0.0001"),
|
||||
max_usage_price_multiplier=_decimal(raw.get("max_usage_price_multiplier")) or Decimal("4"),
|
||||
)
|
||||
|
||||
|
||||
def build_customer_tuning_pilot(
|
||||
snapshot: EconomicsSnapshot,
|
||||
models: list[PricingModel],
|
||||
usage_records: list[dict[str, Any]],
|
||||
scenario_catalog: dict[str, Any],
|
||||
request_catalog: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request_catalog = request_catalog or {}
|
||||
if not request_catalog.get("requests"):
|
||||
return {
|
||||
"period": snapshot.period,
|
||||
"currency": snapshot.currency,
|
||||
"requests": [],
|
||||
"notes": [
|
||||
"No customer-tuning pilot requests are configured for this observatory deployment.",
|
||||
],
|
||||
}
|
||||
|
||||
profile_index = {item["id"]: _profile(item) for item in scenario_catalog.get("profiles", [])}
|
||||
model_index = {model.id: model for model in models if model.status in ("active", "candidate")}
|
||||
policy = _ltv_policy(scenario_catalog)
|
||||
boundary_policy = build_boundary_policy(snapshot)
|
||||
observed_usage_unit_cost = _usage_unit_cost(usage_records, snapshot.period)
|
||||
|
||||
results = []
|
||||
for raw_request in request_catalog.get("requests", []):
|
||||
model = model_index[raw_request["model_id"]]
|
||||
profile = profile_index[raw_request["profile_id"]]
|
||||
selected_tunables = raw_request.get("selected_tunables", {})
|
||||
issues = _validate_request_surface(model, selected_tunables)
|
||||
|
||||
if issues:
|
||||
results.append(
|
||||
{
|
||||
"id": raw_request["id"],
|
||||
"name": raw_request["name"],
|
||||
"decision": "rejected",
|
||||
"issues": list(issues),
|
||||
"profile_id": profile.id,
|
||||
"model_id": model.id,
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
base_configuration = _configuration(model, profile, snapshot, observed_usage_unit_cost)
|
||||
reference_configurations = [
|
||||
_configuration(candidate, profile, snapshot, observed_usage_unit_cost)
|
||||
for candidate in models
|
||||
if candidate.status in ("active", "candidate")
|
||||
]
|
||||
outcome = solve_customer_tuning(
|
||||
base_configuration,
|
||||
reference_configurations,
|
||||
profile,
|
||||
boundary_policy,
|
||||
policy,
|
||||
_request(raw_request),
|
||||
search_policy=_search_policy(raw_request.get("search_policy", {})),
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"id": raw_request["id"],
|
||||
"name": raw_request["name"],
|
||||
"profile_id": profile.id,
|
||||
"profile_name": profile.name,
|
||||
"model_id": model.id,
|
||||
"model_name": model.name,
|
||||
"selected_tunables": selected_tunables,
|
||||
"result": outcome,
|
||||
}
|
||||
)
|
||||
|
||||
accepted = [
|
||||
item["id"]
|
||||
for item in results
|
||||
if item.get("result") is not None and getattr(item["result"], "decision", None) == "accepted"
|
||||
]
|
||||
|
||||
return _serialize(
|
||||
{
|
||||
"period": snapshot.period,
|
||||
"currency": snapshot.currency,
|
||||
"request_count": len(results),
|
||||
"accepted_request_ids": accepted,
|
||||
"requests": results,
|
||||
"notes": [
|
||||
request_catalog.get("notes", ""),
|
||||
"Pilot requests map product-level tunables into canonical pricing configuration fields before running the generic solver.",
|
||||
"For Coulomb's hybrid prototype, selected included token values are treated as total package allowances rather than per-seat multipliers.",
|
||||
],
|
||||
}
|
||||
)
|
||||
48
projects/coulomb-pricing/observatory/usage.py
Normal file
48
projects/coulomb-pricing/observatory/usage.py
Normal file
@@ -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),
|
||||
}
|
||||
81
projects/coulomb-pricing/reports/economics-2026-06.md
Normal file
81
projects/coulomb-pricing/reports/economics-2026-06.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Economics Dashboard v1 — Coulomb Social Membership
|
||||
|
||||
**Period:** 2026-06
|
||||
**Lifecycle phase:** growth
|
||||
**Active pricing model:** Standard Membership (8.99 EUR/monthly)
|
||||
|
||||
> Platform costs accrue to the operator. Customer cost-pass-through billing is
|
||||
> **not active** in MVP — members pay subscription only. All totals are computed
|
||||
> programmatically from expense and payment record ledgers (58 expense
|
||||
> records).
|
||||
|
||||
## Key Metrics (current period)
|
||||
|
||||
| Metric | Value |
|
||||
|--------|------:|
|
||||
| Active members | 1 |
|
||||
| Member payments (gross) | 8.99 EUR |
|
||||
| Infrastructure cost | 29.73 EUR |
|
||||
| Payment processing cost | 0.44 EUR |
|
||||
| Total platform cost | 30.17 EUR |
|
||||
| Platform cost per member | 30.17 EUR |
|
||||
| Period gross margin | -21.18 EUR |
|
||||
| Period gross margin % | -235.6% |
|
||||
| Period net liquidity | -21.18 EUR (burning) |
|
||||
|
||||
_Period net liquidity = net member payments − infrastructure cost (processing fees already netted from payments)._
|
||||
_Revenue source: stripe_
|
||||
|
||||
## Liquidity & Budget (through 2026-06)
|
||||
|
||||
| Metric | Value |
|
||||
|--------|------:|
|
||||
| Initial budget | 1000.00 EUR |
|
||||
| Cumulative member payments (net) | 68.40 EUR |
|
||||
| Cumulative infrastructure cost | 409.28 EUR |
|
||||
| Cumulative payment processing | 3.52 EUR |
|
||||
| Cumulative total platform cost | 412.80 EUR |
|
||||
| Cumulative net liquidity | -340.88 EUR (burning) |
|
||||
| Remaining budget | 659.12 EUR (within budget) |
|
||||
| Months tracked | 18 |
|
||||
|
||||
## Monthly History
|
||||
|
||||
| Period | Members | Gross revenue | Infrastructure | Processing | Total platform | Net liquidity |
|
||||
|--------|--------:|--------------:|---------------:|-----------:|---------------:|--------------:|
|
||||
| 2025-01 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
|
||||
| 2025-02 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
|
||||
| 2025-03 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
|
||||
| 2025-04 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
|
||||
| 2025-05 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
|
||||
| 2025-06 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
|
||||
| 2025-07 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
|
||||
| 2025-08 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
|
||||
| 2025-09 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
|
||||
| 2025-10 | 0 | 0.00 | 20.74 | 0.00 | 20.74 | -20.74 |
|
||||
| 2025-11 | 1 | 8.99 | 20.74 | 0.44 | 21.18 | -12.19 |
|
||||
| 2025-12 | 1 | 8.99 | 20.74 | 0.44 | 21.18 | -12.19 |
|
||||
| 2026-01 | 1 | 8.99 | 20.74 | 0.44 | 21.18 | -12.19 |
|
||||
| 2026-02 | 1 | 8.99 | 20.74 | 0.44 | 21.18 | -12.19 |
|
||||
| 2026-03 | 1 | 8.99 | 29.73 | 0.44 | 30.17 | -21.18 |
|
||||
| 2026-04 | 1 | 8.99 | 29.73 | 0.44 | 30.17 | -21.18 |
|
||||
| 2026-05 | 1 | 8.99 | 29.73 | 0.44 | 30.17 | -21.18 |
|
||||
| 2026-06 | 1 | 8.99 | 29.73 | 0.44 | 30.17 | -21.18 |
|
||||
|
||||
## 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`)
|
||||
- Budget (`data/budget.json`)
|
||||
- Expense records (`data/expense_records.json`) — source of truth for costs
|
||||
- Infrastructure catalog (`data/infrastructure/`) — domain, VPS, and Stripe reference data
|
||||
- Payment records (`data/payment_records.json`)
|
||||
- Membership (`data/membership.json`)
|
||||
- Requirements (`REQUIREMENTS.md`)
|
||||
47
projects/coulomb-pricing/scripts/sync-whynot-design.sh
Executable file
47
projects/coulomb-pricing/scripts/sync-whynot-design.sh
Executable file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env bash
|
||||
# Synchronises the vendored copy of whynot-design from a pinned upstream commit.
|
||||
# Source: ~/whynot-design (worktree) or a clone from gitea.
|
||||
#
|
||||
# Usage: ./scripts/sync-whynot-design.sh [<commit-or-ref>]
|
||||
# Default: reads .whynot-design-ref from the vendor directory, else HEAD.
|
||||
#
|
||||
# Pulls Layer 1 (tokens + CSS) and Layer 2 (Lit web components) so the
|
||||
# observatory UI picks up design-system changes on re-run.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENDOR_DIR="$ROOT/ui/vendor/whynot-design"
|
||||
REF_FILE="$VENDOR_DIR/.whynot-design-ref"
|
||||
SRC_REPO="${WHYNOT_DESIGN_SRC:-$HOME/whynot-design}"
|
||||
|
||||
REF="${1:-}"
|
||||
if [[ -z "$REF" && -f "$REF_FILE" ]]; then
|
||||
REF="$(cat "$REF_FILE")"
|
||||
fi
|
||||
if [[ -z "$REF" ]]; then
|
||||
REF="HEAD"
|
||||
fi
|
||||
|
||||
if [[ ! -d "$SRC_REPO/.git" ]]; then
|
||||
echo "Source not found: $SRC_REPO" >&2
|
||||
echo "Set WHYNOT_DESIGN_SRC or clone gitea:whynot/whynot-design there." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$VENDOR_DIR/tokens" "$VENDOR_DIR/elements"
|
||||
|
||||
git -C "$SRC_REPO" show "$REF:src/styles/colors_and_type.css" > "$VENDOR_DIR/colors_and_type.css"
|
||||
git -C "$SRC_REPO" show "$REF:src/styles/components.css" > "$VENDOR_DIR/components.css"
|
||||
git -C "$SRC_REPO" show "$REF:src/index.js" > "$VENDOR_DIR/index.js"
|
||||
|
||||
for f in atoms.js chrome.js form.js icons.js layout.js _styles.js; do
|
||||
git -C "$SRC_REPO" show "$REF:src/elements/$f" > "$VENDOR_DIR/elements/$f"
|
||||
done
|
||||
|
||||
for f in colors.json type.json spacing.json index.json; do
|
||||
git -C "$SRC_REPO" show "$REF:tokens/$f" > "$VENDOR_DIR/tokens/$f"
|
||||
done
|
||||
|
||||
git -C "$SRC_REPO" rev-parse "$REF" > "$REF_FILE"
|
||||
|
||||
echo "Vendor synced → $VENDOR_DIR (ref: $(cat "$REF_FILE"))"
|
||||
10
projects/coulomb-pricing/tests/conftest.py
Normal file
10
projects/coulomb-pricing/tests/conftest.py
Normal file
@@ -0,0 +1,10 @@
|
||||
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))
|
||||
|
||||
REPO_ROOT = ROOT.parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
51
projects/coulomb-pricing/tests/test_api.py
Normal file
51
projects/coulomb-pricing/tests/test_api.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from observatory.api import build_dashboard_payload, payload_json
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
|
||||
|
||||
def test_dashboard_payload_contains_live_ledger_totals() -> None:
|
||||
payload = build_dashboard_payload(DATA_DIR, "2026-06")
|
||||
|
||||
assert payload["period"] == "2026-06"
|
||||
assert payload["liquidity"]["remaining_budget"] == "659.12"
|
||||
assert payload["liquidity"]["cumulative_infrastructure_cost"] == "409.28"
|
||||
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
|
||||
assert payload["membership_analytics"]["active_members"] == 1
|
||||
assert payload["usage"]["record_count"] == 1
|
||||
assert len(payload["pricing_simulations"]["scenarios"]) == 3
|
||||
assert len(payload["pricing_simulations"]["profile_comparisons"]) == 2
|
||||
assert payload["pricing_simulations"]["primary_profile_id"] == "solo-builder"
|
||||
assert payload["pricing_simulations"]["required_improvement_factor"] == "1.05"
|
||||
assert payload["pricing_simulations"]["reference_model_id"] is not None
|
||||
assert payload["customer_tuning"]["request_count"] == 2
|
||||
assert payload["customer_tuning"]["accepted_request_ids"] == ["small-team-lower-usage-price"]
|
||||
assert payload["provider_publication"]["provider"] == "stripe"
|
||||
assert payload["provider_publication"]["model_id"] == "flat-899-eur-monthly"
|
||||
assert payload["provider_publication"]["plan"]["summary"].startswith("stripe:")
|
||||
assert payload["governance"]["policy"]["policy_id"] == "coulomb-governance-v1"
|
||||
assert payload["governance"]["publication_assessment"]["decision"] == "approval_required"
|
||||
assert payload["governance"]["safe_tuning_contracts"]
|
||||
assert len(payload["boundary_validation"]["model_results"]) == 3
|
||||
assert payload["boundary_validation"]["policy"]["target_margin_pct"] == "15"
|
||||
assert any(
|
||||
result["decision"] == "rejected"
|
||||
for result in payload["boundary_validation"]["model_results"]
|
||||
)
|
||||
assert payload["recommendations"]
|
||||
assert all("confidence" in item and "governance" in item for item in payload["recommendations"])
|
||||
|
||||
|
||||
def test_payload_json_is_valid() -> None:
|
||||
parsed = json.loads(payload_json(DATA_DIR, "2026-06"))
|
||||
assert Decimal(parsed["payments"][0]["fees_amount"]) == Decimal("0.44")
|
||||
168
projects/coulomb-pricing/tests/test_boundary_engine.py
Normal file
168
projects/coulomb-pricing/tests/test_boundary_engine.py
Normal file
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from adaptive_pricing_core.boundary_engine import (
|
||||
BoundaryPolicy,
|
||||
CommitmentTerms,
|
||||
PricingConfiguration,
|
||||
validate_pricing_configuration,
|
||||
)
|
||||
from observatory.load import load_pricing_models
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
|
||||
|
||||
def _model(model_id: str):
|
||||
return next(item for item in load_pricing_models(DATA_DIR) if item.id == model_id)
|
||||
|
||||
|
||||
def test_commitment_backed_discount_is_accepted_when_economics_stay_strong() -> None:
|
||||
result = validate_pricing_configuration(
|
||||
PricingConfiguration(
|
||||
model=_model("membership-plus-overage"),
|
||||
segment="coulomb-social-members",
|
||||
expected_usage_units=Decimal("100200"),
|
||||
expected_usage_variance_pct=Decimal("25"),
|
||||
allocated_fixed_cost=Decimal("2.00"),
|
||||
unit_cost=Decimal("0.00000125"),
|
||||
payment_fee_rate_pct=Decimal("5"),
|
||||
usage_unit_price=Decimal("0.0015"),
|
||||
commitment_terms=CommitmentTerms(
|
||||
contract_duration_months=6,
|
||||
minimum_monthly_turnover=Decimal("9.30"),
|
||||
),
|
||||
),
|
||||
BoundaryPolicy(),
|
||||
)
|
||||
|
||||
assert result.decision == "accepted"
|
||||
assert result.valid is True
|
||||
assert result.requires_approval is False
|
||||
commitment_result = next(
|
||||
item for item in result.constraints if item.id == "commitment-backed-concession"
|
||||
)
|
||||
assert commitment_result.status == "pass"
|
||||
assert "minimum_monthly_turnover" in commitment_result.details["signals"]
|
||||
|
||||
|
||||
def test_discount_without_commitment_is_rejected() -> None:
|
||||
result = validate_pricing_configuration(
|
||||
PricingConfiguration(
|
||||
model=_model("membership-plus-overage"),
|
||||
segment="coulomb-social-members",
|
||||
expected_usage_units=Decimal("100200"),
|
||||
expected_usage_variance_pct=Decimal("25"),
|
||||
allocated_fixed_cost=Decimal("2.00"),
|
||||
unit_cost=Decimal("0.00000125"),
|
||||
payment_fee_rate_pct=Decimal("5"),
|
||||
usage_unit_price=Decimal("0.0015"),
|
||||
),
|
||||
BoundaryPolicy(),
|
||||
)
|
||||
|
||||
assert result.decision == "rejected"
|
||||
assert result.valid is False
|
||||
failing_ids = {
|
||||
item.id for item in result.constraints if item.status == "fail" and item.severity == "hard"
|
||||
}
|
||||
assert "commitment-backed-concession" in failing_ids
|
||||
|
||||
|
||||
def test_weak_commitment_trade_is_rejected() -> None:
|
||||
result = validate_pricing_configuration(
|
||||
PricingConfiguration(
|
||||
model=_model("membership-plus-overage"),
|
||||
segment="coulomb-social-members",
|
||||
expected_usage_units=Decimal("100200"),
|
||||
expected_usage_variance_pct=Decimal("25"),
|
||||
allocated_fixed_cost=Decimal("2.00"),
|
||||
unit_cost=Decimal("0.00000125"),
|
||||
payment_fee_rate_pct=Decimal("5"),
|
||||
usage_unit_price=Decimal("0.0015"),
|
||||
commitment_terms=CommitmentTerms(
|
||||
contract_duration_months=2,
|
||||
minimum_monthly_turnover=Decimal("9.00"),
|
||||
),
|
||||
),
|
||||
BoundaryPolicy(),
|
||||
)
|
||||
|
||||
assert result.decision == "rejected"
|
||||
commitment_result = next(
|
||||
item for item in result.constraints if item.id == "commitment-backed-concession"
|
||||
)
|
||||
assert commitment_result.status == "fail"
|
||||
|
||||
|
||||
def test_large_but_supported_concession_requires_approval() -> None:
|
||||
result = validate_pricing_configuration(
|
||||
PricingConfiguration(
|
||||
model=_model("flat-899-eur-monthly"),
|
||||
segment="coulomb-social-members",
|
||||
allocated_fixed_cost=Decimal("1.00"),
|
||||
payment_fee_rate_pct=Decimal("5"),
|
||||
access_fee_amount=Decimal("7.50"),
|
||||
commitment_terms=CommitmentTerms(
|
||||
contract_duration_months=6,
|
||||
minimum_monthly_turnover=Decimal("8.00"),
|
||||
),
|
||||
),
|
||||
BoundaryPolicy(),
|
||||
)
|
||||
|
||||
assert result.decision == "requires_approval"
|
||||
assert result.valid is True
|
||||
assert result.requires_approval is True
|
||||
review_ids = {item.id for item in result.constraints if item.status == "review"}
|
||||
assert "discount-approval-threshold" in review_ids
|
||||
|
||||
|
||||
def test_zero_usage_flat_configuration_can_pass() -> None:
|
||||
result = validate_pricing_configuration(
|
||||
PricingConfiguration(
|
||||
model=_model("flat-899-eur-monthly"),
|
||||
segment="coulomb-social-members",
|
||||
expected_usage_units=Decimal("0"),
|
||||
expected_usage_variance_pct=Decimal("0"),
|
||||
allocated_fixed_cost=Decimal("3.00"),
|
||||
payment_fee_rate_pct=Decimal("5"),
|
||||
),
|
||||
BoundaryPolicy(),
|
||||
)
|
||||
|
||||
assert result.decision == "accepted"
|
||||
assert result.metrics.billable_usage_units == Decimal("0")
|
||||
|
||||
|
||||
def test_high_fee_configuration_is_rejected() -> None:
|
||||
result = validate_pricing_configuration(
|
||||
PricingConfiguration(
|
||||
model=_model("flat-899-eur-monthly"),
|
||||
segment="coulomb-social-members",
|
||||
allocated_fixed_cost=Decimal("1.00"),
|
||||
payment_fee_rate_pct=Decimal("30"),
|
||||
),
|
||||
BoundaryPolicy(),
|
||||
)
|
||||
|
||||
assert result.decision == "rejected"
|
||||
payment_fee_result = next(item for item in result.constraints if item.id == "payment-fee-limit")
|
||||
assert payment_fee_result.status == "fail"
|
||||
|
||||
|
||||
def test_unprofitable_configuration_is_rejected() -> None:
|
||||
result = validate_pricing_configuration(
|
||||
PricingConfiguration(
|
||||
model=_model("flat-899-eur-monthly"),
|
||||
segment="coulomb-social-members",
|
||||
allocated_fixed_cost=Decimal("10.00"),
|
||||
payment_fee_rate_pct=Decimal("5"),
|
||||
),
|
||||
BoundaryPolicy(),
|
||||
)
|
||||
|
||||
assert result.decision == "rejected"
|
||||
assert result.metrics.monthly_margin < Decimal("0")
|
||||
assert any(item.id == "cost-floor-coverage" and item.status == "fail" for item in result.constraints)
|
||||
69
projects/coulomb-pricing/tests/test_comparable_ltv.py
Normal file
69
projects/coulomb-pricing/tests/test_comparable_ltv.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from observatory.economics import build_snapshot
|
||||
from observatory.load import (
|
||||
load_ltv_scenarios,
|
||||
load_membership,
|
||||
load_monthly_ledger,
|
||||
load_payment_records,
|
||||
load_pricing_models,
|
||||
load_product,
|
||||
)
|
||||
from observatory.simulator import build_pricing_simulations
|
||||
from observatory.usage import 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_simulations_include_reference_model_and_profile_comparisons() -> None:
|
||||
snapshot = _snapshot()
|
||||
models = load_pricing_models(DATA_DIR)
|
||||
simulations = build_pricing_simulations(
|
||||
snapshot,
|
||||
models,
|
||||
snapshot.cost_per_member,
|
||||
usage_records=load_usage_records(DATA_DIR),
|
||||
scenario_catalog=load_ltv_scenarios(DATA_DIR),
|
||||
)
|
||||
|
||||
assert simulations["primary_profile_id"] == "solo-builder"
|
||||
assert simulations["reference_model_id"] is not None
|
||||
assert simulations["best_ltv_scenario_id"] is not None
|
||||
assert len(simulations["profile_comparisons"]) == 2
|
||||
assert simulations["scenarios"][0]["average_comparable_customer_lifetime_value"] is not None
|
||||
assert simulations["scenarios"][0]["sensitivity"]
|
||||
|
||||
|
||||
def test_small_team_profile_has_reference_and_non_passing_candidates() -> None:
|
||||
snapshot = _snapshot()
|
||||
models = load_pricing_models(DATA_DIR)
|
||||
simulations = build_pricing_simulations(
|
||||
snapshot,
|
||||
models,
|
||||
snapshot.cost_per_member,
|
||||
usage_records=load_usage_records(DATA_DIR),
|
||||
scenario_catalog=load_ltv_scenarios(DATA_DIR),
|
||||
)
|
||||
|
||||
small_team = next(
|
||||
item for item in simulations["profile_comparisons"] if item["profile"]["id"] == "small-team"
|
||||
)
|
||||
|
||||
assert small_team["reference_model_id"] is not None
|
||||
assert small_team["best_valid_model_id"] is not None
|
||||
assert any(
|
||||
not comparison["passes_required_improvement"]
|
||||
for comparison in small_team["comparisons"]
|
||||
if comparison["model_id"] != small_team["reference_model_id"]
|
||||
)
|
||||
149
projects/coulomb-pricing/tests/test_customer_tuning.py
Normal file
149
projects/coulomb-pricing/tests/test_customer_tuning.py
Normal file
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from adaptive_pricing_core.customer_tuning import CustomerTuningRequest, solve_customer_tuning
|
||||
from observatory.boundary import build_boundary_policy
|
||||
from observatory.economics import build_snapshot
|
||||
from observatory.load import (
|
||||
load_ltv_scenarios,
|
||||
load_membership,
|
||||
load_monthly_ledger,
|
||||
load_payment_records,
|
||||
load_pricing_models,
|
||||
load_product,
|
||||
)
|
||||
from observatory.ltv import _configuration, _ltv_policy, _profile, _usage_unit_cost
|
||||
from observatory.tuning import build_customer_tuning_pilot
|
||||
from observatory.usage import load_usage_records
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
|
||||
|
||||
def _scenario_inputs(profile_id: str = "small-team"):
|
||||
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)
|
||||
snapshot = build_snapshot("2026-06", product, models, members, payments, ledger)
|
||||
usage_records = load_usage_records(DATA_DIR)
|
||||
scenario_catalog = load_ltv_scenarios(DATA_DIR)
|
||||
profile = _profile(next(item for item in scenario_catalog["profiles"] if item["id"] == profile_id))
|
||||
observed_usage_unit_cost = _usage_unit_cost(usage_records, snapshot.period)
|
||||
return snapshot, models, usage_records, scenario_catalog, profile, observed_usage_unit_cost
|
||||
|
||||
|
||||
def test_lower_usage_price_request_can_stay_seller_safe() -> None:
|
||||
(
|
||||
snapshot,
|
||||
models,
|
||||
usage_records,
|
||||
scenario_catalog,
|
||||
profile,
|
||||
observed_usage_unit_cost,
|
||||
) = _scenario_inputs()
|
||||
model = next(item for item in models if item.id == "membership-plus-overage")
|
||||
outcome = solve_customer_tuning(
|
||||
_configuration(model, profile, snapshot, observed_usage_unit_cost),
|
||||
[
|
||||
_configuration(candidate, profile, snapshot, observed_usage_unit_cost)
|
||||
for candidate in models
|
||||
if candidate.status in ("active", "candidate")
|
||||
],
|
||||
profile,
|
||||
build_boundary_policy(snapshot),
|
||||
_ltv_policy(scenario_catalog),
|
||||
CustomerTuningRequest(
|
||||
included_units=Decimal("50000"),
|
||||
contract_duration_months=3,
|
||||
preference="lower_usage_price",
|
||||
approval_mode="self_serve_only",
|
||||
),
|
||||
)
|
||||
|
||||
assert outcome.decision == "accepted"
|
||||
assert outcome.reference_model_id == "flat-899-eur-monthly"
|
||||
assert outcome.passes_required_improvement is True
|
||||
assert outcome.solved_usage_unit_price < Decimal("0.002")
|
||||
assert "lower_included_usage" in outcome.tradeoffs
|
||||
assert "longer_contract_duration" in outcome.tradeoffs
|
||||
|
||||
|
||||
def test_high_included_request_is_rejected_for_self_serve() -> None:
|
||||
(
|
||||
snapshot,
|
||||
models,
|
||||
_usage_records,
|
||||
scenario_catalog,
|
||||
profile,
|
||||
observed_usage_unit_cost,
|
||||
) = _scenario_inputs()
|
||||
model = next(item for item in models if item.id == "membership-plus-overage")
|
||||
outcome = solve_customer_tuning(
|
||||
_configuration(model, profile, snapshot, observed_usage_unit_cost),
|
||||
[
|
||||
_configuration(candidate, profile, snapshot, observed_usage_unit_cost)
|
||||
for candidate in models
|
||||
if candidate.status in ("active", "candidate")
|
||||
],
|
||||
profile,
|
||||
build_boundary_policy(snapshot),
|
||||
_ltv_policy(scenario_catalog),
|
||||
CustomerTuningRequest(
|
||||
included_units=Decimal("150000"),
|
||||
contract_duration_months=3,
|
||||
preference="lower_usage_price",
|
||||
approval_mode="self_serve_only",
|
||||
),
|
||||
)
|
||||
|
||||
assert outcome.decision == "rejected"
|
||||
assert outcome.passes_required_improvement is True
|
||||
assert any(
|
||||
constraint.id == "discount-exposure-limit"
|
||||
for constraint in outcome.binding_constraints
|
||||
)
|
||||
|
||||
|
||||
def test_customer_tuning_pilot_surfaces_accepted_and_rejected_requests() -> None:
|
||||
snapshot, models, usage_records, scenario_catalog, _profile_data, _usage_unit_cost_value = _scenario_inputs()
|
||||
pilot = build_customer_tuning_pilot(
|
||||
snapshot,
|
||||
models,
|
||||
usage_records,
|
||||
scenario_catalog,
|
||||
{
|
||||
"requests": [
|
||||
{
|
||||
"id": "accepted",
|
||||
"name": "Accepted",
|
||||
"profile_id": "small-team",
|
||||
"model_id": "membership-plus-overage",
|
||||
"preference": "lower_usage_price",
|
||||
"approval_mode": "self_serve_only",
|
||||
"selected_tunables": {
|
||||
"included_tokens": "50000",
|
||||
"contract_duration_months": 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "rejected",
|
||||
"name": "Rejected",
|
||||
"profile_id": "small-team",
|
||||
"model_id": "membership-plus-overage",
|
||||
"preference": "lower_usage_price",
|
||||
"approval_mode": "self_serve_only",
|
||||
"selected_tunables": {
|
||||
"included_tokens": "150000",
|
||||
"contract_duration_months": 3,
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
assert pilot["request_count"] == 2
|
||||
assert pilot["accepted_request_ids"] == ["accepted"]
|
||||
assert {item["result"]["decision"] for item in pilot["requests"]} == {"accepted", "rejected"}
|
||||
117
projects/coulomb-pricing/tests/test_economics.py
Normal file
117
projects/coulomb-pricing/tests/test_economics.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from observatory.economics import active_members, build_liquidity_summary, build_snapshot
|
||||
from observatory.ledger import aggregate_infrastructure_by_period, build_monthly_ledger
|
||||
from observatory.load import (
|
||||
load_budget,
|
||||
load_expense_records,
|
||||
load_fx_rates,
|
||||
load_membership,
|
||||
load_monthly_ledger,
|
||||
load_payment_records,
|
||||
load_pricing_models,
|
||||
load_product,
|
||||
)
|
||||
|
||||
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) == 1
|
||||
|
||||
|
||||
def test_infrastructure_aggregated_from_domain_expense_records() -> None:
|
||||
expenses = load_expense_records(DATA_DIR)
|
||||
fx = load_fx_rates(DATA_DIR)
|
||||
totals = aggregate_infrastructure_by_period(expenses, fx)
|
||||
|
||||
assert totals["2025-01"] == Decimal("20.74")
|
||||
assert totals["2026-02"] == Decimal("20.74")
|
||||
assert totals["2026-03"] == Decimal("29.73")
|
||||
assert totals["2026-06"] == Decimal("29.73")
|
||||
|
||||
|
||||
def test_monthly_ledger_starts_january_2025() -> None:
|
||||
ledger = load_monthly_ledger(DATA_DIR)
|
||||
assert ledger[0].period == "2025-01"
|
||||
assert len(ledger) == 18
|
||||
|
||||
march = next(row for row in ledger if row.period == "2025-03")
|
||||
june = next(row for row in ledger if row.period == "2026-06")
|
||||
|
||||
assert march.infrastructure_cost == Decimal("20.74")
|
||||
assert march.payment_processing_cost == Decimal("0.00")
|
||||
assert june.infrastructure_cost == Decimal("29.73")
|
||||
assert june.payment_processing_cost == Decimal("0.44")
|
||||
assert june.gross_revenue == Decimal("8.99")
|
||||
|
||||
|
||||
def test_build_snapshot_june_2026_domain_only_infrastructure() -> None:
|
||||
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)
|
||||
|
||||
snapshot = build_snapshot("2026-06", product, models, members, payments, ledger)
|
||||
|
||||
assert snapshot.monthly_infrastructure_cost == Decimal("29.73")
|
||||
assert snapshot.monthly_payment_processing_cost == Decimal("0.44")
|
||||
assert snapshot.monthly_total_platform_cost == Decimal("30.17")
|
||||
assert snapshot.period_net_liquidity == Decimal("-21.18")
|
||||
assert snapshot.liquidity_status == "burning"
|
||||
|
||||
|
||||
def test_liquidity_summary_with_actual_domain_costs() -> None:
|
||||
budget = load_budget(DATA_DIR)
|
||||
payments = load_payment_records(DATA_DIR)
|
||||
ledger = load_monthly_ledger(DATA_DIR)
|
||||
|
||||
summary = build_liquidity_summary(budget, payments, ledger, "2026-06")
|
||||
|
||||
assert summary.cumulative_infrastructure_cost == Decimal("409.28")
|
||||
assert summary.cumulative_payment_processing_cost == Decimal("3.52")
|
||||
assert summary.cumulative_total_platform_cost == Decimal("412.80")
|
||||
assert summary.cumulative_member_payments == Decimal("68.40")
|
||||
assert summary.cumulative_net_liquidity == Decimal("-340.88")
|
||||
assert summary.remaining_budget == Decimal("659.12")
|
||||
assert summary.liquidity_status == "burning"
|
||||
assert summary.months_tracked == 18
|
||||
|
||||
|
||||
def test_build_monthly_ledger_matches_loader() -> None:
|
||||
budget = load_budget(DATA_DIR)
|
||||
expenses = load_expense_records(DATA_DIR)
|
||||
payments = load_payment_records(DATA_DIR)
|
||||
members = load_membership(DATA_DIR)
|
||||
fx = load_fx_rates(DATA_DIR)
|
||||
|
||||
assert build_monthly_ledger(budget, expenses, payments, members, fx) == load_monthly_ledger(
|
||||
DATA_DIR
|
||||
)
|
||||
|
||||
|
||||
def test_payment_records_match_stripe_actuals_for_tegwick() -> None:
|
||||
payments = load_payment_records(DATA_DIR)
|
||||
nov = next(p for p in payments if p.period == "2025-11")
|
||||
|
||||
assert nov.gross_amount == Decimal("8.99")
|
||||
assert nov.fees_amount == Decimal("0.44")
|
||||
assert nov.net_amount == Decimal("8.55")
|
||||
assert nov.member_username == "tegwick"
|
||||
assert nov.payout_account == "binky-hedgehog"
|
||||
assert nov.source == "stripe"
|
||||
|
||||
|
||||
def test_dashboard_notes_expense_record_source() -> None:
|
||||
from observatory.dashboard import generate_dashboard
|
||||
|
||||
report = generate_dashboard(DATA_DIR, "2026-06")
|
||||
assert "expense and payment record ledgers" in report
|
||||
assert "409.28" in report
|
||||
assert "659.12" in report
|
||||
assert "coulomb.social" not in report # dashboard shows aggregates, not domain names
|
||||
48
projects/coulomb-pricing/tests/test_governance.py
Normal file
48
projects/coulomb-pricing/tests/test_governance.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from observatory.api import build_dashboard_payload
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
|
||||
|
||||
def test_governance_payload_contains_policy_health_and_audit_surfaces() -> None:
|
||||
payload = build_dashboard_payload(DATA_DIR, "2026-06")
|
||||
governance = payload["governance"]
|
||||
|
||||
assert governance["policy"]["policy_id"] == "coulomb-governance-v1"
|
||||
assert governance["publication_assessment"]["decision"] == "approval_required"
|
||||
assert governance["audit_surface"]["provider"] == "stripe"
|
||||
assert governance["audit_surface"]["revision_count"] == 0
|
||||
assert any(
|
||||
check["id"] == "provider-execution-health" and check["status"] == "warn"
|
||||
for check in governance["health_checks"]
|
||||
)
|
||||
|
||||
|
||||
def test_safe_tuning_contract_stays_pilot_only_and_hides_customer_visibility() -> None:
|
||||
payload = build_dashboard_payload(DATA_DIR, "2026-06")
|
||||
contract = next(
|
||||
item
|
||||
for item in payload["governance"]["safe_tuning_contracts"]
|
||||
if item["model_id"] == "membership-plus-overage"
|
||||
)
|
||||
|
||||
assert contract["mode"] == "pilot_only"
|
||||
assert contract["customer_visible"] is False
|
||||
assert any(example["outcome"] == "accepted" for example in contract["examples"])
|
||||
assert all(example["visible_to_customer"] is False for example in contract["examples"])
|
||||
|
||||
|
||||
def test_recommendations_include_governed_execution_gate() -> None:
|
||||
payload = build_dashboard_payload(DATA_DIR, "2026-06")
|
||||
execution_gate = next(
|
||||
item for item in payload["recommendations"] if item["id"] == "execution-governance-gate"
|
||||
)
|
||||
|
||||
assert execution_gate["recommendation_type"] == "execution"
|
||||
assert execution_gate["governance"]["decision"] == "approval_required"
|
||||
assert execution_gate["confidence"] == "0.88"
|
||||
assert execution_gate["risks"]
|
||||
assert execution_gate["supporting_observations"]
|
||||
42
projects/coulomb-pricing/tests/test_importers.py
Normal file
42
projects/coulomb-pricing/tests/test_importers.py
Normal file
@@ -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"
|
||||
105
projects/coulomb-pricing/tests/test_mvp_sprints.py
Normal file
105
projects/coulomb-pricing/tests/test_mvp_sprints.py
Normal file
@@ -0,0 +1,105 @@
|
||||
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_ltv_scenarios,
|
||||
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"),
|
||||
usage_records=load_usage_records(DATA_DIR),
|
||||
scenario_catalog=load_ltv_scenarios(DATA_DIR),
|
||||
)
|
||||
|
||||
assert len(simulations["scenarios"]) == 3
|
||||
assert simulations["active_scenario_id"] == "flat-899-eur-monthly"
|
||||
assert simulations["best_ltv_scenario_id"] is not None
|
||||
assert simulations["reference_model_id"] is not None
|
||||
|
||||
|
||||
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
|
||||
61
projects/coulomb-pricing/tests/test_pricing_context.py
Normal file
61
projects/coulomb-pricing/tests/test_pricing_context.py
Normal file
@@ -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")
|
||||
35
projects/coulomb-pricing/tests/test_pricing_model_schema.py
Normal file
35
projects/coulomb-pricing/tests/test_pricing_model_schema.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from adaptive_pricing_core.pricing_models import validate_pricing_catalog
|
||||
from observatory.load import load_pricing_models
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
|
||||
|
||||
def test_coulomb_pricing_catalog_validates() -> None:
|
||||
models = load_pricing_models(DATA_DIR)
|
||||
assert validate_pricing_catalog(models) == {}
|
||||
|
||||
|
||||
def test_hybrid_model_preserves_usage_component_and_tuning_metadata() -> None:
|
||||
models = load_pricing_models(DATA_DIR)
|
||||
model = next(item for item in models if item.id == "membership-plus-overage")
|
||||
|
||||
usage_component = next(component for component in model.charge_components if component.kind == "usage")
|
||||
|
||||
assert usage_component.meter == "openrouter_tokens"
|
||||
assert usage_component.included_units == Decimal("100000")
|
||||
assert usage_component.unit_price == Decimal("0.002")
|
||||
assert any(parameter.parameter_class == "customer_tunable" for parameter in model.tunable_parameters)
|
||||
|
||||
|
||||
def test_flat_model_still_exposes_access_fee_compatibility_fields() -> None:
|
||||
models = load_pricing_models(DATA_DIR)
|
||||
model = next(item for item in models if item.id == "flat-899-eur-monthly")
|
||||
|
||||
assert model.access_fee_amount == Decimal("8.99")
|
||||
assert model.access_fee_cadence == "monthly"
|
||||
assert len(model.charge_components) == 1
|
||||
124
projects/coulomb-pricing/tests/test_publication.py
Normal file
124
projects/coulomb-pricing/tests/test_publication.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from observatory.load import load_pricing_models, load_product
|
||||
from observatory.publication import (
|
||||
build_stripe_publication_preview,
|
||||
publish_to_stripe_shadow,
|
||||
rollback_stripe_shadow,
|
||||
)
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
|
||||
|
||||
def _catalog():
|
||||
return load_product(DATA_DIR), load_pricing_models(DATA_DIR)
|
||||
|
||||
|
||||
def test_overage_preview_includes_meter_and_approximate_mapping(tmp_path: Path) -> None:
|
||||
product, models = _catalog()
|
||||
preview = build_stripe_publication_preview(
|
||||
product,
|
||||
models,
|
||||
DATA_DIR,
|
||||
model_id="membership-plus-overage",
|
||||
state_path=tmp_path / "stripe-state.json",
|
||||
)
|
||||
|
||||
assert preview["artifact_counts"]["exact"] >= 3
|
||||
assert preview["artifact_counts"]["approximate"] >= 1
|
||||
assert preview["artifact_counts"]["unsupported"] == 0
|
||||
assert any(
|
||||
operation["provider_object_type"] == "billing_meter"
|
||||
for operation in preview["plan"]["operations"]
|
||||
)
|
||||
|
||||
|
||||
def test_credit_allowance_preview_marks_unsupported_usage_mapping(tmp_path: Path) -> None:
|
||||
product, models = _catalog()
|
||||
preview = build_stripe_publication_preview(
|
||||
product,
|
||||
models,
|
||||
DATA_DIR,
|
||||
model_id="membership-plus-credits",
|
||||
state_path=tmp_path / "stripe-state.json",
|
||||
)
|
||||
|
||||
unsupported = {
|
||||
artifact["source_key"]
|
||||
for artifact in preview["plan"]["unsupported_artifacts"]
|
||||
}
|
||||
assert "price:membership-plus-credits:ai-credit-allowance" in unsupported
|
||||
assert preview["artifact_counts"]["unsupported"] >= 1
|
||||
|
||||
|
||||
def test_publication_is_idempotent_and_detects_drift(tmp_path: Path) -> None:
|
||||
product, models = _catalog()
|
||||
state_path = tmp_path / "stripe-state.json"
|
||||
|
||||
publish_to_stripe_shadow(
|
||||
product,
|
||||
models,
|
||||
DATA_DIR,
|
||||
model_id="flat-899-eur-monthly",
|
||||
state_path=state_path,
|
||||
)
|
||||
preview = build_stripe_publication_preview(
|
||||
product,
|
||||
models,
|
||||
DATA_DIR,
|
||||
model_id="flat-899-eur-monthly",
|
||||
state_path=state_path,
|
||||
)
|
||||
|
||||
assert {operation["kind"] for operation in preview["plan"]["operations"]} == {"noop"}
|
||||
|
||||
raw_state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
price_artifact = next(
|
||||
artifact
|
||||
for artifact in raw_state["artifacts"]
|
||||
if artifact["provider_id"] == "price--flat-899-eur-monthly--membership-access"
|
||||
)
|
||||
price_artifact["payload"]["unit_amount_decimal"] = "9.49"
|
||||
state_path.write_text(json.dumps(raw_state, indent=2), encoding="utf-8")
|
||||
|
||||
drifted = build_stripe_publication_preview(
|
||||
product,
|
||||
models,
|
||||
DATA_DIR,
|
||||
model_id="flat-899-eur-monthly",
|
||||
state_path=state_path,
|
||||
)
|
||||
|
||||
assert drifted["plan"]["drift"]
|
||||
assert any(operation["kind"] == "update" for operation in drifted["plan"]["operations"])
|
||||
|
||||
|
||||
def test_publication_rollback_restores_prior_revision(tmp_path: Path) -> None:
|
||||
product, models = _catalog()
|
||||
state_path = tmp_path / "stripe-state.json"
|
||||
|
||||
first = publish_to_stripe_shadow(
|
||||
product,
|
||||
models,
|
||||
DATA_DIR,
|
||||
model_id="flat-899-eur-monthly",
|
||||
state_path=state_path,
|
||||
)
|
||||
publish_to_stripe_shadow(
|
||||
product,
|
||||
models,
|
||||
DATA_DIR,
|
||||
model_id="membership-plus-overage",
|
||||
state_path=state_path,
|
||||
)
|
||||
rollback = rollback_stripe_shadow(
|
||||
DATA_DIR,
|
||||
first["result"]["revision"]["revision_id"],
|
||||
state_path=state_path,
|
||||
)
|
||||
|
||||
assert rollback["result"]["state"]["active_model_id"] == "flat-899-eur-monthly"
|
||||
assert rollback["result"]["revision"]["summary"].startswith("Rolled back")
|
||||
65
projects/coulomb-pricing/tests/test_ui_vendor.py
Normal file
65
projects/coulomb-pricing/tests/test_ui_vendor.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import threading
|
||||
from http.server import HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
VENDOR = ROOT / "ui" / "vendor" / "whynot-design"
|
||||
|
||||
REQUIRED_VENDOR_FILES = [
|
||||
VENDOR / ".whynot-design-ref",
|
||||
VENDOR / "colors_and_type.css",
|
||||
VENDOR / "components.css",
|
||||
VENDOR / "index.js",
|
||||
VENDOR / "elements" / "atoms.js",
|
||||
VENDOR / "elements" / "chrome.js",
|
||||
VENDOR / "elements" / "form.js",
|
||||
VENDOR / "elements" / "layout.js",
|
||||
VENDOR / "elements" / "icons.js",
|
||||
VENDOR / "elements" / "_styles.js",
|
||||
VENDOR / "tokens" / "colors.json",
|
||||
]
|
||||
|
||||
|
||||
def test_vendor_tree_is_complete() -> None:
|
||||
missing = [path for path in REQUIRED_VENDOR_FILES if not path.exists()]
|
||||
assert not missing, f"Missing vendored whynot-design files: {missing}"
|
||||
|
||||
|
||||
def test_vendor_ref_is_pinned() -> None:
|
||||
ref = (VENDOR / ".whynot-design-ref").read_text(encoding="utf-8").strip()
|
||||
assert len(ref) == 40
|
||||
|
||||
|
||||
def test_server_serves_vendor_modules() -> None:
|
||||
server_module = importlib.import_module("observatory.server")
|
||||
handler = server_module.ObservatoryHandler
|
||||
handler.data_dir = ROOT / "data"
|
||||
|
||||
try:
|
||||
httpd = HTTPServer(("127.0.0.1", 0), handler)
|
||||
except PermissionError:
|
||||
pytest.skip("local socket binds are not permitted in this execution environment")
|
||||
port = httpd.server_address[1]
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
index = urllib.request.urlopen(f"http://127.0.0.1:{port}/", timeout=2)
|
||||
assert "wn-top-nav" in index.read().decode("utf-8")
|
||||
|
||||
module = urllib.request.urlopen(
|
||||
f"http://127.0.0.1:{port}/ui/vendor/whynot-design/index.js",
|
||||
timeout=2,
|
||||
)
|
||||
assert module.headers["Content-Type"].startswith("application/javascript")
|
||||
assert "defineAtoms" in module.read().decode("utf-8")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
thread.join(timeout=2)
|
||||
416
projects/coulomb-pricing/ui/app.js
Normal file
416
projects/coulomb-pricing/ui/app.js
Normal file
@@ -0,0 +1,416 @@
|
||||
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}`);
|
||||
if (!response.ok) throw new Error("Failed to load dashboard");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function setBadge(el, status) {
|
||||
el.textContent = status;
|
||||
el.active = status === "generating" || status === "neutral";
|
||||
el.draft = status === "burning";
|
||||
}
|
||||
|
||||
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 = [
|
||||
{
|
||||
label: "Remaining budget",
|
||||
value: euro(liquidity.remaining_budget),
|
||||
sub: `${liquidity.months_tracked} months tracked`,
|
||||
},
|
||||
{
|
||||
label: "Period net liquidity",
|
||||
value: euro(snapshot.period_net_liquidity),
|
||||
sub: snapshot.liquidity_status,
|
||||
},
|
||||
{
|
||||
label: "Cost per member",
|
||||
value: euro(data.cost_floor.cost_per_member),
|
||||
sub: `Platform ${euro(data.cost_floor.monthly_total_platform_cost)} / mo`,
|
||||
},
|
||||
{
|
||||
label: "Gross margin",
|
||||
value: euro(snapshot.gross_margin),
|
||||
sub: `${snapshot.gross_margin_pct}% of gross revenue`,
|
||||
},
|
||||
{
|
||||
label: "Infrastructure / month",
|
||||
value: euro(snapshot.monthly_infrastructure_cost),
|
||||
sub: `Processing ${euro(snapshot.monthly_payment_processing_cost)}`,
|
||||
},
|
||||
{
|
||||
label: "Active price",
|
||||
value: euro(data.cost_floor.active_price),
|
||||
sub: data.cost_floor.active_model_name ?? "—",
|
||||
},
|
||||
];
|
||||
|
||||
document.getElementById("metric-grid").innerHTML = cards
|
||||
.map(
|
||||
(card) => `
|
||||
<wn-card size="sm">
|
||||
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
|
||||
<div class="obs-metric__value">${card.value}</div>
|
||||
<span slot="footer">${card.sub}</span>
|
||||
</wn-card>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
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");
|
||||
document.getElementById("budget-fill").style.width = `${pct}%`;
|
||||
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 = [
|
||||
["Initial budget", euro(initial)],
|
||||
["Cumulative member payments (net)", euro(liquidity.cumulative_member_payments)],
|
||||
["Cumulative infrastructure", euro(liquidity.cumulative_infrastructure_cost)],
|
||||
["Cumulative processing", euro(liquidity.cumulative_payment_processing_cost)],
|
||||
]
|
||||
.map(
|
||||
([label, value]) => `
|
||||
<wn-field-row label="${label}" narrow>
|
||||
<span class="wn-field-row__value">${value}</span>
|
||||
</wn-field-row>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
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;
|
||||
const barClass = value < 0 ? "obs-liquidity-row__bar--neg" : "obs-liquidity-row__bar--pos";
|
||||
const valueClass = value < 0 ? "obs-liquidity-row__value--neg" : "";
|
||||
return `
|
||||
<div class="obs-liquidity-row">
|
||||
<div class="obs-liquidity-row__period">${row.period}</div>
|
||||
<div class="obs-liquidity-row__bar-wrap">
|
||||
<div class="obs-liquidity-row__bar ${barClass}" style="width:${width}%"></div>
|
||||
</div>
|
||||
<div class="obs-liquidity-row__value ${valueClass}">${euro(value)}</div>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
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 infraItems = [
|
||||
...domains.map(
|
||||
(d) =>
|
||||
`<wn-field-row label="${d.name}" narrow><span class="wn-field-row__value">${d.monthly_eur} EUR/mo · ${d.tld}</span></wn-field-row>`
|
||||
),
|
||||
...servers.map(
|
||||
(s) =>
|
||||
`<wn-field-row label="${s.name}" narrow><span class="wn-field-row__value">${s.monthly_eur} EUR/mo · since ${s.started}</span></wn-field-row>`
|
||||
),
|
||||
`<wn-field-row label="Stripe · ${stripe.member_username ?? "member"}" narrow><span class="wn-field-row__value">${stripe.gross_monthly_eur} gross · ${stripe.fee_monthly_eur} fee · ${stripe.net_payout_monthly_eur} net to ${payout}</span></wn-field-row>`,
|
||||
];
|
||||
document.getElementById("infra-stack").innerHTML = `<div class="obs-infra-list">${infraItems.join("")}</div>`;
|
||||
|
||||
document.getElementById("history-body").innerHTML = data.history
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td class="mono">${row.period}</td>
|
||||
<td class="mono">${row.active_members}</td>
|
||||
<td class="obs-num mono">${euro(row.gross_revenue)}</td>
|
||||
<td class="obs-num mono">${euro(row.infrastructure_cost)}</td>
|
||||
<td class="obs-num mono">${euro(row.payment_processing_cost)}</td>
|
||||
<td class="obs-num mono">${euro(row.net_liquidity)}</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
document.getElementById("pricing-body").innerHTML = data.pricing_models
|
||||
.map(
|
||||
(model) => `
|
||||
<tr>
|
||||
<td class="mono">${model.id}</td>
|
||||
<td>${model.name}</td>
|
||||
<td class="mono">${model.model_type}</td>
|
||||
<td><wn-tag>${model.status}</wn-tag></td>
|
||||
</tr>`
|
||||
)
|
||||
.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 `
|
||||
<div class="obs-range">
|
||||
<div class="obs-range__labels">
|
||||
<span class="mono">${euro(min)}</span>
|
||||
<span class="mono obs-range__current">${euro(cur)} now</span>
|
||||
<span class="mono">${euro(max)}</span>
|
||||
</div>
|
||||
<div class="obs-range__track">
|
||||
<div class="obs-range__fill" style="width:${curPct}%"></div>
|
||||
<div class="obs-range__marker" style="left:${curPct}%"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
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) => `
|
||||
<wn-card size="sm">
|
||||
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
|
||||
<div class="obs-metric__value obs-metric__value--sm">${card.value}</div>
|
||||
<span slot="footer">${card.sub}</span>
|
||||
</wn-card>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
document.getElementById("value-range-notes").textContent = vr.notes || "";
|
||||
document.getElementById("value-segments").innerHTML = vr.segments
|
||||
.map(
|
||||
(segment) => `
|
||||
<wn-card>
|
||||
<wn-eyebrow slot="header">${segment.name}</wn-eyebrow>
|
||||
<wn-tag slot="header">${segment.confidence}</wn-tag>
|
||||
${renderValueRangeBand(segment.low_eur, segment.high_eur, vr.current_price_eur)}
|
||||
<p class="small">${segment.evidence}</p>
|
||||
<span slot="footer">Headroom ${euro(segment.headroom_to_high_eur)}</span>
|
||||
<span slot="footer">${segment.drivers.join(" · ")}</span>
|
||||
</wn-card>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
document.getElementById("value-drivers").innerHTML = vr.value_drivers
|
||||
.map(
|
||||
(driver) => `
|
||||
<wn-field-row label="${driver.label}" narrow>
|
||||
<span class="wn-field-row__value">${driver.note}</span>
|
||||
<span class="wn-field-row__aside">${driver.strength}</span>
|
||||
</wn-field-row>`
|
||||
)
|
||||
.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) => `
|
||||
<wn-card size="sm">
|
||||
<wn-eyebrow slot="header">${card.label}</wn-eyebrow>
|
||||
<div class="obs-metric__value obs-metric__value--sm">${card.value}</div>
|
||||
<span slot="footer">${card.sub}</span>
|
||||
</wn-card>`
|
||||
)
|
||||
.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 `
|
||||
<tr>
|
||||
<td><strong>${alt.name}</strong><div class="small mono">${alt.id}</div></td>
|
||||
<td><wn-tag>${alt.category}</wn-tag></td>
|
||||
<td class="obs-num mono">${price}</td>
|
||||
<td><wn-stage-dot level="${alt.signal_level}">${alt.signal_level}</wn-stage-dot></td>
|
||||
<td>${features}</td>
|
||||
<td class="small">${alt.source} · ${alt.observed}<br>${alt.note}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function populatePeriods(history, current) {
|
||||
const select = document.getElementById("period-select");
|
||||
select.innerHTML = [...history].reverse().map(
|
||||
(row) => `<option value="${row.period}" ${row.period === current ? "selected" : ""}>${row.period}</option>`
|
||||
);
|
||||
select.value = current;
|
||||
if (!select._obsBound) {
|
||||
select.addEventListener("wn-change", async (event) => {
|
||||
const next = await loadDashboard(event.detail.value);
|
||||
render(next);
|
||||
});
|
||||
select._obsBound = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDesignRefLabel() {
|
||||
try {
|
||||
const response = await fetch("/ui/vendor/whynot-design/.whynot-design-ref");
|
||||
if (!response.ok) return;
|
||||
const ref = (await response.text()).trim().slice(0, 7);
|
||||
const label = document.getElementById("design-ref-label");
|
||||
if (label && ref) label.textContent = `whynot-design @ ${ref}`;
|
||||
} catch {
|
||||
// optional footer detail
|
||||
}
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
currentData = normalizeData(data);
|
||||
document.getElementById("design-link").href = data.design_reference;
|
||||
loadDesignRefLabel();
|
||||
setSection(activeSection);
|
||||
renderCostFloor(data);
|
||||
renderValueRange(data);
|
||||
renderMarketPrice(data);
|
||||
populatePeriods(data.history, data.period);
|
||||
}
|
||||
|
||||
bindNavigation();
|
||||
|
||||
loadDashboard()
|
||||
.then(render)
|
||||
.catch((error) => {
|
||||
document.body.innerHTML = `<main class="wn-main"><wn-banner variant="error" title="Load failed">${error}</wn-banner></main>`;
|
||||
});
|
||||
208
projects/coulomb-pricing/ui/index.html
Normal file
208
projects/coulomb-pricing/ui/index.html
Normal file
@@ -0,0 +1,208 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Coulomb · Economic Observatory</title>
|
||||
<link rel="stylesheet" href="/ui/vendor/whynot-design/colors_and_type.css" />
|
||||
<link rel="stylesheet" href="/ui/vendor/whynot-design/components.css" />
|
||||
<link rel="stylesheet" href="/ui/styles.css" />
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"lit": "https://esm.sh/lit@3.2.1",
|
||||
"lit/": "https://esm.sh/lit@3.2.1/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/ui/vendor/whynot-design/index.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<wn-top-nav brand="adaptive-pricing" slug="coulomb · observatory">
|
||||
<div slot="right" class="obs-period" id="period-control">
|
||||
<wn-eyebrow>Period</wn-eyebrow>
|
||||
<wn-select id="period-select"></wn-select>
|
||||
</div>
|
||||
<wn-tag slot="right" id="liquidity-badge">—</wn-tag>
|
||||
</wn-top-nav>
|
||||
|
||||
<div class="wn-app obs-app">
|
||||
<wn-sidebar id="obs-sidebar">
|
||||
<wn-sidebar-group label="Observatory">
|
||||
<wn-sidebar-item icon="activity" active data-section="cost-floor" id="nav-cost-floor">
|
||||
Cost Floor
|
||||
</wn-sidebar-item>
|
||||
<wn-sidebar-item icon="lightbulb" data-section="value-range" id="nav-value-range">
|
||||
Value Range
|
||||
</wn-sidebar-item>
|
||||
<wn-sidebar-item icon="users" data-section="market-price" id="nav-market-price">
|
||||
Market Price
|
||||
</wn-sidebar-item>
|
||||
</wn-sidebar-group>
|
||||
</wn-sidebar>
|
||||
|
||||
<main class="wn-main obs-main">
|
||||
<wn-page-header id="page-header"></wn-page-header>
|
||||
|
||||
<section class="obs-panel obs-panel--active" id="panel-cost-floor" data-section="cost-floor">
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Snapshot</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-metric-grid" id="metric-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Liquidity & budget</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<wn-banner variant="info" title="Budget position" id="budget-banner">
|
||||
<p id="budget-caption">—</p>
|
||||
</wn-banner>
|
||||
<div class="obs-budget-meter" aria-hidden="true">
|
||||
<div class="obs-budget-meter__fill" id="budget-fill"></div>
|
||||
</div>
|
||||
<div class="obs-field-sheet" id="budget-stats"></div>
|
||||
</section>
|
||||
|
||||
<div class="obs-split">
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Monthly net liquidity</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note">Member net payments minus infrastructure, by period.</p>
|
||||
<div class="obs-liquidity-list" id="liquidity-chart"></div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Infrastructure stack</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note">Domains, hosting, and Stripe reference rates.</p>
|
||||
<div id="infra-stack"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Monthly ledger</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note">Computed from expense and payment record tables.</p>
|
||||
<div class="obs-table-wrap">
|
||||
<table class="wn-table--native obs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th>Members</th>
|
||||
<th class="obs-num">Gross</th>
|
||||
<th class="obs-num">Infrastructure</th>
|
||||
<th class="obs-num">Processing</th>
|
||||
<th class="obs-num">Net liquidity</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="history-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Pricing model registry</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-table-wrap">
|
||||
<table class="wn-table--native obs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricing-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="obs-panel" id="panel-value-range" data-section="value-range" hidden>
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Current position</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-metric-grid" id="value-summary"></div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Segment bands</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note" id="value-range-notes"></p>
|
||||
<div class="obs-value-grid" id="value-segments"></div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Value drivers</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-field-sheet" id="value-drivers"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="obs-panel" id="panel-market-price" data-section="market-price" hidden>
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Market span</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<div class="obs-metric-grid" id="market-summary"></div>
|
||||
</section>
|
||||
|
||||
<section class="obs-section">
|
||||
<div class="obs-section__head">
|
||||
<wn-eyebrow>Competitive alternatives</wn-eyebrow>
|
||||
<div class="obs-section__rule"></div>
|
||||
</div>
|
||||
<p class="lead obs-section-note" id="market-notes"></p>
|
||||
<div class="obs-table-wrap">
|
||||
<table class="wn-table--native obs-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Alternative</th>
|
||||
<th>Category</th>
|
||||
<th class="obs-num">Price / mo</th>
|
||||
<th>Signal</th>
|
||||
<th>Features</th>
|
||||
<th>Evidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="market-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<footer class="obs-footer">
|
||||
<p class="small">
|
||||
Design:
|
||||
<a id="design-link" href="#" target="_blank" rel="noreferrer">Claude design share</a>
|
||||
·
|
||||
<span class="mono" id="design-ref-label">whynot-design</span>
|
||||
· totals computed programmatically from ledgers
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/ui/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
256
projects/coulomb-pricing/ui/styles.css
Normal file
256
projects/coulomb-pricing/ui/styles.css
Normal file
@@ -0,0 +1,256 @@
|
||||
/* Observatory layout — extends whynot-design (Layer 1). No gradients, no shadows on cards. */
|
||||
|
||||
body {
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.obs-app {
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.obs-main {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 132px;
|
||||
}
|
||||
|
||||
wn-top-nav [slot="right"] {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.obs-section {
|
||||
margin-bottom: var(--sp-7);
|
||||
}
|
||||
|
||||
.obs-section__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.obs-section__rule {
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.obs-section-note {
|
||||
margin: 0 0 var(--sp-4);
|
||||
max-width: 60ch;
|
||||
}
|
||||
|
||||
.obs-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.obs-metric__value {
|
||||
font: 500 28px/1.1 var(--ff-sans);
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--fg-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.obs-budget-meter {
|
||||
height: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--paper-2);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.obs-budget-meter__fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: var(--ink);
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
.obs-field-sheet {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--paper);
|
||||
padding: 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.obs-split {
|
||||
display: grid;
|
||||
grid-template-columns: 1.35fr 1fr;
|
||||
gap: var(--sp-6);
|
||||
margin-bottom: var(--sp-2);
|
||||
}
|
||||
|
||||
.obs-liquidity-list {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--paper);
|
||||
padding: 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.obs-liquidity-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 1fr 88px;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
padding: var(--sp-3) 0;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.obs-liquidity-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.obs-liquidity-row__period {
|
||||
font: 400 12px var(--ff-mono);
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
.obs-liquidity-row__value {
|
||||
font: 500 12px var(--ff-mono);
|
||||
color: var(--fg-1);
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.obs-liquidity-row__value--neg {
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
.obs-liquidity-row__bar-wrap {
|
||||
height: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--paper-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.obs-liquidity-row__bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.obs-liquidity-row__bar--neg {
|
||||
right: 50%;
|
||||
background: var(--ink-4);
|
||||
}
|
||||
|
||||
.obs-liquidity-row__bar--pos {
|
||||
left: 50%;
|
||||
background: var(--ink);
|
||||
}
|
||||
|
||||
.obs-infra-list {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
background: var(--paper);
|
||||
padding: 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.obs-table-wrap {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-2);
|
||||
overflow-x: auto;
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.obs-table {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.obs-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.obs-footer {
|
||||
padding-top: var(--sp-5);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.obs-footer a {
|
||||
color: var(--fg-1);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
wn-banner {
|
||||
display: block;
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.obs-split {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
wn-top-nav [slot="right"] {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
1
projects/coulomb-pricing/ui/vendor/whynot-design/.whynot-design-ref
vendored
Normal file
1
projects/coulomb-pricing/ui/vendor/whynot-design/.whynot-design-ref
vendored
Normal file
@@ -0,0 +1 @@
|
||||
9b9f3728937ca308966de9c62accdb00c8cf5b0e
|
||||
273
projects/coulomb-pricing/ui/vendor/whynot-design/colors_and_type.css
vendored
Normal file
273
projects/coulomb-pricing/ui/vendor/whynot-design/colors_and_type.css
vendored
Normal file
@@ -0,0 +1,273 @@
|
||||
/* ============================================================
|
||||
WhyNot Design System — Colors & Type
|
||||
------------------------------------------------------------
|
||||
Neutral, mostly black/white. Color is used SPARINGLY — only
|
||||
one warm accent (annotation yellow) borrowed from the LEGO
|
||||
brick in the logo. The system favours light grey wireframe
|
||||
artefacts over heavy fills.
|
||||
============================================================ */
|
||||
|
||||
/* ---------- Webfonts (Google Fonts, see /fonts for offline) ---------- */
|
||||
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600;700&family=IBM+Plex+Serif:ital,wght@0,400;0,500;1,400&display=swap");
|
||||
|
||||
:root {
|
||||
/* ---------- Base palette: neutrals ---------- */
|
||||
--ink: #0A0A0A; /* near-black, the only "fill" most of the time */
|
||||
--ink-2: #1F1F1F;
|
||||
--ink-3: #5C5C5C;
|
||||
--ink-4: #8A8A8A;
|
||||
--ink-5: #B5B5B3; /* placeholder text, wireframe labels */
|
||||
--line: #E5E5E2; /* default 1px wireframe rule */
|
||||
--line-strong: #C9C9C5; /* dividers between sections */
|
||||
--line-soft: #F0F0EC; /* hairline within a card */
|
||||
--paper: #FFFFFF; /* canvas */
|
||||
--paper-2: #FAFAF7; /* sheet, dim canvas */
|
||||
--paper-3: #F4F4EF; /* recessed surface, code block bg */
|
||||
|
||||
/* ---------- Foreground / background semantic ---------- */
|
||||
--fg-1: var(--ink);
|
||||
--fg-2: var(--ink-3);
|
||||
--fg-3: var(--ink-4);
|
||||
--fg-mute: var(--ink-5);
|
||||
--fg-on-dark: #FAFAF7;
|
||||
|
||||
--bg-1: var(--paper);
|
||||
--bg-2: var(--paper-2);
|
||||
--bg-3: var(--paper-3);
|
||||
--bg-invert: var(--ink);
|
||||
|
||||
--border: var(--line);
|
||||
--border-strong: var(--line-strong);
|
||||
--border-soft: var(--line-soft);
|
||||
|
||||
/* ---------- The single accent: annotation yellow ---------- */
|
||||
/* Lifted from the LEGO brick. Used as highlighter, "draft"
|
||||
stamp, signal-marker. Never as a button fill. */
|
||||
--hi: #FFE14A;
|
||||
--hi-2: #FFD400;
|
||||
--hi-ink: #1A1500; /* text on yellow */
|
||||
|
||||
/* ---------- Status (for prototype lifecycle, signal strength) ---------- */
|
||||
/* Kept deliberately desaturated so they read as labels, not UI. */
|
||||
--status-raw: #B5B5B3; /* S0 — no signal */
|
||||
--status-weak: #8A8A8A; /* S1 — weak signal */
|
||||
--status-medium: #5C5C5C; /* S2 — medium signal */
|
||||
--status-strong: #0A0A0A; /* S3 — strong signal */
|
||||
--status-commercial: #FFD400; /* S4 — commercial */
|
||||
|
||||
/* ---------- Type families ---------- */
|
||||
--ff-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
--ff-mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
--ff-serif: "IBM Plex Serif", "Iowan Old Style", Georgia, serif;
|
||||
|
||||
/* ---------- Type scale (modular, ~1.2) ---------- */
|
||||
--fs-xs: 11px;
|
||||
--fs-sm: 13px;
|
||||
--fs-base: 15px;
|
||||
--fs-md: 17px;
|
||||
--fs-lg: 20px;
|
||||
--fs-xl: 24px;
|
||||
--fs-2xl: 32px;
|
||||
--fs-3xl: 44px;
|
||||
--fs-4xl: 64px;
|
||||
--fs-5xl: 96px;
|
||||
|
||||
--lh-tight: 1.05;
|
||||
--lh-snug: 1.25;
|
||||
--lh-base: 1.5;
|
||||
--lh-loose: 1.7;
|
||||
|
||||
--tr-tight: -0.02em;
|
||||
--tr-snug: -0.01em;
|
||||
--tr-base: 0em;
|
||||
--tr-mono: 0.02em;
|
||||
--tr-label: 0.08em; /* uppercase eyebrow labels */
|
||||
|
||||
/* ---------- Spacing (4px base) ---------- */
|
||||
--sp-1: 4px;
|
||||
--sp-2: 8px;
|
||||
--sp-3: 12px;
|
||||
--sp-4: 16px;
|
||||
--sp-5: 24px;
|
||||
--sp-6: 32px;
|
||||
--sp-7: 48px;
|
||||
--sp-8: 64px;
|
||||
--sp-9: 96px;
|
||||
--sp-10: 128px;
|
||||
|
||||
/* ---------- Radii — small, mostly square ---------- */
|
||||
--r-0: 0px;
|
||||
--r-1: 2px;
|
||||
--r-2: 4px;
|
||||
--r-3: 8px;
|
||||
--r-pill: 999px;
|
||||
|
||||
/* ---------- Elevation — almost none. This is a wireframe system. ---------- */
|
||||
--shadow-0: none;
|
||||
--shadow-1: 0 1px 0 var(--line);
|
||||
--shadow-2: 0 1px 0 var(--line-strong);
|
||||
--shadow-3: 0 4px 12px -6px rgba(10,10,10,0.10);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Semantic element styles
|
||||
============================================================ */
|
||||
|
||||
html {
|
||||
font-family: var(--ff-sans);
|
||||
font-size: var(--fs-base);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--fg-1);
|
||||
background: var(--bg-1);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-feature-settings: "ss01", "cv11";
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* ---------- Headings ---------- */
|
||||
h1, .h1 {
|
||||
font: 600 var(--fs-3xl)/var(--lh-tight) var(--ff-sans);
|
||||
letter-spacing: var(--tr-tight);
|
||||
margin: 0 0 var(--sp-5);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
h2, .h2 {
|
||||
font: 500 var(--fs-2xl)/var(--lh-snug) var(--ff-sans);
|
||||
letter-spacing: var(--tr-snug);
|
||||
margin: 0 0 var(--sp-4);
|
||||
}
|
||||
h3, .h3 {
|
||||
font: 500 var(--fs-xl)/var(--lh-snug) var(--ff-sans);
|
||||
letter-spacing: var(--tr-snug);
|
||||
margin: 0 0 var(--sp-3);
|
||||
}
|
||||
h4, .h4 {
|
||||
font: 500 var(--fs-lg)/var(--lh-snug) var(--ff-sans);
|
||||
margin: 0 0 var(--sp-2);
|
||||
}
|
||||
h5, .h5 {
|
||||
font: 500 var(--fs-md)/var(--lh-snug) var(--ff-sans);
|
||||
margin: 0 0 var(--sp-2);
|
||||
}
|
||||
|
||||
/* ---------- Display (for hero / title slides) ---------- */
|
||||
.display-1 {
|
||||
font: 300 var(--fs-5xl)/0.95 var(--ff-sans);
|
||||
letter-spacing: -0.035em;
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.display-2 {
|
||||
font: 400 var(--fs-4xl)/1.0 var(--ff-sans);
|
||||
letter-spacing: var(--tr-tight);
|
||||
}
|
||||
|
||||
/* ---------- Body ---------- */
|
||||
p {
|
||||
margin: 0 0 var(--sp-4);
|
||||
line-height: var(--lh-base);
|
||||
color: var(--fg-1);
|
||||
}
|
||||
.lead {
|
||||
font-size: var(--fs-md);
|
||||
line-height: 1.55;
|
||||
color: var(--fg-2);
|
||||
}
|
||||
small, .small {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
/* ---------- Eyebrow / uppercase labels (very common in this system) ---------- */
|
||||
.eyebrow,
|
||||
.label {
|
||||
font: 500 var(--fs-xs)/1.2 var(--ff-mono);
|
||||
letter-spacing: var(--tr-label);
|
||||
text-transform: uppercase;
|
||||
color: var(--fg-3);
|
||||
}
|
||||
|
||||
/* ---------- Code / mono ---------- */
|
||||
code, kbd, samp, pre, .mono {
|
||||
font-family: var(--ff-mono);
|
||||
font-size: 0.92em;
|
||||
letter-spacing: var(--tr-mono);
|
||||
}
|
||||
code {
|
||||
background: var(--bg-3);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--r-1);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
pre {
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--sp-4);
|
||||
overflow-x: auto;
|
||||
border-radius: var(--r-2);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: var(--lh-snug);
|
||||
}
|
||||
pre code { background: none; padding: 0; }
|
||||
|
||||
/* ---------- Editorial serif moments ---------- */
|
||||
.serif { font-family: var(--ff-serif); }
|
||||
.serif-quote {
|
||||
font: 400 italic var(--fs-xl)/1.4 var(--ff-serif);
|
||||
color: var(--fg-2);
|
||||
}
|
||||
|
||||
/* ---------- Links ---------- */
|
||||
a {
|
||||
color: var(--fg-1);
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border-strong);
|
||||
text-underline-offset: 3px;
|
||||
text-decoration-thickness: 1px;
|
||||
transition: text-decoration-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration-color: var(--fg-1);
|
||||
}
|
||||
|
||||
/* ---------- HR ---------- */
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: var(--sp-5) 0;
|
||||
}
|
||||
|
||||
/* ---------- Highlighter (the one place yellow appears in body copy) ---------- */
|
||||
mark, .mark {
|
||||
background: var(--hi);
|
||||
color: var(--hi-ink);
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
/* ---------- Tables (used in templates) ---------- */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
th {
|
||||
font-weight: 500;
|
||||
color: var(--fg-2);
|
||||
font-family: var(--ff-mono);
|
||||
font-size: var(--fs-xs);
|
||||
letter-spacing: var(--tr-label);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ---------- Selection ---------- */
|
||||
::selection { background: var(--hi); color: var(--hi-ink); }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user