diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a23dac5..aebdec2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,14 +3,17 @@ name: Deploy JupyterLite on: push: branches: [main] + pull_request: + branches: [main] workflow_dispatch: permissions: contents: read + pull-requests: write -# Only one deployment at a time; cancel in-progress if a new push arrives +# Group by branch; cancel in-progress runs for the same branch/PR concurrency: - group: pages + group: pages-${{ github.head_ref || github.ref_name }} cancel-in-progress: true jobs: @@ -67,8 +70,37 @@ jobs: run: cp _headers _site/_headers - name: Deploy to Cloudflare Pages + id: deploy uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - command: pages deploy _site --project-name=zerokrab-boundless-jupyter --commit-dirty=true + command: pages deploy _site --project-name=zerokrab-boundless-jupyter --commit-dirty=true --branch=${{ github.head_ref || github.ref_name }} + + - name: Comment preview URL on PR + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEPLOY_OUTPUT: ${{ steps.deploy.outputs.command-output }} + run: | + PREVIEW_URL=$(echo "$DEPLOY_OUTPUT" | grep -oE 'https://[^ ]*\.pages\.dev' | tail -1) + if [ -z "$PREVIEW_URL" ]; then + echo "Could not extract preview URL from deploy output" + exit 0 + fi + # Update or create comment (avoids spam on repeated pushes) + COMMENT_TAG="" + BODY="${COMMENT_TAG} + 🚀 **Preview deployment ready!** + + **URL:** ${PREVIEW_URL} + **Branch:** \`${{ github.head_ref }}\` + **Commit:** \`${{ github.event.pull_request.head.sha }}\`" + + # Check for existing comment + EXISTING=$(gh pr view ${{ github.event.pull_request.number }} --json comments --jq '.comments[] | select(.body | contains("")) | .id' | head -1) + if [ -n "$EXISTING" ]; then + gh api graphql -f query="mutation { updateIssueComment(input: {id: \"${EXISTING}\", body: $(echo "$BODY" | jq -Rs .)}) { issueComment { id } } }" + else + gh pr comment ${{ github.event.pull_request.number }} --body "$BODY" + fi diff --git a/AGENTS.md b/AGENTS.md index bc84727..6c7e252 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Instructions for AI agents working on this project. - **README.md** – Domain background, costs, revenue (POVW + market). Read first for context. - **boundless_profitability.ipynb** – Single Jupyter notebook: config → cost → revenue → profit → break-even → scenario tables/charts. All figures are **per epoch**. Inputs live under `## 1. Inputs`: - - **1.1 Model Parameters**: `ZKC_PRICES_USD`, `MARKET_ORDER_UTIL`, `MARKET_REWARD_USD_PER_MHZ`, `FIXED_COST_MONTHLY_USD`, and `gpu_configs` (with `label`, `num_gpus`, `hourly_cost_usd`, `mhz`). + - **1.1 Model Parameters**: `ZKC_PRICES_USD`, `MARKET_ORDER_UTIL` (fixed scalar), `MARKET_REWARD_USD_PER_MHZ` (range of scenarios), `FIXED_COST_MONTHLY_USD`, and `gpu_configs` (with `label`, `num_gpus`, `hourly_cost_usd`, `mhz`). - **1.2 Constants**: `HOURS_PER_EPOCH`, `SECONDS_PER_EPOCH`, `EPOCHS_PER_MONTH`. - **1.3 POVW Reward Data**: parsing logic that derives `POVW_ZKC_PER_MHZ_PER_EPOCH` from `epochs.csv`. - **epochs.csv** – Data export: Total Cycles (all provers) and Mining Rewards (ZKC) per epoch. Used to compute **ZKC per mhz per epoch** for POVW. Updated as new data becomes available. The notebook excludes the **latest epoch** (row 1) as it may still be ongoing. @@ -26,7 +26,7 @@ Instructions for AI agents working on this project. | Term | Meaning | |------|--------| | POVW rewards | ZKC distributed every epoch based on cycles proven; modeled as ZKC per mhz per epoch. | -| Market rewards | Per-proof payouts; modeled as average USD per mhz, scaled by **market order utilization** (share of capacity fulfilling market orders; e.g. 50%, 75%, 100%). POVW is unchanged by utilization. | +| Market rewards | Per-proof payouts; modeled as a range of USD per mhz scenarios, scaled by a fixed **market order utilization** (share of capacity fulfilling market orders). POVW is unchanged by utilization. | | ZKC | Cryptocurrency used in Boundless; price in USD is a key input. | | GPU config | A labeled GPU setup (e.g. `Mock 5090 0.2 5090 x8`) with `label`, `num_gpus`, `mhz` (million cycles/sec), and `hourly_cost_usd`. | @@ -36,7 +36,8 @@ Instructions for AI agents working on this project. - **POVW from data:** `POVW_ZKC_PER_MHZ_PER_EPOCH` is computed from **epochs.csv** (Total Cycles and Mining Rewards); the latest epoch is always excluded. Do not replace this with a literal placeholder. - **Placeholders:** Other inputs may use placeholders (e.g. `MARKET_REWARD_USD_PER_MHZ`, sample GPU rows). Preserve the structure; replace values with real data when available. Label placeholders in comments or markdown. - **Cost conversion:** GPU rental is stored **per hour** (`hourly_cost_usd`) and converted to per-epoch as `rental_per_epoch_usd`. Fixed cost is monthly, converted to per-epoch with `EPOCHS_PER_MONTH`, and added **separately** when computing total cost (profit / break-even). -- **Market order utilization:** Configurable (e.g. 50%, 75%, 100%); market revenue = mhz × reward_per_mhz × util. POVW revenue is not scaled by utilization. +- **Market order utilization:** Fixed scalar (e.g. 0.5); market revenue = mhz × reward_per_mhz × util. POVW revenue is not scaled by utilization. +- **Market reward rate:** Configurable range (e.g. $0.00003–$0.0001/MHz); each value produces a separate scenario. ## What to change vs preserve diff --git a/boundless_profitability.ipynb b/boundless_profitability.ipynb index 77f8740..4ea8123 100644 --- a/boundless_profitability.ipynb +++ b/boundless_profitability.ipynb @@ -16,7 +16,7 @@ "To estimate profitability we use several inputs:\n", "- GPU configurations - to adjust costs and MHz capacity\n", "- ZKC prices - to model for different price points\n", - "- Market order utilization - to adjust for the % of GPU capacity that is spent on market orders, which determines market rewards\n", + "- Market reward rates - to model different expected USD per MHz from market orders\n", "- Market reward rate - average USD per MHz expected from the market\n", "\n", "All costs, revenue, and profit are expressed per epoch and primarily denominated in USD.\n", @@ -59,27 +59,7 @@ "id": "fd5c0500b882c0", "metadata": {}, "source": [ - "### 1.1 Model Parameters (EDIT THESE)\n", - "\n", - "`ZKC_PRICES_USD`\n", - "\n", - "The model will use each value as a possible scenario for the price of ZKC. Pick any values you want.\n", - "\n", - "`MARKET_ORDER_UTIL`\n", - "\n", - "A percentage of the GPU capacity the is used for market orders. Pick any values you want. (Note: utilization tends to be <50%)\n", - "\n", - "`MARKET_REWARD_USD_PER_MHZ`\n", - "\n", - "Expected price paid per million cycles on the market. Can be estimated from [market stats](https://explorer.boundless.network/stats), (see \"Lock price per cycle distribution\"). You can divide USD/Bcycle by 1000 to get USD/Mhz.\n", - "\n", - "`FIXED_COST_MONTHLY_USD`\n", - "\n", - "Any additional expenses, fixed cost per month.\n", - "\n", - "`gpu_configs`\n", - "\n", - "List of GPU options to model." + "### 1.1 Model Parameters (EDIT THESE)\n\n`ZKC_PRICES_USD`\n\nThe model will use each value as a possible scenario for the price of ZKC. Pick any values you want.\n\n`MARKET_ORDER_UTIL`\n\nA fixed percentage of GPU capacity used for market orders. (Note: utilization tends to be <50%)\n\n`MARKET_REWARD_USD_PER_MHZ`\n\nA range of expected prices paid per million cycles on the market. The model will use each value as a scenario. Can be estimated from [market stats](https://explorer.boundless.network/stats), (see \"Lock price per cycle distribution\"). You can divide USD/Bcycle by 1000 to get USD/Mhz.\n\n`FIXED_COST_MONTHLY_USD`\n\nAny additional expenses, fixed cost per month.\n\n`gpu_configs`\n\nList of GPU options to model." ] }, { @@ -94,11 +74,11 @@ "# ZKC prices (USD per ZKC) to model\n", "ZKC_PRICES_USD = [0.025, 0.05, 0.075, 0.1, 0.125, 0.15, 0.2, 0.3, 0.5, 1.0]\n", "\n", - "# Market order utilization: percentage of capacity used for fulfilling market orders.\n", - "MARKET_ORDER_UTIL = [0.25, 0.5, 0.75, 1.0]\n", + "# Market order utilization: fixed percentage of capacity used for market orders\n", + "MARKET_ORDER_UTIL = 0.5\n", "\n", - "# Average reward per million cycles in USD\n", - "MARKET_REWARD_USD_PER_MHZ = 0.00007\n", + "# Average reward per million cycles in USD (range of scenarios)\n", + "MARKET_REWARD_USD_PER_MHZ = [0.00003, 0.00005, 0.00007, 0.0001]\n", "\n", "# Fixed cost additional expenses\n", "FIXED_COST_MONTHLY_USD = 0\n", @@ -253,8 +233,8 @@ " scenario = row[\"label\"]\n", " for zkc_price in ZKC_PRICES_USD:\n", " povw_revenue = mhz_per_epoch * POVW_ZKC_PER_MHZ_PER_EPOCH * zkc_price\n", - " for market_order_util in MARKET_ORDER_UTIL:\n", - " market_revenue = mhz_per_epoch * MARKET_REWARD_USD_PER_MHZ * market_order_util\n", + " for market_reward in MARKET_REWARD_USD_PER_MHZ:\n", + " market_revenue = mhz_per_epoch * market_reward * MARKET_ORDER_UTIL\n", " total_revenue = povw_revenue + market_revenue\n", " profit = total_revenue - total_cost_epoch\n", " results.append({\n", @@ -262,7 +242,8 @@ " \"label\": row[\"label\"],\n", " \"num_gpus\": row[\"num_gpus\"],\n", " \"zkc_price_usd\": zkc_price,\n", - " \"market_order_util\": market_order_util,\n", + " \"market_order_util\": MARKET_ORDER_UTIL,\n", + " \"market_reward_usd_per_mhz\": market_reward,\n", " \"mhz_per_epoch\": mhz_per_epoch,\n", " \"cost_per_epoch\": total_cost_epoch,\n", " \"povw_revenue\": povw_revenue,\n", @@ -295,7 +276,7 @@ "metadata": {}, "source": [ "# Display summary of data\n", - "revenue_df[[\"scenario\", \"zkc_price_usd\", \"market_order_util\", \"profit_per_epoch\"]]" + "revenue_df[[\"scenario\", \"zkc_price_usd\", \"market_reward_usd_per_mhz\", \"profit_per_epoch\"]]" ], "outputs": [], "execution_count": null @@ -322,7 +303,7 @@ "metadata": {}, "source": [ "profit_pivot = revenue_df.pivot_table(\n", - " index=[\"scenario\", \"market_order_util\"], columns=\"zkc_price_usd\", values=\"profit_per_epoch\"\n", + " index=[\"scenario\", \"market_reward_usd_per_mhz\"], columns=\"zkc_price_usd\", values=\"profit_per_epoch\"\n", ")\n", "profit_pivot" ], @@ -345,12 +326,12 @@ "%matplotlib inline\n", "import matplotlib.pyplot as plt\n", "\n", - "# One graph per GPU config; each shows profit vs ZKC price with one line per market order utilization\n", + "# One graph per GPU config; each shows profit vs ZKC price with one line per market reward rate\n", "for scenario in revenue_df[\"scenario\"].unique():\n", " fig, ax = plt.subplots(figsize=(10, 4))\n", " scenario_df = revenue_df[revenue_df[\"scenario\"] == scenario]\n", - " for util, sub in scenario_df.groupby(\"market_order_util\"):\n", - " ax.plot(sub[\"zkc_price_usd\"], sub[\"profit_per_epoch\"], marker=\"o\", label=f\"{util * 100:.0f}% util\")\n", + " for reward, sub in scenario_df.groupby(\"market_reward_usd_per_mhz\"):\n", + " ax.plot(sub[\"zkc_price_usd\"], sub[\"profit_per_epoch\"], marker=\"o\", label=f\"${reward:.5f}/MHz\")\n", " ax.axhline(0, color=\"gray\", linestyle=\"--\")\n", " ax.set_xlabel(\"ZKC price (USD)\")\n", " ax.set_ylabel(\"Profit per epoch (USD)\")\n", @@ -384,7 +365,7 @@ " print(\"No profitable scenarios for current inputs (profit_per_epoch <= 0 everywhere).\")\n", "else:\n", " profitable_pivot = profitable_revenue_df.pivot_table(\n", - " index=[\"scenario\", \"market_order_util\"],\n", + " index=[\"scenario\", \"market_reward_usd_per_mhz\"],\n", " columns=\"zkc_price_usd\",\n", " values=\"profit_per_epoch\",\n", " )\n", diff --git a/dashboard.py b/dashboard.py index 22c7fda..84c5093 100644 --- a/dashboard.py +++ b/dashboard.py @@ -25,35 +25,35 @@ def build_dashboard(df: pd.DataFrame | None = None) -> pn.viewable.Viewable: df : pd.DataFrame, optional Pre-computed revenue_df from the notebook. If None, loads results.csv. - Expected columns: scenario, zkc_price_usd, market_order_util, + Expected columns: scenario, zkc_price_usd, market_reward_usd_per_mhz, profit_per_epoch, povw_revenue, market_revenue, cost_per_epoch, label """ if df is None: df = pd.read_csv("results.csv") - # Derive available discrete ZKC values from data; use any reference util for interpolation + # Derive available discrete ZKC values from data; use any reference reward for interpolation zkc_prices = sorted(df["zkc_price_usd"].unique()) - ref_util = df["market_order_util"].iloc[0] # reference utilization for rate derivation + ref_reward = df["market_reward_usd_per_mhz"].iloc[0] # reference reward for rate derivation # ── Widgets ─────────────────────────────────────────────────────────────── zkc_slider = pn.widgets.DiscreteSlider( name="ZKC Price (USD)", options=zkc_prices, value=zkc_prices[len(zkc_prices) // 2] ) - util_slider = pn.widgets.FloatSlider( - name="Market Utilization", start=0.0, end=1.0, step=0.01, value=0.5, - format="0%", + reward_slider = pn.widgets.FloatSlider( + name="Market Reward (USD/MHz)", start=0.00001, end=0.0002, step=0.00001, value=0.00007, + format="0.00000", ) # ── Tab 1: Profit Explorer ──────────────────────────────────────────────── - @pn.depends(zkc_slider, util_slider) - def profit_chart(zkc_price, market_util): - # Base rows at the reference utilization; scale market_revenue linearly to market_util. - # market_revenue ∝ util (mhz * reward * util), so scaling is exact. - sub = df[(df["zkc_price_usd"] == zkc_price) & (df["market_order_util"] == ref_util)] - scale = market_util / ref_util if ref_util > 0 else 0 + @pn.depends(zkc_slider, reward_slider) + def profit_chart(zkc_price, market_reward): + # Base rows at the reference reward; scale market_revenue linearly to market_reward. + # market_revenue ∝ reward (mhz * reward * util), so scaling is exact. + sub = df[(df["zkc_price_usd"] == zkc_price) & (df["market_reward_usd_per_mhz"] == ref_reward)] + scale = market_reward / ref_reward if ref_reward > 0 else 0 labels = sub["label"].tolist() costs = sub["cost_per_epoch"].tolist() @@ -93,11 +93,11 @@ def profit_chart(zkc_price, market_util): # ── Tab 2: Break-even ───────────────────────────────────────────────────── - @pn.depends(util_slider) - def breakeven_chart(market_util): - # Scale market_revenue to the chosen utilization, then find min profitable ZKC price - scale = market_util / ref_util if ref_util > 0 else 0 - sub = df[df["market_order_util"] == ref_util].copy() + @pn.depends(reward_slider) + def breakeven_chart(market_reward): + # Scale market_revenue to the chosen reward, then find min profitable ZKC price + scale = market_reward / ref_reward if ref_reward > 0 else 0 + sub = df[df["market_reward_usd_per_mhz"] == ref_reward].copy() sub = sub.assign( market_revenue_scaled=sub["market_revenue"] * scale, ) @@ -139,7 +139,7 @@ def breakeven_chart(market_util): sidebar = pn.Column( "### Controls", zkc_slider, - util_slider, + reward_slider, width=240, )