Spaces:
Sleeping
Sleeping
Commit ·
00cabba
1
Parent(s): 4bc9610
Forge: initial deploy — API + static UI over prebuilt forge.db (seeded at build)
Browse files- Dockerfile +19 -0
- README.md +40 -4
- forge/__init__.py +1 -0
- forge/api.py +136 -0
- forge/campaign.py +127 -0
- forge/collect.py +168 -0
- forge/db.py +186 -0
- forge/engine.py +165 -0
- forge/github_collect.py +136 -0
- forge/harness.py +68 -0
- forge/scraper.py +360 -0
- forge/seed.py +160 -0
- forge/test_scraper.py +61 -0
- frontend/app.jsx +309 -0
- frontend/data.js +373 -0
- frontend/graph.jsx +447 -0
- frontend/icons.js +53 -0
- frontend/index.html +45 -0
- frontend/inventory.jsx +139 -0
- frontend/recipe.jsx +238 -0
- frontend/sound.js +131 -0
- frontend/styles.css +1407 -0
- frontend/ticker.jsx +59 -0
- frontend/tooltip.jsx +97 -0
- frontend/tweaks-panel.jsx +530 -0
- requirements.txt +3 -0
Dockerfile
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Forge demo — single container serves the API + static frontend over the
|
| 2 |
+
# prebuilt forge.db. No scraping / no LLM at runtime (that's build-time, local),
|
| 3 |
+
# so this stays tiny and CPU-only — fits a free HF Docker Space.
|
| 4 |
+
FROM python:3.11-slim
|
| 5 |
+
|
| 6 |
+
WORKDIR /app
|
| 7 |
+
|
| 8 |
+
COPY requirements.txt .
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
|
| 11 |
+
COPY forge/ ./forge/
|
| 12 |
+
COPY frontend/ ./frontend/
|
| 13 |
+
|
| 14 |
+
# Bake the prebuilt graph if it shipped in the build context; otherwise seed a
|
| 15 |
+
# fresh curated graph so the Space always boots with something.
|
| 16 |
+
RUN test -f forge/forge.db || python -m forge.seed
|
| 17 |
+
|
| 18 |
+
EXPOSE 7860
|
| 19 |
+
CMD ["uvicorn", "forge.api:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,46 @@
|
|
| 1 |
---
|
| 2 |
title: Forge
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Forge
|
| 3 |
+
emoji: 🔥
|
| 4 |
+
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Forge — ML Training Compatibility Graph
|
| 13 |
+
|
| 14 |
+
Pick your training ingredients (optimizer, scheduler, quantization, technique, architecture). Incompatible or not-yet-satisfied components grey out **live**, each with a cited reason and the fix. Output: a validated training recipe assembled from real practitioner evidence across the web.
|
| 15 |
+
|
| 16 |
+
Built for the Bright Data **Web Data UNLOCKED** hackathon.
|
| 17 |
+
|
| 18 |
+
## How it works
|
| 19 |
+
|
| 20 |
+
- **Data (Bright Data):** `discover` (AI-intent ranking) + Web Unlocker + SERP (EN + CN geo) + Scraper API + Scraping Browser mine compatibility signal from docs, papers, GitHub repos/issues, and forums.
|
| 21 |
+
- **Extraction (local, free):** a local Qwen2.5-3B model (llama.cpp) pulls `(A, relation, B, conditions)` triples — no paid LLM API. A keyless heuristic is the fallback.
|
| 22 |
+
- **Trust:** tier-1 = verified practitioner research + a real LR-scheduler benchmark; tier-2 = docs / 2+ independent sources; tier-3 = single scrape (held in a review queue until corroborated). GitHub is used as a secondary source to verify candidates.
|
| 23 |
+
- **Graph:** SQLite (`forge.db`). The grey-out resolves client-side for instant feedback.
|
| 24 |
+
|
| 25 |
+
## Architecture
|
| 26 |
+
|
| 27 |
+
The heavy work (scrape + extraction) runs at **build time** on the maintainer's machine. This Space just serves the prebuilt `forge.db` via FastAPI + a static D3 frontend — one origin, CPU-only.
|
| 28 |
+
|
| 29 |
+
```
|
| 30 |
+
brightdata CLI + local model ──build──> forge.db ──serve──> FastAPI (/graph,/resolve,/recipe,/feed) + static UI
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
## Run locally
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
pip install -r requirements.txt
|
| 37 |
+
python -m forge.seed # build forge.db
|
| 38 |
+
uvicorn forge.api:app --port 7860 # serves API + UI at http://localhost:7860
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
To grow the graph from live web data (needs the Bright Data CLI authenticated + a local `llama-server`):
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
python -m forge.campaign # discover EN+CN across components
|
| 45 |
+
python -m forge.github_collect corroborate # verify candidates against GitHub
|
| 46 |
+
```
|
forge/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Forge — compatibility graph for ML training components."""
|
forge/api.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Forge read API (piece 3) — serves the prebuilt forge.db to the frontend.
|
| 2 |
+
|
| 3 |
+
Lightweight + read-only: this is what ships to the HF Space. The heavy
|
| 4 |
+
build-time work (Bright Data scrape + local-model extraction) happens
|
| 5 |
+
upstream; the Space just serves the graph.
|
| 6 |
+
|
| 7 |
+
Run: uvicorn forge.api:app --reload --port 8000
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import asyncio
|
| 12 |
+
import json
|
| 13 |
+
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
from fastapi import Body, FastAPI, HTTPException, Query
|
| 17 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 18 |
+
from fastapi.responses import StreamingResponse
|
| 19 |
+
from fastapi.staticfiles import StaticFiles
|
| 20 |
+
|
| 21 |
+
from . import db, engine, scraper
|
| 22 |
+
|
| 23 |
+
FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend"
|
| 24 |
+
|
| 25 |
+
app = FastAPI(title="Forge API", version="0.1")
|
| 26 |
+
app.add_middleware(
|
| 27 |
+
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _conn():
|
| 32 |
+
return db.connect()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.get("/health")
|
| 36 |
+
def health():
|
| 37 |
+
return {"ok": True, "llm_up": scraper._llm_up()}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@app.get("/graph")
|
| 41 |
+
def graph():
|
| 42 |
+
"""All nodes + edges for the initial force-graph render."""
|
| 43 |
+
conn = _conn()
|
| 44 |
+
nodes = [dict(id=r["id"], canonical=r["canonical"], name=r["name"], type=r["type"],
|
| 45 |
+
description=r["description"], tags=json.loads(r["tags_json"]))
|
| 46 |
+
for r in conn.execute("SELECT * FROM nodes").fetchall()]
|
| 47 |
+
name = {r["id"]: (r["canonical"], r["name"]) for r in conn.execute("SELECT id, canonical, name FROM nodes")}
|
| 48 |
+
ev_by_edge = {}
|
| 49 |
+
for x in conn.execute("SELECT * FROM evidence").fetchall():
|
| 50 |
+
ev_by_edge.setdefault(x["edge_id"], []).append(
|
| 51 |
+
dict(url=x["url"], quote=x["quote"], source_type=x["source_type"], tier=x["source_tier"]))
|
| 52 |
+
edges = []
|
| 53 |
+
for e in conn.execute("SELECT * FROM edges").fetchall():
|
| 54 |
+
fc, fn = name[e["from_id"]]
|
| 55 |
+
tc, tn = name[e["to_id"]]
|
| 56 |
+
edges.append(dict(id=e["id"], from_canon=fc, to_canon=tc, from_name=fn, to_name=tn,
|
| 57 |
+
relation=e["relation"], conditions=json.loads(e["conditions_json"]),
|
| 58 |
+
fix=e["fix"], tier=e["tier"], confidence=e["confidence"],
|
| 59 |
+
evidence=ev_by_edge.get(e["id"], [])))
|
| 60 |
+
return {"nodes": nodes, "edges": edges}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.post("/resolve")
|
| 64 |
+
def resolve(payload: dict = Body(...)):
|
| 65 |
+
"""Grey-out engine. Body: {selected:[canonical], context:{...}}."""
|
| 66 |
+
conn = _conn()
|
| 67 |
+
return engine.resolve(conn, payload.get("selected", []), payload.get("context", {}))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@app.get("/recipe")
|
| 71 |
+
def recipe(selected: str = Query("", description="comma-separated canonicals")):
|
| 72 |
+
conn = _conn()
|
| 73 |
+
sel = [s.strip() for s in selected.split(",") if s.strip()]
|
| 74 |
+
return engine.recipe(conn, sel)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@app.get("/node/{canonical}")
|
| 78 |
+
def node(canonical: str):
|
| 79 |
+
conn = _conn()
|
| 80 |
+
row = conn.execute("SELECT * FROM nodes WHERE canonical=?", (canonical,)).fetchone()
|
| 81 |
+
if not row:
|
| 82 |
+
raise HTTPException(404, f"no node {canonical!r}")
|
| 83 |
+
nid = row["id"]
|
| 84 |
+
name = {r["id"]: r["name"] for r in conn.execute("SELECT id, name FROM nodes")}
|
| 85 |
+
edges = []
|
| 86 |
+
for e in conn.execute("SELECT * FROM edges WHERE from_id=? OR to_id=?", (nid, nid)).fetchall():
|
| 87 |
+
ev = [dict(url=x["url"], quote=x["quote"], source_type=x["source_type"], tier=x["source_tier"])
|
| 88 |
+
for x in conn.execute("SELECT * FROM evidence WHERE edge_id=?", (e["id"],)).fetchall()]
|
| 89 |
+
edges.append(dict(from_name=name[e["from_id"]], to_name=name[e["to_id"]], relation=e["relation"],
|
| 90 |
+
conditions=json.loads(e["conditions_json"]), fix=e["fix"], tier=e["tier"],
|
| 91 |
+
confidence=e["confidence"], evidence=ev))
|
| 92 |
+
bench = [dict(model=b["model"], task=b["task"], val_loss=b["val_loss_mean"], val_acc=b["val_acc_mean"],
|
| 93 |
+
steps_to_target=b["steps_to_target"], n_seeds=b["n_seeds"], source=b["source_url"])
|
| 94 |
+
for b in conn.execute("SELECT * FROM benchmarks WHERE component=?", (canonical,)).fetchall()]
|
| 95 |
+
return dict(id=nid, canonical=row["canonical"], name=row["name"], type=row["type"],
|
| 96 |
+
description=row["description"], aliases=json.loads(row["aliases_json"]),
|
| 97 |
+
tags=json.loads(row["tags_json"]), edges=edges, benchmarks=bench)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@app.get("/feed")
|
| 101 |
+
def feed(n: int = 10):
|
| 102 |
+
"""Newest edges — the 'just discovered' panel."""
|
| 103 |
+
return scraper.feed(_conn(), n)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@app.get("/review")
|
| 107 |
+
def review(n: int = 20):
|
| 108 |
+
"""Pending candidates awaiting corroboration."""
|
| 109 |
+
rows = _conn().execute(
|
| 110 |
+
"SELECT raw_a, relation, raw_b, COUNT(*) c FROM review_queue WHERE status='pending'"
|
| 111 |
+
" GROUP BY raw_a, relation, raw_b ORDER BY c DESC LIMIT ?", (n,)).fetchall()
|
| 112 |
+
return [dict(a=r["raw_a"], relation=r["relation"], b=r["raw_b"], sources=r["c"]) for r in rows]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@app.get("/feed/stream")
|
| 116 |
+
async def feed_stream():
|
| 117 |
+
"""SSE: emits an edge event whenever a newer one appears."""
|
| 118 |
+
async def gen():
|
| 119 |
+
last_id = 0
|
| 120 |
+
while True:
|
| 121 |
+
rows = _conn().execute(
|
| 122 |
+
"SELECT e.id, e.relation, e.tier, n1.name a, n2.name b FROM edges e"
|
| 123 |
+
" JOIN nodes n1 ON n1.id=e.from_id JOIN nodes n2 ON n2.id=e.to_id"
|
| 124 |
+
" WHERE e.id > ? ORDER BY e.id", (last_id,)).fetchall()
|
| 125 |
+
for r in rows:
|
| 126 |
+
last_id = max(last_id, r["id"])
|
| 127 |
+
yield f"data: {json.dumps(dict(a=r['a'], relation=r['relation'], b=r['b'], tier=r['tier']))}\n\n"
|
| 128 |
+
await asyncio.sleep(2)
|
| 129 |
+
|
| 130 |
+
return StreamingResponse(gen(), media_type="text/event-stream")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# Serve the static frontend at root (mounted LAST so API routes take precedence).
|
| 134 |
+
# Same-origin hosting → no CORS in prod, one public URL for the HF Space.
|
| 135 |
+
if FRONTEND_DIR.is_dir():
|
| 136 |
+
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="site")
|
forge/campaign.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Max-depth Bright Data collection campaign.
|
| 2 |
+
|
| 3 |
+
Exercises the full BD stack to grow the graph and back the 'Application of
|
| 4 |
+
Technology' story:
|
| 5 |
+
- discover (AI intent ranking) over every seeded component, EN + CN geo
|
| 6 |
+
- Web Unlocker page content (via discover --include-content)
|
| 7 |
+
- Scraper API (pipelines) — structured data from a pre-built scraper
|
| 8 |
+
- Scraping Browser — a JS-heavy forum page
|
| 9 |
+
|
| 10 |
+
Extraction stays local/free (Qwen via llama-server, heuristic fallback).
|
| 11 |
+
Raw payloads saved to data/raw/. Console gets compact summaries only.
|
| 12 |
+
|
| 13 |
+
Run: python -m forge.campaign # full
|
| 14 |
+
python -m forge.campaign 3 # validate on first 3 components
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import re
|
| 20 |
+
import sys
|
| 21 |
+
from datetime import datetime, timezone
|
| 22 |
+
|
| 23 |
+
from . import collect, scraper
|
| 24 |
+
from . import seed as seed_mod
|
| 25 |
+
|
| 26 |
+
# Scraper API demo: pull a repo file as structured data.
|
| 27 |
+
PIPELINE_TYPE = "github_repository_file"
|
| 28 |
+
PIPELINE_URL = "https://github.com/artidoro/qlora/blob/main/README.md"
|
| 29 |
+
FORUM_RE = re.compile(r"reddit\.com|huggingface\.co/.*/discussions|/forum|zhihu\.com|csdn\.net")
|
| 30 |
+
BROWSER_FALLBACK = "https://www.reddit.com/r/LocalLLaMA/" # JS-heavy, guaranteed Browser target
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def en_query(name):
|
| 34 |
+
return f"{name} training optimizer scheduler compatible OR incompatible OR requires"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def cn_query(name):
|
| 38 |
+
return f"{name} 微调" # 2-word CN query; 3+ keywords return 0
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def gather(label, query, n):
|
| 42 |
+
"""EN: discover (AI intent + inline content). CN: SERP + Web Unlocker scrape
|
| 43 |
+
(discover --country CN reliably returns 0; SERP CN works)."""
|
| 44 |
+
if label == "EN":
|
| 45 |
+
return [{"url": r.get("link", ""), "content": r.get("content") or ""}
|
| 46 |
+
for r in collect.discover(query, country=None, language="en", n=n)]
|
| 47 |
+
items = []
|
| 48 |
+
for hit in scraper.search(query, n=n, country="cn"):
|
| 49 |
+
items.append({"url": hit["url"], "content": scraper.fetch(hit["url"])})
|
| 50 |
+
return items
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def run(limit=None, n=8, do_pipeline=True, do_browser=True):
|
| 54 |
+
conn = seed_mod.seed()
|
| 55 |
+
ai = scraper.build_alias_index(conn)
|
| 56 |
+
resolver = scraper.build_resolver(conn)
|
| 57 |
+
comps = [(r["canonical"], r["name"]) for r in
|
| 58 |
+
conn.execute("SELECT canonical, name FROM nodes ORDER BY type, canonical").fetchall()]
|
| 59 |
+
if limit:
|
| 60 |
+
comps = comps[:limit]
|
| 61 |
+
|
| 62 |
+
totals = {"promoted": [], "queued": [], "pages": 0, "components": 0,
|
| 63 |
+
"bd_calls": 0, "first_forum": None, "products": {"discover", "web_unlocker"}}
|
| 64 |
+
raw = {"ts": datetime.now(timezone.utc).isoformat(), "passes": {}}
|
| 65 |
+
|
| 66 |
+
for canon, name in comps:
|
| 67 |
+
totals["components"] += 1
|
| 68 |
+
for label, q in [("EN", en_query(name)), ("CN", cn_query(name))]:
|
| 69 |
+
items = gather(label, q, n)
|
| 70 |
+
totals["bd_calls"] += 1
|
| 71 |
+
raw["passes"][f"{canon}:{label}"] = [{"url": i["url"]} for i in items]
|
| 72 |
+
print(f"[{canon}/{label}] {len(items)} items")
|
| 73 |
+
for it in items:
|
| 74 |
+
url, content = it["url"], it["content"]
|
| 75 |
+
if totals["first_forum"] is None and FORUM_RE.search(url):
|
| 76 |
+
totals["first_forum"] = url
|
| 77 |
+
if len(content) < 200:
|
| 78 |
+
continue
|
| 79 |
+
totals["pages"] += 1
|
| 80 |
+
res = scraper.ingest(conn, scraper.extract(content[:15000], url, ai), url, resolver)
|
| 81 |
+
totals["promoted"] += res["promoted"]
|
| 82 |
+
totals["queued"] += res["queued"]
|
| 83 |
+
|
| 84 |
+
if do_pipeline: # Scraper API (async snapshot job)
|
| 85 |
+
data, err = scraper.run_pipeline(PIPELINE_TYPE, PIPELINE_URL)
|
| 86 |
+
blob = (err or "") + json.dumps(data)
|
| 87 |
+
m = re.search(r"(sd_[A-Za-z0-9]+)", blob)
|
| 88 |
+
totals["pipeline_ok"] = bool(data) or bool(m)
|
| 89 |
+
totals["pipeline_note"] = (f"triggered snapshot {m.group(1)}" if m
|
| 90 |
+
else ("ok" if data else f"err: {(err or '')[:100]}"))
|
| 91 |
+
if totals["pipeline_ok"]:
|
| 92 |
+
totals["products"].add("scraper_api")
|
| 93 |
+
|
| 94 |
+
target = totals["first_forum"] or BROWSER_FALLBACK
|
| 95 |
+
if do_browser and target: # Scraping Browser
|
| 96 |
+
out, err, rc = scraper.browser_open(target)
|
| 97 |
+
totals["browser_ok"] = rc == 0 and bool(out)
|
| 98 |
+
totals["browser_note"] = (f"ok on {target} ({len(out)} chars)" if totals["browser_ok"]
|
| 99 |
+
else f"err: {(err or out)[:120]}")
|
| 100 |
+
if totals["browser_ok"]:
|
| 101 |
+
totals["products"].add("scraping_browser")
|
| 102 |
+
|
| 103 |
+
collect.RAW_DIR.mkdir(parents=True, exist_ok=True)
|
| 104 |
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 105 |
+
(collect.RAW_DIR / f"forge_campaign_{stamp}.json").write_text(json.dumps(raw, ensure_ascii=False))
|
| 106 |
+
return conn, totals
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def main():
|
| 110 |
+
limit = int(sys.argv[1]) if len(sys.argv) > 1 else None
|
| 111 |
+
conn, t = run(limit=limit)
|
| 112 |
+
print(f"\n=== campaign summary ===")
|
| 113 |
+
print(f"components : {t['components']}")
|
| 114 |
+
print(f"Bright Data calls: {t['bd_calls']} discover (EN+CN)")
|
| 115 |
+
print(f"pages extracted : {t['pages']}")
|
| 116 |
+
print(f"edges promoted : {len(t['promoted'])}")
|
| 117 |
+
print(f"queued candidates: {len(t['queued'])}")
|
| 118 |
+
print(f"BD products used : {', '.join(sorted(t['products']))}")
|
| 119 |
+
print(f"scraper api : {t.get('pipeline_note', 'skipped')}")
|
| 120 |
+
print(f"scraping browser : {t.get('browser_note', 'skipped')}")
|
| 121 |
+
total_edges = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
| 122 |
+
pending = conn.execute("SELECT COUNT(*) FROM review_queue WHERE status='pending'").fetchone()[0]
|
| 123 |
+
print(f"graph now : {total_edges} edges, {pending} pending candidates")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
if __name__ == "__main__":
|
| 127 |
+
main()
|
forge/collect.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live Bright Data collection run (max depth).
|
| 2 |
+
|
| 3 |
+
Uses `brightdata discover` (AI-driven intent ranking) with --include-content, so a
|
| 4 |
+
single call ranks results AND returns Web-Unlocker'd page markdown. Runs EN and
|
| 5 |
+
CN (geo-targeted) passes, extracts compatibility triples, and feeds the
|
| 6 |
+
corroboration gate. Optionally demos the Scraping Browser on a JS-heavy forum.
|
| 7 |
+
|
| 8 |
+
Bright Data is the only external dependency. Raw payloads are saved to
|
| 9 |
+
data/raw/ for provenance; only compact summaries print to the console.
|
| 10 |
+
|
| 11 |
+
Run: python -m forge.collect QLoRA
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import re
|
| 17 |
+
import sys
|
| 18 |
+
from datetime import datetime, timezone
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
from . import db, scraper
|
| 22 |
+
from . import seed as seed_mod
|
| 23 |
+
|
| 24 |
+
INTENT = (
|
| 25 |
+
"I am building a compatibility graph of ML training components. Prioritize results "
|
| 26 |
+
"that explicitly state how components combine: which optimizers, schedulers, "
|
| 27 |
+
"quantization methods, PEFT techniques and architectures work together, require each "
|
| 28 |
+
"other, or are incompatible. Favor concrete statements like 'X requires Y', 'X is "
|
| 29 |
+
"incompatible with Y', 'X works with Y under condition Z', with hardware/precision "
|
| 30 |
+
"context. Deprioritize marketing, beginner tutorials, and pure inference content."
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
RAW_DIR = Path(__file__).resolve().parent.parent / "data" / "raw"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _parse_discover(raw: str) -> list[dict]:
|
| 37 |
+
try:
|
| 38 |
+
return json.loads(raw).get("results", [])
|
| 39 |
+
except json.JSONDecodeError:
|
| 40 |
+
m = re.search(r"\{[\s\S]*\}", raw)
|
| 41 |
+
if not m:
|
| 42 |
+
return []
|
| 43 |
+
try:
|
| 44 |
+
return json.loads(m.group(0)).get("results", [])
|
| 45 |
+
except json.JSONDecodeError:
|
| 46 |
+
return []
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def discover(query, *, country=None, language="en", n=8) -> list[dict]:
|
| 50 |
+
args = ["discover", query, "--intent", INTENT, "--include-content",
|
| 51 |
+
"--num-results", str(n), "--pretty"]
|
| 52 |
+
if country:
|
| 53 |
+
args += ["--country", country]
|
| 54 |
+
if language:
|
| 55 |
+
args += ["--language", language]
|
| 56 |
+
out, _, _ = scraper._brightdata(args, timeout=600)
|
| 57 |
+
return _parse_discover(out)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def browser_showcase(url) -> str:
|
| 61 |
+
"""Demo the Scraping Browser on a JS-heavy page. Guarded; never blocks the run."""
|
| 62 |
+
try:
|
| 63 |
+
out, err, rc = scraper._brightdata(
|
| 64 |
+
["browser", "open", url, "--interactive", "--timeout", "30000", "--pretty"],
|
| 65 |
+
timeout=60,
|
| 66 |
+
)
|
| 67 |
+
return f"ok ({len(out)} chars)" if out and rc == 0 else f"no-op ({(err or '').strip()[:80]})"
|
| 68 |
+
except Exception as e: # noqa: BLE001 - showcase only, must not kill the run
|
| 69 |
+
return f"skipped ({type(e).__name__})"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def run(target="QLoRA"):
|
| 73 |
+
conn = seed_mod.seed() # fresh seed; live edges land on top
|
| 74 |
+
alias_index = scraper.build_alias_index(conn)
|
| 75 |
+
resolver = scraper.build_resolver(conn)
|
| 76 |
+
|
| 77 |
+
passes = [
|
| 78 |
+
("EN", f"{target} optimizer scheduler quantization compatible OR incompatible OR requires", None, "en"),
|
| 79 |
+
("CN", f"{target} 微调 优化器 调度器 量化 兼容 不兼容 需要", "CN", "zh"),
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
raw_bundle, total = {"target": target, "ts": datetime.now(timezone.utc).isoformat(), "passes": {}}, \
|
| 83 |
+
{"promoted": [], "queued": [], "pages": 0, "first_forum": None}
|
| 84 |
+
|
| 85 |
+
for label, q, country, lang in passes:
|
| 86 |
+
results = discover(q, country=country, language=lang, n=8)
|
| 87 |
+
raw_bundle["passes"][label] = results
|
| 88 |
+
print(f"[{label}] discover returned {len(results)} ranked results")
|
| 89 |
+
for r in results:
|
| 90 |
+
content = r.get("content") or ""
|
| 91 |
+
url = r.get("link", "")
|
| 92 |
+
if total["first_forum"] is None and re.search(r"reddit\.com|huggingface\.co/.*/discussions|forum", url):
|
| 93 |
+
total["first_forum"] = url
|
| 94 |
+
if len(content) < 200:
|
| 95 |
+
continue
|
| 96 |
+
total["pages"] += 1
|
| 97 |
+
triples = scraper.extract(content[:15000], url, alias_index)
|
| 98 |
+
res = scraper.ingest(conn, triples, url, resolver)
|
| 99 |
+
total["promoted"] += res["promoted"]
|
| 100 |
+
total["queued"] += res["queued"]
|
| 101 |
+
|
| 102 |
+
RAW_DIR.mkdir(parents=True, exist_ok=True)
|
| 103 |
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 104 |
+
(RAW_DIR / f"forge_collect_{stamp}.json").write_text(json.dumps(raw_bundle, ensure_ascii=False))
|
| 105 |
+
|
| 106 |
+
return conn, total
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def replay(path=None):
|
| 110 |
+
"""Re-extract + ingest over a saved raw bundle. No new Bright Data credits."""
|
| 111 |
+
conn = seed_mod.seed()
|
| 112 |
+
alias_index = scraper.build_alias_index(conn)
|
| 113 |
+
resolver = scraper.build_resolver(conn)
|
| 114 |
+
files = sorted(RAW_DIR.glob("forge_collect_*.json"))
|
| 115 |
+
f = Path(path) if path else (files[-1] if files else None)
|
| 116 |
+
if not f or not f.exists():
|
| 117 |
+
print("no saved bundle found — run a live collection first")
|
| 118 |
+
return conn, None
|
| 119 |
+
bundle = json.loads(f.read_text())
|
| 120 |
+
total = {"promoted": [], "queued": [], "pages": 0, "first_forum": None}
|
| 121 |
+
for results in bundle.get("passes", {}).values():
|
| 122 |
+
for r in results:
|
| 123 |
+
content = r.get("content") or ""
|
| 124 |
+
if len(content) < 200:
|
| 125 |
+
continue
|
| 126 |
+
total["pages"] += 1
|
| 127 |
+
url = r.get("link", "")
|
| 128 |
+
res = scraper.ingest(conn, scraper.extract(content[:15000], url, alias_index), url, resolver)
|
| 129 |
+
total["promoted"] += res["promoted"]
|
| 130 |
+
total["queued"] += res["queued"]
|
| 131 |
+
print(f"[replay] {f.name}: {total['pages']} pages (no credits spent)")
|
| 132 |
+
return conn, total
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _report(conn, total):
|
| 136 |
+
backend = "local-model" if scraper._llm_up() else "heuristic"
|
| 137 |
+
print(f"\n=== extraction ({backend}) ===")
|
| 138 |
+
print(f"pages extracted : {total['pages']}")
|
| 139 |
+
print(f"edges promoted : {len(total['promoted'])} (2+ independent sources agreed)")
|
| 140 |
+
for a, rel, b in total["promoted"][:12]:
|
| 141 |
+
print(f" ● {a} —{rel}→ {b}")
|
| 142 |
+
print(f"queued (1 src) : {len(total['queued'])} (waiting for corroboration)")
|
| 143 |
+
rows = conn.execute(
|
| 144 |
+
"SELECT raw_a, relation, raw_b, COUNT(*) c FROM review_queue WHERE status='pending'"
|
| 145 |
+
" GROUP BY raw_a, relation, raw_b ORDER BY c DESC LIMIT 10"
|
| 146 |
+
).fetchall()
|
| 147 |
+
if rows:
|
| 148 |
+
print("\ntop pending candidates (raw_a, rel, raw_b, #sources):")
|
| 149 |
+
for r in rows:
|
| 150 |
+
print(f" ◐ {r['raw_a']} —{r['relation']}→ {r['raw_b']} ({r['c']})")
|
| 151 |
+
print("\nlive feed (newest edges):")
|
| 152 |
+
for fd in scraper.feed(conn, n=10):
|
| 153 |
+
print(f" {fd['a']} —{fd['relation']}→ {fd['b']} (T{fd['tier']})")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def main():
|
| 157 |
+
arg = sys.argv[1] if len(sys.argv) > 1 else "QLoRA"
|
| 158 |
+
if arg == "replay":
|
| 159 |
+
conn, total = replay(sys.argv[2] if len(sys.argv) > 2 else None)
|
| 160 |
+
else:
|
| 161 |
+
conn, total = run(arg)
|
| 162 |
+
if total:
|
| 163 |
+
_report(conn, total)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
if __name__ == "__main__":
|
| 167 |
+
import os # noqa: E402 - used by _report
|
| 168 |
+
main()
|
forge/db.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Forge SQLite layer: schema, connection, insert helpers.
|
| 2 |
+
|
| 3 |
+
One file: forge.db. No vector layer in v1 (see spec). Confidence is derived
|
| 4 |
+
from tier so the scraper can never assert nonsense above what its evidence earns.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import sqlite3
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
DB_PATH = Path(__file__).with_name("forge.db")
|
| 14 |
+
|
| 15 |
+
# tier -> confidence. Tier 1 = Rick's verified research (immutable, scraper never
|
| 16 |
+
# overwrites). Tier 2 = official docs or 2+ independent sources. Tier 3 = single
|
| 17 |
+
# scrape (lands in review_queue, not asserted as a live edge).
|
| 18 |
+
TIER_CONFIDENCE = {1: 1.0, 2: 0.8, 3: 0.5}
|
| 19 |
+
|
| 20 |
+
RELATIONS = {"REQUIRES", "CONDITIONAL", "COMPATIBLE", "DEGRADES", "BREAKS"}
|
| 21 |
+
# REQUIRES is directional (from -> to). The rest are treated as undirected.
|
| 22 |
+
DIRECTED = {"REQUIRES"}
|
| 23 |
+
|
| 24 |
+
SCHEMA = """
|
| 25 |
+
CREATE TABLE IF NOT EXISTS nodes (
|
| 26 |
+
id INTEGER PRIMARY KEY,
|
| 27 |
+
type TEXT NOT NULL,
|
| 28 |
+
name TEXT NOT NULL,
|
| 29 |
+
canonical TEXT NOT NULL UNIQUE,
|
| 30 |
+
aliases_json TEXT NOT NULL DEFAULT '[]',
|
| 31 |
+
description TEXT NOT NULL DEFAULT '',
|
| 32 |
+
tags_json TEXT NOT NULL DEFAULT '[]'
|
| 33 |
+
);
|
| 34 |
+
|
| 35 |
+
CREATE TABLE IF NOT EXISTS edges (
|
| 36 |
+
id INTEGER PRIMARY KEY,
|
| 37 |
+
from_id INTEGER NOT NULL REFERENCES nodes(id),
|
| 38 |
+
to_id INTEGER NOT NULL REFERENCES nodes(id),
|
| 39 |
+
relation TEXT NOT NULL,
|
| 40 |
+
conditions_json TEXT NOT NULL DEFAULT '{}',
|
| 41 |
+
fix TEXT NOT NULL DEFAULT '',
|
| 42 |
+
tier INTEGER NOT NULL,
|
| 43 |
+
confidence REAL NOT NULL,
|
| 44 |
+
created_at TEXT NOT NULL
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
CREATE TABLE IF NOT EXISTS evidence (
|
| 48 |
+
id INTEGER PRIMARY KEY,
|
| 49 |
+
edge_id INTEGER NOT NULL REFERENCES edges(id),
|
| 50 |
+
url TEXT NOT NULL DEFAULT '',
|
| 51 |
+
quote TEXT NOT NULL DEFAULT '',
|
| 52 |
+
source_type TEXT NOT NULL DEFAULT '',
|
| 53 |
+
source_tier INTEGER NOT NULL,
|
| 54 |
+
scraped_at TEXT NOT NULL
|
| 55 |
+
);
|
| 56 |
+
|
| 57 |
+
CREATE TABLE IF NOT EXISTS review_queue (
|
| 58 |
+
id INTEGER PRIMARY KEY,
|
| 59 |
+
raw_a TEXT NOT NULL,
|
| 60 |
+
raw_b TEXT NOT NULL,
|
| 61 |
+
relation TEXT NOT NULL,
|
| 62 |
+
conditions TEXT NOT NULL DEFAULT '{}',
|
| 63 |
+
evidence_url TEXT NOT NULL DEFAULT '',
|
| 64 |
+
status TEXT NOT NULL DEFAULT 'pending'
|
| 65 |
+
);
|
| 66 |
+
|
| 67 |
+
-- Performance evidence (NOT compatibility). Drives recipe recommendations.
|
| 68 |
+
-- One row per scheduler under a fixed benchmark condition. Wall-clock is
|
| 69 |
+
-- deliberately omitted: observed elapsed had a 4x GPU-contention artifact.
|
| 70 |
+
CREATE TABLE IF NOT EXISTS benchmarks (
|
| 71 |
+
id INTEGER PRIMARY KEY,
|
| 72 |
+
component TEXT NOT NULL, -- scheduler name (matches a node canonical/alias)
|
| 73 |
+
optimizer TEXT NOT NULL DEFAULT 'AdamW',
|
| 74 |
+
model TEXT NOT NULL,
|
| 75 |
+
task TEXT NOT NULL,
|
| 76 |
+
val_loss_mean REAL,
|
| 77 |
+
val_loss_std REAL,
|
| 78 |
+
val_acc_mean REAL,
|
| 79 |
+
val_acc_std REAL,
|
| 80 |
+
steps_to_target REAL,
|
| 81 |
+
n_seeds INTEGER,
|
| 82 |
+
conditions_json TEXT NOT NULL DEFAULT '{}',
|
| 83 |
+
source_url TEXT NOT NULL DEFAULT '',
|
| 84 |
+
tier INTEGER NOT NULL DEFAULT 1
|
| 85 |
+
);
|
| 86 |
+
|
| 87 |
+
CREATE INDEX IF NOT EXISTS idx_edges_from ON edges(from_id);
|
| 88 |
+
CREATE INDEX IF NOT EXISTS idx_edges_to ON edges(to_id);
|
| 89 |
+
"""
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def now_iso() -> str:
|
| 93 |
+
return datetime.now(timezone.utc).isoformat()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def connect(path: Path | str = DB_PATH) -> sqlite3.Connection:
|
| 97 |
+
conn = sqlite3.connect(str(path))
|
| 98 |
+
conn.row_factory = sqlite3.Row
|
| 99 |
+
conn.execute("PRAGMA foreign_keys = ON")
|
| 100 |
+
return conn
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def init_schema(conn: sqlite3.Connection) -> None:
|
| 104 |
+
conn.executescript(SCHEMA)
|
| 105 |
+
conn.commit()
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def reset(path: Path | str = DB_PATH) -> sqlite3.Connection:
|
| 109 |
+
"""Drop and recreate from scratch. Used by seed/harness for a clean build."""
|
| 110 |
+
p = Path(path)
|
| 111 |
+
if p.exists():
|
| 112 |
+
p.unlink()
|
| 113 |
+
conn = connect(path)
|
| 114 |
+
init_schema(conn)
|
| 115 |
+
return conn
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def add_node(conn, *, type, name, canonical, aliases=None, description="", tags=None) -> int:
|
| 119 |
+
cur = conn.execute(
|
| 120 |
+
"INSERT INTO nodes(type, name, canonical, aliases_json, description, tags_json)"
|
| 121 |
+
" VALUES(?,?,?,?,?,?)",
|
| 122 |
+
(type, name, canonical, json.dumps(aliases or []), description, json.dumps(tags or [])),
|
| 123 |
+
)
|
| 124 |
+
return cur.lastrowid
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def node_id(conn, canonical: str) -> int:
|
| 128 |
+
row = conn.execute("SELECT id FROM nodes WHERE canonical=?", (canonical,)).fetchone()
|
| 129 |
+
if row is None:
|
| 130 |
+
raise KeyError(f"no node with canonical={canonical!r}")
|
| 131 |
+
return row["id"]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def add_edge(conn, *, from_canon, to_canon, relation, tier, conditions=None, fix="",
|
| 135 |
+
evidence=None) -> int:
|
| 136 |
+
if relation not in RELATIONS:
|
| 137 |
+
raise ValueError(f"bad relation {relation!r}")
|
| 138 |
+
fid, tid = node_id(conn, from_canon), node_id(conn, to_canon)
|
| 139 |
+
cur = conn.execute(
|
| 140 |
+
"INSERT INTO edges(from_id, to_id, relation, conditions_json, fix, tier, confidence, created_at)"
|
| 141 |
+
" VALUES(?,?,?,?,?,?,?,?)",
|
| 142 |
+
(fid, tid, relation, json.dumps(conditions or {}), fix, tier,
|
| 143 |
+
TIER_CONFIDENCE[tier], now_iso()),
|
| 144 |
+
)
|
| 145 |
+
edge_id = cur.lastrowid
|
| 146 |
+
for ev in (evidence or []):
|
| 147 |
+
conn.execute(
|
| 148 |
+
"INSERT INTO evidence(edge_id, url, quote, source_type, source_tier, scraped_at)"
|
| 149 |
+
" VALUES(?,?,?,?,?,?)",
|
| 150 |
+
(edge_id, ev.get("url", ""), ev.get("quote", ""), ev.get("source_type", ""),
|
| 151 |
+
ev.get("source_tier", tier), ev.get("scraped_at", now_iso())),
|
| 152 |
+
)
|
| 153 |
+
return edge_id
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def add_review(conn, *, raw_a, raw_b, relation, conditions=None, evidence_url="") -> int:
|
| 157 |
+
cur = conn.execute(
|
| 158 |
+
"INSERT INTO review_queue(raw_a, raw_b, relation, conditions, evidence_url)"
|
| 159 |
+
" VALUES(?,?,?,?,?)",
|
| 160 |
+
(raw_a, raw_b, relation, json.dumps(conditions or {}), evidence_url),
|
| 161 |
+
)
|
| 162 |
+
return cur.lastrowid
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def add_evidence(conn, edge_id, ev) -> int:
|
| 166 |
+
cur = conn.execute(
|
| 167 |
+
"INSERT INTO evidence(edge_id, url, quote, source_type, source_tier, scraped_at)"
|
| 168 |
+
" VALUES(?,?,?,?,?,?)",
|
| 169 |
+
(edge_id, ev.get("url", ""), ev.get("quote", ""), ev.get("source_type", ""),
|
| 170 |
+
ev.get("source_tier", 3), ev.get("scraped_at", now_iso())),
|
| 171 |
+
)
|
| 172 |
+
return cur.lastrowid
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def add_benchmark(conn, *, component, model, task, val_loss_mean, val_loss_std,
|
| 176 |
+
val_acc_mean, val_acc_std, steps_to_target, n_seeds,
|
| 177 |
+
optimizer="AdamW", conditions=None, source_url="", tier=1) -> int:
|
| 178 |
+
cur = conn.execute(
|
| 179 |
+
"INSERT INTO benchmarks(component, optimizer, model, task, val_loss_mean,"
|
| 180 |
+
" val_loss_std, val_acc_mean, val_acc_std, steps_to_target, n_seeds,"
|
| 181 |
+
" conditions_json, source_url, tier) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
| 182 |
+
(component, optimizer, model, task, val_loss_mean, val_loss_std, val_acc_mean,
|
| 183 |
+
val_acc_std, steps_to_target, n_seeds, json.dumps(conditions or {}),
|
| 184 |
+
source_url, tier),
|
| 185 |
+
)
|
| 186 |
+
return cur.lastrowid
|
forge/engine.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Forge engine.
|
| 2 |
+
|
| 3 |
+
resolve() -- the grey-out engine. Given a selected set + a context, classify
|
| 4 |
+
every other node as available / conditional / blocked, with the
|
| 5 |
+
reason and cited evidence. Pairwise over the edge graph (v1).
|
| 6 |
+
recommend() -- reads the benchmark table to rank components under a condition.
|
| 7 |
+
Performance evidence, kept separate from compatibility.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
from . import db
|
| 13 |
+
|
| 14 |
+
# status severity ordering (worst wins when a node has multiple edges)
|
| 15 |
+
SEVERITY = {"available": 0, "conditional": 1, "blocked": 2}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _node_map(conn):
|
| 19 |
+
rows = conn.execute("SELECT id, canonical, name, type FROM nodes").fetchall()
|
| 20 |
+
by_id = {r["id"]: dict(id=r["id"], canonical=r["canonical"], name=r["name"], type=r["type"]) for r in rows}
|
| 21 |
+
by_canon = {r["canonical"]: r["id"] for r in rows}
|
| 22 |
+
# aliases
|
| 23 |
+
for r in conn.execute("SELECT id, aliases_json FROM nodes").fetchall():
|
| 24 |
+
for a in json.loads(r["aliases_json"]):
|
| 25 |
+
by_canon.setdefault(a, r["id"])
|
| 26 |
+
return by_id, by_canon
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _conditions_met(conditions: dict, context: dict) -> bool:
|
| 30 |
+
"""A CONDITIONAL/REQUIRES edge is satisfied if context matches all its keys."""
|
| 31 |
+
for k, v in conditions.items():
|
| 32 |
+
if k == "note":
|
| 33 |
+
continue
|
| 34 |
+
if str(context.get(k)) != str(v):
|
| 35 |
+
return False
|
| 36 |
+
return True
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _edges_touching(conn, node_id, selected_ids):
|
| 40 |
+
"""All edges between node_id and any selected node (both directions)."""
|
| 41 |
+
rows = conn.execute(
|
| 42 |
+
"SELECT * FROM edges WHERE (from_id=? AND to_id IN (%s)) OR (to_id=? AND from_id IN (%s))"
|
| 43 |
+
% (",".join("?" * len(selected_ids)), ",".join("?" * len(selected_ids))),
|
| 44 |
+
[node_id, *selected_ids, node_id, *selected_ids],
|
| 45 |
+
).fetchall() if selected_ids else []
|
| 46 |
+
return rows
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _evidence_for(conn, edge_id):
|
| 50 |
+
return [dict(url=r["url"], quote=r["quote"], source_type=r["source_type"], tier=r["source_tier"])
|
| 51 |
+
for r in conn.execute("SELECT * FROM evidence WHERE edge_id=?", (edge_id,)).fetchall()]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def resolve(conn, selected, context=None):
|
| 55 |
+
"""selected: list of canonical names (or aliases). context: dict of chosen settings.
|
| 56 |
+
Returns {canonical: {status, reasons:[...]}} for every node not in selected."""
|
| 57 |
+
context = context or {}
|
| 58 |
+
by_id, by_canon = _node_map(conn)
|
| 59 |
+
selected_ids = [by_canon[s] for s in selected if s in by_canon]
|
| 60 |
+
selected_set = set(selected_ids)
|
| 61 |
+
|
| 62 |
+
out = {}
|
| 63 |
+
for nid, node in by_id.items():
|
| 64 |
+
if nid in selected_set:
|
| 65 |
+
continue
|
| 66 |
+
status, reasons = "available", []
|
| 67 |
+
|
| 68 |
+
for e in _edges_touching(conn, nid, selected_ids):
|
| 69 |
+
rel = e["relation"]
|
| 70 |
+
conds = json.loads(e["conditions_json"])
|
| 71 |
+
other = e["to_id"] if e["from_id"] == nid else e["from_id"]
|
| 72 |
+
other_name = by_id[other]["name"]
|
| 73 |
+
ev = _evidence_for(conn, e["id"])
|
| 74 |
+
|
| 75 |
+
if rel == "BREAKS":
|
| 76 |
+
status = _worst(status, "blocked")
|
| 77 |
+
reasons.append(_reason("blocked", f"breaks with {other_name}", e["fix"], conds, ev, e["tier"], e["confidence"]))
|
| 78 |
+
elif rel in ("REQUIRES", "CONDITIONAL"):
|
| 79 |
+
if not _conditions_met(conds, context):
|
| 80 |
+
status = _worst(status, "conditional")
|
| 81 |
+
label = f"requires {other_name}" if rel == "REQUIRES" else f"conditional with {other_name}"
|
| 82 |
+
reasons.append(_reason("conditional", label, e["fix"], conds, ev, e["tier"], e["confidence"]))
|
| 83 |
+
elif rel == "DEGRADES":
|
| 84 |
+
reasons.append(_reason("degrades", f"degrades with {other_name}", e["fix"], conds, ev, e["tier"], e["confidence"]))
|
| 85 |
+
|
| 86 |
+
# node's own unmet REQUIRES pointing at something not selected
|
| 87 |
+
for e in conn.execute("SELECT * FROM edges WHERE from_id=? AND relation='REQUIRES'", (nid,)).fetchall():
|
| 88 |
+
if e["to_id"] not in selected_set and not _conditions_met(json.loads(e["conditions_json"]), context):
|
| 89 |
+
req = by_id[e["to_id"]]["name"]
|
| 90 |
+
status = _worst(status, "conditional")
|
| 91 |
+
reasons.append(_reason("conditional", f"requires {req}", e["fix"],
|
| 92 |
+
json.loads(e["conditions_json"]), _evidence_for(conn, e["id"]),
|
| 93 |
+
e["tier"], e["confidence"]))
|
| 94 |
+
|
| 95 |
+
out[node["canonical"]] = dict(name=node["name"], type=node["type"], status=status, reasons=reasons)
|
| 96 |
+
return out
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _worst(a, b):
|
| 100 |
+
return a if SEVERITY[a] >= SEVERITY[b] else b
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _reason(kind, label, fix, conditions, evidence, tier, confidence):
|
| 104 |
+
return dict(kind=kind, label=label, fix=fix, conditions=conditions,
|
| 105 |
+
evidence=evidence, tier=tier, confidence=confidence)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def recommend(conn, *, metric="val_loss", task=None, top=5):
|
| 109 |
+
"""Rank benchmarked components by a metric. metric in {val_loss, val_acc}."""
|
| 110 |
+
order = "ASC" if metric == "val_loss" else "DESC"
|
| 111 |
+
col = "val_loss_mean" if metric == "val_loss" else "val_acc_mean"
|
| 112 |
+
sql = f"SELECT * FROM benchmarks WHERE {col} IS NOT NULL"
|
| 113 |
+
params = []
|
| 114 |
+
if task:
|
| 115 |
+
sql += " AND task=?"
|
| 116 |
+
params.append(task)
|
| 117 |
+
sql += f" ORDER BY {col} {order} LIMIT ?"
|
| 118 |
+
params.append(top)
|
| 119 |
+
rows = conn.execute(sql, params).fetchall()
|
| 120 |
+
return [dict(component=r["component"], val_loss=r["val_loss_mean"], val_acc=r["val_acc_mean"],
|
| 121 |
+
steps_to_target=r["steps_to_target"], n_seeds=r["n_seeds"],
|
| 122 |
+
source=r["source_url"]) for r in rows]
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def recipe(conn, selected, context=None):
|
| 126 |
+
"""Validate a selection: conflicts (BREAKS within set), unmet REQUIRES/CONDITIONAL,
|
| 127 |
+
plus benchmark-backed scheduler suggestions. The 'recipe card' payload."""
|
| 128 |
+
context = context or {}
|
| 129 |
+
by_id, by_canon = _node_map(conn)
|
| 130 |
+
sel_ids = {by_canon[s] for s in selected if s in by_canon}
|
| 131 |
+
|
| 132 |
+
conflicts, unmet = [], []
|
| 133 |
+
for e in conn.execute("SELECT * FROM edges").fetchall():
|
| 134 |
+
if e["from_id"] not in sel_ids:
|
| 135 |
+
continue
|
| 136 |
+
if e["relation"] == "BREAKS" and e["to_id"] in sel_ids:
|
| 137 |
+
conflicts.append({"a": by_id[e["from_id"]]["name"], "b": by_id[e["to_id"]]["name"],
|
| 138 |
+
"fix": e["fix"], "tier": e["tier"]})
|
| 139 |
+
elif e["relation"] in ("REQUIRES", "CONDITIONAL") and e["to_id"] not in sel_ids \
|
| 140 |
+
and not _conditions_met(json.loads(e["conditions_json"]), context):
|
| 141 |
+
unmet.append({"component": by_id[e["from_id"]]["name"], "needs": by_id[e["to_id"]]["name"],
|
| 142 |
+
"relation": e["relation"], "fix": e["fix"], "tier": e["tier"]})
|
| 143 |
+
|
| 144 |
+
return {
|
| 145 |
+
"selected": selected,
|
| 146 |
+
"context": context,
|
| 147 |
+
"valid": not conflicts and not unmet,
|
| 148 |
+
"conflicts": conflicts,
|
| 149 |
+
"unmet": unmet,
|
| 150 |
+
"scheduler_picks": recommend(conn, metric="val_loss", top=3),
|
| 151 |
+
"warnings": warnings(conn),
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def warnings(conn, *, task=None):
|
| 156 |
+
"""Components that clearly underperform the field (loss > field_mean + 2*field_std)."""
|
| 157 |
+
rows = conn.execute("SELECT component, val_loss_mean FROM benchmarks WHERE val_loss_mean IS NOT NULL").fetchall()
|
| 158 |
+
if not rows:
|
| 159 |
+
return []
|
| 160 |
+
vals = [r["val_loss_mean"] for r in rows]
|
| 161 |
+
mean = sum(vals) / len(vals)
|
| 162 |
+
std = (sum((v - mean) ** 2 for v in vals) / len(vals)) ** 0.5
|
| 163 |
+
cutoff = mean + 2 * std
|
| 164 |
+
return [dict(component=r["component"], val_loss=r["val_loss_mean"], cutoff=round(cutoff, 4))
|
| 165 |
+
for r in rows if r["val_loss_mean"] > cutoff]
|
forge/github_collect.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GitHub-focused collection — the highest-signal compatibility source.
|
| 2 |
+
|
| 3 |
+
READMEs state intended composition ("uses bitsandbytes 4-bit", "requires LoRA");
|
| 4 |
+
issues are where real incompatibilities surface ("X breaks with Y", error reports).
|
| 5 |
+
We mine both via Bright Data Web Unlocker, plus fire a Scraper-API snapshot
|
| 6 |
+
(github_repository_file) as the structured-data showcase.
|
| 7 |
+
|
| 8 |
+
Adds to the EXISTING forge.db (does not reseed) so it stacks on the campaign.
|
| 9 |
+
|
| 10 |
+
Run: python -m forge.github_collect
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import re
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
|
| 18 |
+
from . import collect, db, scraper
|
| 19 |
+
|
| 20 |
+
# High-signal ML training repos — where composition + breakage actually get documented.
|
| 21 |
+
REPOS = [
|
| 22 |
+
"artidoro/qlora", "unslothai/unsloth", "huggingface/peft", "huggingface/trl",
|
| 23 |
+
"huggingface/transformers", "bitsandbytes-foundation/bitsandbytes",
|
| 24 |
+
"microsoft/DeepSpeed", "Dao-AILab/flash-attention", "huggingface/accelerate",
|
| 25 |
+
"KellerJordan/modded-nanogpt",
|
| 26 |
+
]
|
| 27 |
+
PIPELINE_TYPE = "github_repository_file"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def issue_query(repo):
|
| 31 |
+
return f'site:github.com/{repo} incompatible OR "does not work" OR requires OR breaks'
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def run(repos=None, issues_per_repo=4):
|
| 35 |
+
repos = repos or REPOS
|
| 36 |
+
conn = db.connect() # stack onto existing graph
|
| 37 |
+
ai = scraper.build_alias_index(conn)
|
| 38 |
+
resolver = scraper.build_resolver(conn)
|
| 39 |
+
totals = {"promoted": [], "queued": [], "readmes": 0, "issues": 0, "repos": 0,
|
| 40 |
+
"snapshots": []}
|
| 41 |
+
raw = {"ts": datetime.utcnow().isoformat(), "pages": {}}
|
| 42 |
+
|
| 43 |
+
for repo in repos:
|
| 44 |
+
totals["repos"] += 1
|
| 45 |
+
# 1. README (rendered repo page via Web Unlocker)
|
| 46 |
+
md = scraper.fetch(f"https://github.com/{repo}")
|
| 47 |
+
if md and len(md) > 200:
|
| 48 |
+
totals["readmes"] += 1
|
| 49 |
+
raw["pages"][f"{repo}#readme"] = md[:20000]
|
| 50 |
+
res = scraper.ingest(conn, scraper.extract(md[:15000], f"https://github.com/{repo}", ai),
|
| 51 |
+
f"https://github.com/{repo}", resolver)
|
| 52 |
+
totals["promoted"] += res["promoted"]; totals["queued"] += res["queued"]
|
| 53 |
+
|
| 54 |
+
# 2. issue threads (richest breakage signal) via SERP -> Web Unlocker
|
| 55 |
+
for hit in scraper.search(issue_query(repo), n=issues_per_repo):
|
| 56 |
+
url = hit["url"]
|
| 57 |
+
if "/issues/" not in url and "/pull/" not in url:
|
| 58 |
+
continue
|
| 59 |
+
page = scraper.fetch(url)
|
| 60 |
+
if not page or len(page) < 200:
|
| 61 |
+
continue
|
| 62 |
+
totals["issues"] += 1
|
| 63 |
+
raw["pages"][url] = page[:20000]
|
| 64 |
+
res = scraper.ingest(conn, scraper.extract(page[:15000], url, ai), url, resolver)
|
| 65 |
+
totals["promoted"] += res["promoted"]; totals["queued"] += res["queued"]
|
| 66 |
+
print(f"[{repo}] readme+{totals['issues']} issues so far")
|
| 67 |
+
|
| 68 |
+
# 3. Scraper API showcase — structured file pulls (async snapshots)
|
| 69 |
+
for repo in repos[:3]:
|
| 70 |
+
data, err = scraper.run_pipeline(PIPELINE_TYPE, f"https://github.com/{repo}/blob/main/README.md")
|
| 71 |
+
m = re.search(r"(sd_[A-Za-z0-9]+)", (err or "") + json.dumps(data))
|
| 72 |
+
if m:
|
| 73 |
+
totals["snapshots"].append(m.group(1))
|
| 74 |
+
|
| 75 |
+
collect.RAW_DIR.mkdir(parents=True, exist_ok=True)
|
| 76 |
+
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 77 |
+
(collect.RAW_DIR / f"forge_github_{stamp}.json").write_text(json.dumps(raw, ensure_ascii=False))
|
| 78 |
+
return conn, totals
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def corroborate_via_github(conn=None, limit=30):
|
| 82 |
+
"""Rick's idea: verify pending candidates against GitHub (secondary/authoritative
|
| 83 |
+
source). For each single-source (A, rel, B) in the queue, search GitHub for the
|
| 84 |
+
same pair; if the model re-extracts that edge from a GitHub page, it's an
|
| 85 |
+
independent 2nd source → the gate promotes it."""
|
| 86 |
+
conn = conn or db.connect()
|
| 87 |
+
ai = scraper.build_alias_index(conn)
|
| 88 |
+
resolver = scraper.build_resolver(conn)
|
| 89 |
+
names = {r["canonical"]: r["name"] for r in conn.execute("SELECT canonical, name FROM nodes")}
|
| 90 |
+
pend = conn.execute(
|
| 91 |
+
"SELECT DISTINCT raw_a, relation, raw_b FROM review_queue WHERE status='pending'").fetchall()
|
| 92 |
+
promoted, checked = [], 0
|
| 93 |
+
for row in pend:
|
| 94 |
+
a, b = row["raw_a"], row["raw_b"]
|
| 95 |
+
if a not in names or b not in names: # only verify already-resolved candidates
|
| 96 |
+
continue
|
| 97 |
+
if checked >= limit:
|
| 98 |
+
break
|
| 99 |
+
checked += 1
|
| 100 |
+
q = f'site:github.com {names[a]} {names[b]}'
|
| 101 |
+
for hit in scraper.search(q, n=3):
|
| 102 |
+
page = scraper.fetch(hit["url"])
|
| 103 |
+
if not page or len(page) < 200:
|
| 104 |
+
continue
|
| 105 |
+
res = scraper.ingest(conn, scraper.extract(page[:15000], hit["url"], ai), hit["url"], resolver)
|
| 106 |
+
promoted += res["promoted"]
|
| 107 |
+
print(f"[corroborate] {names[a]}—{row['relation']}→{names[b]}: {len(promoted)} promoted so far")
|
| 108 |
+
return conn, promoted
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def main():
|
| 112 |
+
import sys
|
| 113 |
+
if len(sys.argv) > 1 and sys.argv[1] == "corroborate":
|
| 114 |
+
conn, promoted = corroborate_via_github()
|
| 115 |
+
print(f"\n=== github corroboration ===")
|
| 116 |
+
print(f"edges promoted by GitHub verification: {len(promoted)}")
|
| 117 |
+
for a, rel, b in promoted:
|
| 118 |
+
print(f" ● {a} —{rel}→ {b}")
|
| 119 |
+
edges = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
| 120 |
+
print(f"graph now: {edges} edges")
|
| 121 |
+
return
|
| 122 |
+
conn, t = run()
|
| 123 |
+
print(f"\n=== github campaign ===")
|
| 124 |
+
print(f"repos : {t['repos']}")
|
| 125 |
+
print(f"readmes scraped: {t['readmes']}")
|
| 126 |
+
print(f"issues scraped : {t['issues']}")
|
| 127 |
+
print(f"edges promoted : {len(t['promoted'])}")
|
| 128 |
+
print(f"queued : {len(t['queued'])}")
|
| 129 |
+
print(f"scraper-api snapshots: {len(t['snapshots'])} {t['snapshots']}")
|
| 130 |
+
edges = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
| 131 |
+
pending = conn.execute("SELECT COUNT(*) FROM review_queue WHERE status='pending'").fetchone()[0]
|
| 132 |
+
print(f"graph now : {edges} edges, {pending} pending candidates")
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
if __name__ == "__main__":
|
| 136 |
+
main()
|
forge/harness.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Piece 1 harness: seed the graph, then watch the engine work on real data.
|
| 2 |
+
|
| 3 |
+
Run: python -m forge.harness
|
| 4 |
+
No network, no server. Pure spine. Includes asserts so we know it's correct.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from . import seed as seed_mod
|
| 9 |
+
from . import engine
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
C_RED, C_YEL, C_GRN, C_DIM, C_RST = "\033[91m", "\033[93m", "\033[92m", "\033[2m", "\033[0m"
|
| 13 |
+
ICON = {"available": f"{C_GRN}●{C_RST}", "conditional": f"{C_YEL}◐{C_RST}", "blocked": f"{C_RED}○{C_RST}"}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def show(conn, selected, context=None):
|
| 17 |
+
context = context or {}
|
| 18 |
+
ctx = f" context={context}" if context else ""
|
| 19 |
+
print(f"\n{C_DIM}── selected: {selected}{ctx} ──{C_RST}")
|
| 20 |
+
res = engine.resolve(conn, selected, context)
|
| 21 |
+
for status in ("blocked", "conditional", "available"):
|
| 22 |
+
for canon, info in sorted(res.items()):
|
| 23 |
+
if info["status"] != status:
|
| 24 |
+
continue
|
| 25 |
+
line = f" {ICON[status]} {info['name']:<24} [{info['type']}]"
|
| 26 |
+
if info["reasons"]:
|
| 27 |
+
r = info["reasons"][0]
|
| 28 |
+
tag = f"T{r['tier']}·{r['confidence']:.1f}"
|
| 29 |
+
line += f" {C_DIM}{r['label']} ({tag}){C_RST}"
|
| 30 |
+
if r["fix"]:
|
| 31 |
+
line += f"\n {C_DIM}↳ fix: {r['fix']}{C_RST}"
|
| 32 |
+
print(line)
|
| 33 |
+
return res
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def main():
|
| 37 |
+
conn = seed_mod.seed()
|
| 38 |
+
print(f"{C_GRN}forge.db seeded.{C_RST}")
|
| 39 |
+
|
| 40 |
+
# Scenario 1: your verified tier-1 edge. Pick per-layer LR rotation -> AdamW 8-bit blocks.
|
| 41 |
+
res = show(conn, ["per_layer_lr_rotation"])
|
| 42 |
+
assert res["adamw_8bit"]["status"] == "blocked", "tier-1 edge should block adamw_8bit"
|
| 43 |
+
|
| 44 |
+
# Scenario 2: QLoRA pulls in its requirement (bnb_4bit) and blocks full fine-tune.
|
| 45 |
+
res = show(conn, ["qlora"])
|
| 46 |
+
assert res["fft"]["status"] == "blocked", "QLoRA and FFT are mutually exclusive"
|
| 47 |
+
assert res["bnb_4bit"]["status"] in ("conditional", "available")
|
| 48 |
+
|
| 49 |
+
# Scenario 3: conditional flips on context. vLLM + gradient_checkpointing.
|
| 50 |
+
r_off = engine.resolve(conn, ["vllm"], {"gradient_checkpointing": "disabled"})
|
| 51 |
+
r_on = engine.resolve(conn, ["vllm"], {"gradient_checkpointing": "enabled"})
|
| 52 |
+
assert r_off["gradient_checkpointing"]["status"] == "conditional"
|
| 53 |
+
assert r_on["gradient_checkpointing"]["status"] == "available"
|
| 54 |
+
show(conn, ["vllm"], {"gradient_checkpointing": "disabled"})
|
| 55 |
+
|
| 56 |
+
# Scenario 4: benchmark-backed recommendation (performance, not compatibility).
|
| 57 |
+
print(f"\n{C_DIM}── recipe: best schedulers by val_loss (your benchmark) ──{C_RST}")
|
| 58 |
+
for i, r in enumerate(engine.recommend(conn, metric="val_loss", top=5), 1):
|
| 59 |
+
print(f" {i}. {r['component']:<16} val_loss={r['val_loss']:.4f} val_acc={r['val_acc']:.4f} (n={r['n_seeds']})")
|
| 60 |
+
print(f"\n{C_DIM}── underperformers (>2σ worse on val_loss) ──{C_RST}")
|
| 61 |
+
for w in engine.warnings(conn):
|
| 62 |
+
print(f" {C_RED}⚠ {w['component']}{C_RST} val_loss={w['val_loss']:.4f} (field cutoff {w['cutoff']})")
|
| 63 |
+
|
| 64 |
+
print(f"\n{C_GRN}all assertions passed.{C_RST}")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
if __name__ == "__main__":
|
| 68 |
+
main()
|
forge/scraper.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Piece 2: the Bright Data scraper that grows the graph.
|
| 2 |
+
|
| 3 |
+
Flow: search (SERP) -> scrape (Web Unlocker) -> Haiku extract triples
|
| 4 |
+
-> entity-resolve against node aliases -> corroboration gate
|
| 5 |
+
-> edges (2+ independent sources) OR review_queue (single/unknown).
|
| 6 |
+
|
| 7 |
+
The corroboration gate is what keeps the graph clean as it grows: a single
|
| 8 |
+
scraped claim never becomes an asserted edge. It waits in review_queue until a
|
| 9 |
+
second independent source says the same thing, then it promotes to a tier-2 edge.
|
| 10 |
+
Tier-1 (Rick's) edges are never touched by this path.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
import os
|
| 16 |
+
import re
|
| 17 |
+
import subprocess
|
| 18 |
+
import urllib.request
|
| 19 |
+
|
| 20 |
+
from . import db
|
| 21 |
+
|
| 22 |
+
# COMPATIBLE/BREAKS/DEGRADES are undirected; dedupe (a,b) and (b,a) into one edge.
|
| 23 |
+
SYMMETRIC = {"COMPATIBLE", "BREAKS", "DEGRADES"}
|
| 24 |
+
# Local OpenAI-compatible LLM (llama.cpp server). No API key, fully local/free.
|
| 25 |
+
LLM_URL = os.environ.get("FORGE_LLM_URL", "http://localhost:8080")
|
| 26 |
+
|
| 27 |
+
# Key-free extraction. Bright Data is the ONLY external dependency — no LLM API.
|
| 28 |
+
# We scan scraped text for sentences mentioning >=2 known components plus a
|
| 29 |
+
# relation cue word, and emit candidate triples. Noisy by design; the
|
| 30 |
+
# corroboration gate (2+ independent sources) is what makes the graph clean.
|
| 31 |
+
# Checked in priority order so "not compatible" classifies BREAKS, not COMPATIBLE.
|
| 32 |
+
RELATION_CUES = [
|
| 33 |
+
("BREAKS", ["incompatible with", "not compatible with", "cannot be used with",
|
| 34 |
+
"can't be used with", "does not work with", "doesn't work with",
|
| 35 |
+
"breaks with", "conflicts with", "unsupported with", "mutually exclusive",
|
| 36 |
+
"not supported with", "fails with"]),
|
| 37 |
+
("REQUIRES", ["requires", "depends on", "only works with", "must use", "must be used with",
|
| 38 |
+
"needs", "is required for"]),
|
| 39 |
+
("DEGRADES", ["slower with", "degrades", "hurts performance", "worse with",
|
| 40 |
+
"regression with", "underperforms"]),
|
| 41 |
+
("CONDITIONAL", ["works with .* if", "compatible .* when", "as long as", "provided that",
|
| 42 |
+
"only if", "when using"]),
|
| 43 |
+
("COMPATIBLE", ["works with", "compatible with", "pairs with", "combined with",
|
| 44 |
+
"alongside", "together with", "in combination with", "uses", "use",
|
| 45 |
+
"used with", "integrated with", "built on", "based on", "leverages",
|
| 46 |
+
"supports", "relies on", "applied to", "runs on", "paired with",
|
| 47 |
+
"on top of", "enabled with"]),
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _brightdata(args, timeout=120):
|
| 52 |
+
r = subprocess.run(["brightdata", *args], capture_output=True, text=True, timeout=timeout)
|
| 53 |
+
return r.stdout, r.stderr, r.returncode
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _json_from_output(out: str) -> dict:
|
| 57 |
+
"""CLI prints progress lines before the JSON body; extract the JSON object."""
|
| 58 |
+
try:
|
| 59 |
+
return json.loads(out)
|
| 60 |
+
except json.JSONDecodeError:
|
| 61 |
+
m = re.search(r"\{[\s\S]*\}", out)
|
| 62 |
+
if not m:
|
| 63 |
+
return {}
|
| 64 |
+
try:
|
| 65 |
+
return json.loads(m.group(0))
|
| 66 |
+
except json.JSONDecodeError:
|
| 67 |
+
return {}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def search(query, n=8, country=None):
|
| 71 |
+
args = ["search", query, "--pretty"]
|
| 72 |
+
if country:
|
| 73 |
+
args += ["--country", country]
|
| 74 |
+
out, _, _ = _brightdata(args)
|
| 75 |
+
d = _json_from_output(out)
|
| 76 |
+
organic = d.get("organic") or d.get("results") or []
|
| 77 |
+
hits = []
|
| 78 |
+
for o in organic[:n]:
|
| 79 |
+
url = o.get("link") or o.get("url")
|
| 80 |
+
if url:
|
| 81 |
+
hits.append({"url": url, "title": o.get("title", ""), "description": o.get("description", "")})
|
| 82 |
+
return hits
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def fetch(url, timeout=90):
|
| 86 |
+
out, _, _ = _brightdata(["scrape", url, "--format", "markdown"], timeout=timeout)
|
| 87 |
+
return out.strip()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def run_pipeline(ptype, *params, timeout=600):
|
| 91 |
+
"""Bright Data Scraper API — structured data from a pre-built pipeline."""
|
| 92 |
+
out, err, rc = _brightdata(["pipelines", ptype, *params, "--pretty"], timeout=timeout)
|
| 93 |
+
return _json_from_output(out), (err or "").strip()
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def browser_open(url, timeout=90):
|
| 97 |
+
"""Bright Data Scraping Browser — navigate a JS-heavy page, return a snapshot."""
|
| 98 |
+
out, err, rc = _brightdata(["browser", "open", url, "--pretty"], timeout=timeout)
|
| 99 |
+
return out.strip(), (err or "").strip(), rc
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# --- entity resolution -------------------------------------------------------
|
| 103 |
+
|
| 104 |
+
def _norm(s: str) -> str:
|
| 105 |
+
return re.sub(r"[^a-z0-9]", "", (s or "").lower())
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def build_resolver(conn) -> dict[str, str]:
|
| 109 |
+
"""Map normalized canonical/name/alias -> canonical. Single source of truth."""
|
| 110 |
+
res = {}
|
| 111 |
+
for r in conn.execute("SELECT canonical, name, aliases_json FROM nodes").fetchall():
|
| 112 |
+
for k in [r["canonical"], r["name"], *json.loads(r["aliases_json"])]:
|
| 113 |
+
res[_norm(k)] = r["canonical"]
|
| 114 |
+
return res
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def resolve(name: str, resolver: dict[str, str]):
|
| 118 |
+
return resolver.get(_norm(name))
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# --- key-free extraction -----------------------------------------------------
|
| 122 |
+
|
| 123 |
+
def _variants(alias: str) -> set[str]:
|
| 124 |
+
"""Separator variants so 'adamw 8-bit' also matches 'adamw_8bit', 'adamw-8bit', etc."""
|
| 125 |
+
parts = re.split(r"[-_ ]+", alias)
|
| 126 |
+
if len(parts) == 1:
|
| 127 |
+
return {alias}
|
| 128 |
+
return {sep.join(parts) for sep in (" ", "-", "_", "")}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def build_alias_index(conn) -> list[tuple[str, str]]:
|
| 132 |
+
"""[(alias_lower, canonical)] sorted longest-first so 'adamw 8bit' beats 'adamw'."""
|
| 133 |
+
idx = set()
|
| 134 |
+
for r in conn.execute("SELECT canonical, name, aliases_json FROM nodes").fetchall():
|
| 135 |
+
for k in [r["canonical"], r["name"], *json.loads(r["aliases_json"])]:
|
| 136 |
+
for v in _variants(k.lower()):
|
| 137 |
+
idx.add((v, r["canonical"]))
|
| 138 |
+
return sorted(idx, key=lambda x: -len(x[0]))
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _find_components(sentence: str, alias_index) -> list[str]:
|
| 142 |
+
"""Canonicals mentioned, in order of first appearance, longest-alias-wins, no overlaps."""
|
| 143 |
+
s = sentence.lower()
|
| 144 |
+
consumed, found = [], []
|
| 145 |
+
for alias, canon in alias_index: # longest first
|
| 146 |
+
for m in re.finditer(r"(?<![a-z0-9])" + re.escape(alias) + r"(?![a-z0-9])", s):
|
| 147 |
+
span = (m.start(), m.end())
|
| 148 |
+
if any(span[0] < e and span[1] > b for b, e in consumed):
|
| 149 |
+
continue
|
| 150 |
+
consumed.append(span)
|
| 151 |
+
found.append((span[0], canon))
|
| 152 |
+
seen, ordered = set(), []
|
| 153 |
+
for _, canon in sorted(found):
|
| 154 |
+
if canon not in seen:
|
| 155 |
+
seen.add(canon)
|
| 156 |
+
ordered.append(canon)
|
| 157 |
+
return ordered
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _classify(sentence: str):
|
| 161 |
+
s = sentence.lower()
|
| 162 |
+
for relation, cues in RELATION_CUES:
|
| 163 |
+
for cue in cues:
|
| 164 |
+
if (".*" in cue and re.search(cue, s)) or (".*" not in cue and cue in s):
|
| 165 |
+
return relation
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def extract(markdown: str, source_url: str, alias_index) -> list[dict]:
|
| 170 |
+
"""Dispatch: local llama.cpp model if its server is up, else keyless heuristic."""
|
| 171 |
+
if _llm_up():
|
| 172 |
+
try:
|
| 173 |
+
return _extract_local(markdown, alias_index)
|
| 174 |
+
except Exception: # noqa: BLE001 - never let extraction kill a run
|
| 175 |
+
pass
|
| 176 |
+
return _extract_heuristic(markdown, alias_index)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def _clean_markdown(md: str) -> str:
|
| 180 |
+
"""Strip nav/links/images and short non-prose lines so extractors see signal."""
|
| 181 |
+
md = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", md) # images
|
| 182 |
+
md = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", md) # links -> anchor text
|
| 183 |
+
out = []
|
| 184 |
+
for ln in md.splitlines():
|
| 185 |
+
s = ln.strip()
|
| 186 |
+
if len(s) >= 20 and s.count(" ") >= 3: # keep prose, drop nav
|
| 187 |
+
out.append(s)
|
| 188 |
+
return "\n".join(out)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _extract_heuristic(markdown: str, alias_index) -> list[dict]:
|
| 192 |
+
"""Scan text for (a, relation, b) candidates. Co-mention -> weak COMPATIBLE.
|
| 193 |
+
Noisy by design; the corroboration gate filters it."""
|
| 194 |
+
triples, seen = [], set()
|
| 195 |
+
for raw in re.split(r"(?<=[.!?])\s+|\n+", _clean_markdown(markdown)):
|
| 196 |
+
seg = raw.strip()
|
| 197 |
+
if len(seg) < 12 or len(seg) > 400:
|
| 198 |
+
continue
|
| 199 |
+
comps = _find_components(seg, alias_index)
|
| 200 |
+
if len(comps) < 2:
|
| 201 |
+
continue
|
| 202 |
+
relation = _classify(seg) or "COMPATIBLE" # co-mention fallback
|
| 203 |
+
a, b = comps[0], comps[1]
|
| 204 |
+
key = (a, relation, b)
|
| 205 |
+
if a == b or key in seen:
|
| 206 |
+
continue
|
| 207 |
+
seen.add(key)
|
| 208 |
+
triples.append({"component_a": a, "relation": relation, "component_b": b,
|
| 209 |
+
"conditions": {}, "fix": "", "evidence_quote": seg[:300]})
|
| 210 |
+
return triples
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
_LLM_PROMPT = """You extract ML training-component compatibility relationships from text.
|
| 214 |
+
Content may be Chinese/Japanese/English — respond in English.
|
| 215 |
+
Return a JSON object: {"relations": [ {"component_a","relation","component_b","conditions","fix","evidence_quote"} ]}
|
| 216 |
+
relation in: REQUIRES|CONDITIONAL|COMPATIBLE|DEGRADES|BREAKS.
|
| 217 |
+
Only relationships with DIRECT textual evidence. Prefer CONDITIONAL/REQUIRES over BREAKS.
|
| 218 |
+
If none, return {"relations": []}."""
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def _llm_up() -> bool:
|
| 222 |
+
try:
|
| 223 |
+
with urllib.request.urlopen(LLM_URL.rstrip("/") + "/v1/models", timeout=2) as r:
|
| 224 |
+
return r.status == 200
|
| 225 |
+
except Exception: # noqa: BLE001
|
| 226 |
+
return False
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def _extract_local(markdown: str, alias_index) -> list[dict]:
|
| 230 |
+
"""Extract via a local llama.cpp OpenAI-compatible server. No API key."""
|
| 231 |
+
vocab = sorted({c for _, c in alias_index})
|
| 232 |
+
system = _LLM_PROMPT + "\nKnown components (prefer these names when they match): " + ", ".join(vocab[:60])
|
| 233 |
+
payload = {
|
| 234 |
+
"messages": [{"role": "system", "content": system},
|
| 235 |
+
{"role": "user", "content": _clean_markdown(markdown)[:12000]}],
|
| 236 |
+
"temperature": 0,
|
| 237 |
+
"max_tokens": 1500,
|
| 238 |
+
"response_format": {"type": "json_object"},
|
| 239 |
+
}
|
| 240 |
+
req = urllib.request.Request(
|
| 241 |
+
LLM_URL.rstrip("/") + "/v1/chat/completions",
|
| 242 |
+
data=json.dumps(payload).encode(),
|
| 243 |
+
headers={"Content-Type": "application/json", "Authorization": "Bearer no-key"},
|
| 244 |
+
)
|
| 245 |
+
with urllib.request.urlopen(req, timeout=120) as r:
|
| 246 |
+
resp = json.loads(r.read())
|
| 247 |
+
text = resp["choices"][0]["message"]["content"]
|
| 248 |
+
m = re.search(r"\{[\s\S]*\}", text)
|
| 249 |
+
obj = json.loads(m.group(0)) if m else {}
|
| 250 |
+
out = []
|
| 251 |
+
for t in obj.get("relations", []):
|
| 252 |
+
comps_a = _find_components(str(t.get("component_a", "")), alias_index)
|
| 253 |
+
comps_b = _find_components(str(t.get("component_b", "")), alias_index)
|
| 254 |
+
if comps_a and comps_b:
|
| 255 |
+
t["component_a"], t["component_b"] = comps_a[0], comps_b[0]
|
| 256 |
+
out.append(t)
|
| 257 |
+
return out
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
# --- ingestion with corroboration gate ---------------------------------------
|
| 261 |
+
|
| 262 |
+
def _corroborated(conn, a, rel, b, source_url) -> bool:
|
| 263 |
+
"""True if a DIFFERENT source already asserted this (a, rel, b). Direction-aware."""
|
| 264 |
+
if rel in SYMMETRIC:
|
| 265 |
+
rows = conn.execute(
|
| 266 |
+
"SELECT evidence_url FROM review_queue WHERE relation=? AND status='pending'"
|
| 267 |
+
" AND ((raw_a=? AND raw_b=?) OR (raw_a=? AND raw_b=?))", (rel, a, b, b, a)).fetchall()
|
| 268 |
+
else:
|
| 269 |
+
rows = conn.execute(
|
| 270 |
+
"SELECT evidence_url FROM review_queue WHERE relation=? AND status='pending'"
|
| 271 |
+
" AND raw_a=? AND raw_b=?", (rel, a, b)).fetchall()
|
| 272 |
+
return any(r["evidence_url"] and r["evidence_url"] != source_url for r in rows)
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _existing_edge(conn, a_id, b_id, rel):
|
| 276 |
+
"""Edge id if one already exists (symmetric relations match either direction)."""
|
| 277 |
+
if rel in SYMMETRIC:
|
| 278 |
+
row = conn.execute(
|
| 279 |
+
"SELECT id FROM edges WHERE relation=? AND ((from_id=? AND to_id=?) OR (from_id=? AND to_id=?))",
|
| 280 |
+
(rel, a_id, b_id, b_id, a_id)).fetchone()
|
| 281 |
+
else:
|
| 282 |
+
row = conn.execute("SELECT id FROM edges WHERE relation=? AND from_id=? AND to_id=?",
|
| 283 |
+
(rel, a_id, b_id)).fetchone()
|
| 284 |
+
return row["id"] if row else None
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def _mark_promoted(conn, a, b, rel):
|
| 288 |
+
if rel in SYMMETRIC:
|
| 289 |
+
conn.execute("UPDATE review_queue SET status='promoted' WHERE relation=? AND"
|
| 290 |
+
" ((raw_a=? AND raw_b=?) OR (raw_a=? AND raw_b=?))", (rel, a, b, b, a))
|
| 291 |
+
else:
|
| 292 |
+
conn.execute("UPDATE review_queue SET status='promoted' WHERE relation=? AND raw_a=? AND raw_b=?",
|
| 293 |
+
(rel, a, b))
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def ingest(conn, triples, source_url, resolver) -> dict:
|
| 297 |
+
promoted, queued = [], []
|
| 298 |
+
for t in triples:
|
| 299 |
+
rel = (t.get("relation") or "").upper()
|
| 300 |
+
if rel not in db.RELATIONS:
|
| 301 |
+
continue
|
| 302 |
+
a = resolve(t.get("component_a", ""), resolver)
|
| 303 |
+
b = resolve(t.get("component_b", ""), resolver)
|
| 304 |
+
conditions = t.get("conditions") or {}
|
| 305 |
+
|
| 306 |
+
if not a or not b: # unknown entity -> queue with raw names
|
| 307 |
+
db.add_review(conn, raw_a=t.get("component_a", ""), raw_b=t.get("component_b", ""),
|
| 308 |
+
relation=rel, conditions=conditions, evidence_url=source_url)
|
| 309 |
+
queued.append((t.get("component_a"), rel, t.get("component_b")))
|
| 310 |
+
continue
|
| 311 |
+
|
| 312 |
+
ev = {"url": source_url, "quote": t.get("evidence_quote", ""),
|
| 313 |
+
"source_type": "scraped", "source_tier": 3}
|
| 314 |
+
existing = _existing_edge(conn, db.node_id(conn, a), db.node_id(conn, b), rel)
|
| 315 |
+
if existing: # already an edge -> just accumulate evidence
|
| 316 |
+
db.add_evidence(conn, existing, ev)
|
| 317 |
+
elif _corroborated(conn, a, rel, b, source_url): # 2nd independent source -> promote
|
| 318 |
+
db.add_edge(conn, from_canon=a, to_canon=b, relation=rel, tier=2,
|
| 319 |
+
conditions=conditions, fix=t.get("fix", ""), evidence=[ev])
|
| 320 |
+
promoted.append((a, rel, b))
|
| 321 |
+
_mark_promoted(conn, a, b, rel)
|
| 322 |
+
else: # single source so far -> queue (resolved canonicals)
|
| 323 |
+
db.add_review(conn, raw_a=a, raw_b=b, relation=rel, conditions=conditions, evidence_url=source_url)
|
| 324 |
+
queued.append((a, rel, b))
|
| 325 |
+
conn.commit()
|
| 326 |
+
return {"promoted": promoted, "queued": queued}
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def discover(conn, component, n=5, resolver=None, alias_index=None) -> dict:
|
| 330 |
+
"""One full pass for a component: search -> scrape -> extract -> ingest.
|
| 331 |
+
Bright Data is the only external dependency."""
|
| 332 |
+
resolver = resolver or build_resolver(conn)
|
| 333 |
+
alias_index = alias_index or build_alias_index(conn)
|
| 334 |
+
queries = [
|
| 335 |
+
f'"{component}" training optimizer scheduler compatible OR incompatible',
|
| 336 |
+
f'{component} requires OR breaks OR works with fine-tuning',
|
| 337 |
+
]
|
| 338 |
+
seen, total = set(), {"promoted": [], "queued": [], "pages": 0}
|
| 339 |
+
for q in queries:
|
| 340 |
+
for hit in search(q, n=n):
|
| 341 |
+
if hit["url"] in seen:
|
| 342 |
+
continue
|
| 343 |
+
seen.add(hit["url"])
|
| 344 |
+
md = fetch(hit["url"])
|
| 345 |
+
if not md or len(md) < 200:
|
| 346 |
+
continue
|
| 347 |
+
total["pages"] += 1
|
| 348 |
+
res = ingest(conn, extract(md, hit["url"], alias_index), hit["url"], resolver)
|
| 349 |
+
total["promoted"] += res["promoted"]
|
| 350 |
+
total["queued"] += res["queued"]
|
| 351 |
+
return total
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def feed(conn, n=10) -> list[dict]:
|
| 355 |
+
rows = conn.execute(
|
| 356 |
+
"SELECT e.relation, e.tier, e.created_at, n1.name AS a, n2.name AS b "
|
| 357 |
+
"FROM edges e JOIN nodes n1 ON n1.id=e.from_id JOIN nodes n2 ON n2.id=e.to_id "
|
| 358 |
+
"ORDER BY e.created_at DESC LIMIT ?", (n,),
|
| 359 |
+
).fetchall()
|
| 360 |
+
return [dict(a=r["a"], relation=r["relation"], b=r["b"], tier=r["tier"], created_at=r["created_at"]) for r in rows]
|
forge/seed.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Seed the Forge graph.
|
| 2 |
+
|
| 3 |
+
Three kinds of truth, kept honestly separate:
|
| 4 |
+
- NODES: the components.
|
| 5 |
+
- EDGES: real *relational* facts (composes / requires / breaks). Tiered.
|
| 6 |
+
- BENCHMARKS: real *performance* evidence (Rick's LR-scheduler benchmark).
|
| 7 |
+
Drives recommendations, NOT the grey-out graph.
|
| 8 |
+
Unverified ecosystem claims go to review_queue, never asserted as live edges.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from . import db
|
| 13 |
+
|
| 14 |
+
BENCH_URL = "https://huggingface.co/spaces/juiceb0xc0de/lr-scheduler-benchmark"
|
| 15 |
+
MEMPALACE = "mempalace://chaos-injection-trainer-notes"
|
| 16 |
+
|
| 17 |
+
# (canonical, type, display_name, aliases, description)
|
| 18 |
+
NODES = [
|
| 19 |
+
# optimizers
|
| 20 |
+
("adamw", "optimizer", "AdamW", ["adam", "adamw_torch"], "Decoupled weight-decay Adam. The benchmark's fixed optimizer."),
|
| 21 |
+
("adamw_8bit", "optimizer", "AdamW 8-bit", ["adamw8bit", "bnb_adamw_8bit"], "bitsandbytes 8-bit AdamW. Default decay/no-decay param groups."),
|
| 22 |
+
("paged_adamw_8bit", "optimizer", "Paged AdamW 8-bit", ["paged_adamw"], "Paged 8-bit AdamW; pairs with QLoRA in the QLoRA paper."),
|
| 23 |
+
("muon", "optimizer", "Muon", [], "Orthogonalizing momentum optimizer for 2D weight matrices."),
|
| 24 |
+
# schedulers (subset; benchmark covers 25)
|
| 25 |
+
("cosine", "scheduler", "Cosine", ["cosine_schedule"], "Cosine decay LR schedule."),
|
| 26 |
+
("wsd", "scheduler", "WSD", ["warmup_stable_decay"], "Warmup-Stable-Decay schedule."),
|
| 27 |
+
("constant", "scheduler", "Constant", ["constant_lr"], "Flat LR. Topped accuracy in the benchmark."),
|
| 28 |
+
("linear", "scheduler", "Linear", ["linear_decay"], "Linear decay schedule."),
|
| 29 |
+
("onecycle", "scheduler", "OneCycle", ["one_cycle"], "One-cycle policy. Clear underperformer on short classification finetune."),
|
| 30 |
+
("deep_chaos_scheduler", "scheduler", "DeepChaosScheduler", ["lucky_pick", "lucky-pick-scheduler"], "Rick's scheduler. Top-tier accuracy in the benchmark."),
|
| 31 |
+
("aecs", "scheduler", "AECS", [], "Rick's AECS scheduler. #3 on val_loss in the benchmark."),
|
| 32 |
+
("dlrs", "scheduler", "DLRS", [], "Rick's dynamic LR scheduler. #1 on val_loss in the benchmark."),
|
| 33 |
+
("greedy_lr", "scheduler", "GreedyLR", [], "Rick's greedy LR scheduler. #2 on val_loss in the benchmark."),
|
| 34 |
+
# techniques
|
| 35 |
+
("lora", "technique", "LoRA", [], "Low-rank adaptation."),
|
| 36 |
+
("qlora", "technique", "QLoRA", [], "LoRA on a 4-bit quantized base."),
|
| 37 |
+
("fft", "technique", "Full Fine-Tune", ["full_finetune", "full_finetuning"], "Update all weights."),
|
| 38 |
+
("gradient_checkpointing", "technique", "Gradient Checkpointing", ["grad_ckpt", "checkpointing"], "Trade compute for memory by recomputing activations."),
|
| 39 |
+
("response_only", "technique", "Response-Only Training", ["completion_only", "response_only_training"], "Loss only on completion tokens."),
|
| 40 |
+
("packed_sequences", "technique", "Packed Sequences", ["packing"], "Pack multiple samples per sequence."),
|
| 41 |
+
("per_layer_lr_rotation", "technique", "Per-Layer LR Rotation", ["wavelength_rotation"], "Rotate LR across layer bands during training."),
|
| 42 |
+
# quantization
|
| 43 |
+
("bnb_4bit", "quantization", "bnb 4-bit", ["nf4", "4bit"], "bitsandbytes 4-bit (NF4) base quantization."),
|
| 44 |
+
("bnb_8bit", "quantization", "bnb 8-bit", ["8bit"], "bitsandbytes 8-bit quantization."),
|
| 45 |
+
# inference
|
| 46 |
+
("vllm", "inference", "vLLM", [], "High-throughput inference/generation engine."),
|
| 47 |
+
# architectures
|
| 48 |
+
("qwen2_5", "architecture", "Qwen2.5", ["qwen2.5", "qwen"], "Qwen2.5 family."),
|
| 49 |
+
("llama3", "architecture", "Llama 3", ["llama-3"], "Llama 3 family."),
|
| 50 |
+
("gemma2", "architecture", "Gemma 2", ["gemma"], "Gemma 2 family."),
|
| 51 |
+
("distilbert", "architecture", "DistilBERT", ["distilbert-base-uncased"], "Benchmark model: DistilBERT on SST-2."),
|
| 52 |
+
]
|
| 53 |
+
|
| 54 |
+
# Real relational edges. tier 1 = Rick-verified; tier 2 = documented/definitional.
|
| 55 |
+
EDGES = [
|
| 56 |
+
# --- tier 1: verified in Rick's mempalace / codebase ---
|
| 57 |
+
dict(from_canon="per_layer_lr_rotation", to_canon="adamw_8bit", relation="BREAKS", tier=1,
|
| 58 |
+
fix="Pass custom optimizer_grouped_parameters split by layer band, OR apply the LR as a per-param multiplier instead of relying on default groups.",
|
| 59 |
+
evidence=[dict(url=MEMPALACE, source_type="practitioner_run",
|
| 60 |
+
quote="adamw_8bit produces decay/no-decay param groups by default, not layer-band groups; WavelengthRotationCallback maps params to layers but the plumbing doesn't reach the right target.")]),
|
| 61 |
+
dict(from_canon="deep_chaos_scheduler", to_canon="adamw", relation="COMPATIBLE", tier=1,
|
| 62 |
+
evidence=[dict(url=MEMPALACE, source_type="practitioner_run",
|
| 63 |
+
quote="DeepChaosScheduler runs on top of AdamW; confirmed in Rick's codebase and the LR-scheduler benchmark.")]),
|
| 64 |
+
|
| 65 |
+
# --- tier 2: documented / definitional ---
|
| 66 |
+
dict(from_canon="qlora", to_canon="bnb_4bit", relation="REQUIRES", tier=2,
|
| 67 |
+
fix="QLoRA fine-tunes LoRA adapters over a 4-bit (NF4) frozen base by definition.",
|
| 68 |
+
evidence=[dict(url="https://arxiv.org/abs/2305.14314", source_type="paper",
|
| 69 |
+
quote="QLoRA backpropagates gradients through a frozen, 4-bit quantized base model into LoRA adapters.")]),
|
| 70 |
+
dict(from_canon="qlora", to_canon="paged_adamw_8bit", relation="COMPATIBLE", tier=2,
|
| 71 |
+
evidence=[dict(url="https://arxiv.org/abs/2305.14314", source_type="paper",
|
| 72 |
+
quote="QLoRA uses paged optimizers to manage memory spikes.")]),
|
| 73 |
+
dict(from_canon="qlora", to_canon="fft", relation="BREAKS", tier=2,
|
| 74 |
+
fix="QLoRA and full fine-tuning are mutually exclusive training modes for one run; pick one.",
|
| 75 |
+
evidence=[dict(url="https://arxiv.org/abs/2305.14314", source_type="paper",
|
| 76 |
+
quote="QLoRA freezes the base model and trains only adapters; full fine-tuning updates all weights.")]),
|
| 77 |
+
dict(from_canon="response_only", to_canon="packed_sequences", relation="CONDITIONAL", tier=2,
|
| 78 |
+
conditions={"loss_mask": "packing_aware"},
|
| 79 |
+
fix="Use a completion-only loss mask that is packing-aware so loss isn't computed across packed sample boundaries.",
|
| 80 |
+
evidence=[dict(url="https://huggingface.co/docs/trl", source_type="docs",
|
| 81 |
+
quote="Completion-only loss with packed sequences requires correct masking at sample boundaries.")]),
|
| 82 |
+
dict(from_canon="vllm", to_canon="gradient_checkpointing", relation="CONDITIONAL", tier=2,
|
| 83 |
+
conditions={"gradient_checkpointing": "enabled"},
|
| 84 |
+
fix="Keep gradient_checkpointing ENABLED — disabling it is not compatible with vLLM generation (TRL).",
|
| 85 |
+
evidence=[dict(url="https://huggingface.co/docs/trl", source_type="docs",
|
| 86 |
+
quote="Disabling this option is not compatible with vLLM generation.")]),
|
| 87 |
+
dict(from_canon="gradient_checkpointing", to_canon="lora", relation="COMPATIBLE", tier=2,
|
| 88 |
+
evidence=[dict(url="https://huggingface.co/docs/peft", source_type="docs",
|
| 89 |
+
quote="Gradient checkpointing is commonly combined with LoRA to cut activation memory.")]),
|
| 90 |
+
]
|
| 91 |
+
|
| 92 |
+
# Unverified ecosystem claims — proposed, NOT asserted. Sit in review_queue until
|
| 93 |
+
# corroborated by 2+ sources or Rick approves.
|
| 94 |
+
REVIEW = [
|
| 95 |
+
dict(raw_a="muon", raw_b="bnb_4bit", relation="CONDITIONAL",
|
| 96 |
+
conditions={"note": "Muon orthogonalizes 2D weight matrices; whether it composes cleanly with a 4-bit frozen base is unverified."},
|
| 97 |
+
evidence_url=""),
|
| 98 |
+
dict(raw_a="muon", raw_b="mup_scaling", relation="COMPATIBLE",
|
| 99 |
+
conditions={"note": "Claimed in 'Practical Efficiency of Muon' — needs a real source before promotion."},
|
| 100 |
+
evidence_url=""),
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
# Rick's LR-scheduler benchmark leaderboard. DistilBERT / SST-2, 3 seeds each.
|
| 104 |
+
# tuple: (scheduler, val_loss_mean, val_loss_std, val_acc_mean, val_acc_std, steps_to_target)
|
| 105 |
+
BENCH_MODEL, BENCH_TASK = "distilbert-base-uncased", "glue/sst2"
|
| 106 |
+
BENCH_CONDITIONS = {"batch_size": 32, "num_epochs": 3, "lr": 2e-5, "weight_decay": 0.01,
|
| 107 |
+
"warmup_fraction": 0.06, "target_loss": 0.35}
|
| 108 |
+
LEADERBOARD = [
|
| 109 |
+
("dlrs", 0.26528, 0.00207, 0.89029, 0.00369, 266.7),
|
| 110 |
+
("greedy_lr", 0.26876, 0.00651, 0.89220, 0.00344, 266.7),
|
| 111 |
+
("aecs", 0.29851, 0.00828, 0.90443, 0.00369, 400.0),
|
| 112 |
+
("cosine_with_restarts", 0.30654, 0.01373, 0.90482, 0.00607, 266.7),
|
| 113 |
+
("rex", 0.30660, 0.01280, 0.90405, 0.00265, 266.7),
|
| 114 |
+
("exp_increasing", 0.31259, 0.06081, 0.89144, 0.00672, 1850.0),
|
| 115 |
+
("step_decay", 0.31675, 0.00386, 0.90138, 0.00115, 266.7),
|
| 116 |
+
("wsd_long", 0.31957, 0.01864, 0.90099, 0.00350, 266.7),
|
| 117 |
+
("wsd", 0.32265, 0.01259, 0.90329, 0.00132, 266.7),
|
| 118 |
+
("cyclic_lr", 0.33390, 0.00633, 0.90214, 0.00239, 250.0),
|
| 119 |
+
("constant_with_warmup", 0.34331, 0.00169, 0.90023, 0.00413, 266.7),
|
| 120 |
+
("polynomial", 0.34423, 0.00307, 0.90443, 0.00477, 266.7),
|
| 121 |
+
("scaling_law_opt", 0.34616, 0.01730, 0.90482, 0.00229, 266.7),
|
| 122 |
+
("plateau_reduction", 0.34947, 0.00682, 0.90176, 0.00662, 266.7),
|
| 123 |
+
("cosine_annealing", 0.35219, 0.02189, 0.90405, 0.00701, 266.7),
|
| 124 |
+
("constant", 0.35229, 0.03222, 0.90749, 0.00463, 150.0),
|
| 125 |
+
("cosine", 0.35329, 0.01951, 0.90329, 0.00577, 266.7),
|
| 126 |
+
("rlrs", 0.35473, 0.00483, 0.90214, 0.00350, 266.7),
|
| 127 |
+
("sqrt_scheduler", 0.35477, 0.01790, 0.89985, 0.00066, 266.7),
|
| 128 |
+
("inverse_sqrt", 0.35477, 0.01790, 0.89985, 0.00066, 266.7),
|
| 129 |
+
("mnist_example", 0.35602, 0.03941, 0.90558, 0.00588, 150.0),
|
| 130 |
+
("exponential", 0.35784, 0.01552, 0.90176, 0.00434, 266.7),
|
| 131 |
+
("lucky_pick", 0.35881, 0.01650, 0.90635, 0.00239, 266.7),
|
| 132 |
+
("linear", 0.35951, 0.01049, 0.90596, 0.00229, 266.7),
|
| 133 |
+
("onecycle", 0.42844, 0.02122, 0.86430, 0.00403, 183.3),
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def seed(path=db.DB_PATH):
|
| 138 |
+
conn = db.reset(path)
|
| 139 |
+
for canon, type_, name, aliases, desc in NODES:
|
| 140 |
+
db.add_node(conn, type=type_, name=name, canonical=canon, aliases=aliases, description=desc)
|
| 141 |
+
for e in EDGES:
|
| 142 |
+
db.add_edge(conn, **e)
|
| 143 |
+
for r in REVIEW:
|
| 144 |
+
db.add_review(conn, **r)
|
| 145 |
+
for sched, lm, ls, am, asd, stt in LEADERBOARD:
|
| 146 |
+
db.add_benchmark(conn, component=sched, model=BENCH_MODEL, task=BENCH_TASK,
|
| 147 |
+
val_loss_mean=lm, val_loss_std=ls, val_acc_mean=am, val_acc_std=asd,
|
| 148 |
+
steps_to_target=stt, n_seeds=3, conditions=BENCH_CONDITIONS,
|
| 149 |
+
source_url=BENCH_URL, tier=1)
|
| 150 |
+
conn.commit()
|
| 151 |
+
return conn
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
c = seed()
|
| 156 |
+
n = c.execute("SELECT COUNT(*) FROM nodes").fetchone()[0]
|
| 157 |
+
e = c.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
| 158 |
+
b = c.execute("SELECT COUNT(*) FROM benchmarks").fetchone()[0]
|
| 159 |
+
q = c.execute("SELECT COUNT(*) FROM review_queue").fetchone()[0]
|
| 160 |
+
print(f"seeded: {n} nodes, {e} edges, {b} benchmark rows, {q} review-queue items")
|
forge/test_scraper.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline proof for piece 2: extractor + corroboration gate. No network, no keys.
|
| 2 |
+
|
| 3 |
+
Run: python -m forge.test_scraper
|
| 4 |
+
Proves the graph-growth logic is sound before we spend a single Bright Data credit.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
from . import seed as seed_mod
|
| 9 |
+
from . import scraper
|
| 10 |
+
|
| 11 |
+
C_GRN, C_YEL, C_DIM, C_RST = "\033[92m", "\033[93m", "\033[2m", "\033[0m"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def main():
|
| 15 |
+
conn = seed_mod.seed()
|
| 16 |
+
alias_index = scraper.build_alias_index(conn)
|
| 17 |
+
resolver = scraper.build_resolver(conn)
|
| 18 |
+
|
| 19 |
+
# 1. extractor: longest-alias-wins + negation-aware classification
|
| 20 |
+
comps = scraper._find_components("training adamw8bit with a cosine schedule", alias_index)
|
| 21 |
+
assert "adamw_8bit" in comps and "adamw" not in comps, f"longest-alias-wins failed: {comps}"
|
| 22 |
+
assert scraper._classify("Muon is not compatible with 4bit") == "BREAKS"
|
| 23 |
+
assert scraper._classify("QLoRA works with LoRA adapters") == "COMPATIBLE"
|
| 24 |
+
assert scraper._classify("OneCycle requires a warmup phase and cosine") == "REQUIRES"
|
| 25 |
+
print(f"{C_GRN}✓ extractor: alias resolution + relation classification{C_RST}")
|
| 26 |
+
|
| 27 |
+
# 2. a single scraped page -> candidate triples (heuristic path, hermetic:
|
| 28 |
+
# call _extract_heuristic directly so the test never depends on a live LLM server)
|
| 29 |
+
page1 = "In our runs, Muon is incompatible with 4bit quantization on consumer GPUs."
|
| 30 |
+
triples = scraper._extract_heuristic(page1, alias_index)
|
| 31 |
+
assert any(t["component_a"] == "muon" and t["relation"] == "BREAKS" and t["component_b"] == "bnb_4bit"
|
| 32 |
+
for t in triples), f"expected muon-BREAKS-bnb_4bit, got {triples}"
|
| 33 |
+
print(f"{C_GRN}✓ extract: found (muon, BREAKS, bnb_4bit) from scraped text{C_RST}")
|
| 34 |
+
|
| 35 |
+
# 3. THE GATE: one source queues, a second independent source promotes to an edge
|
| 36 |
+
before = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
| 37 |
+
r1 = scraper.ingest(conn, triples, "https://src-one.example/post", resolver)
|
| 38 |
+
assert r1["queued"] and not r1["promoted"], "first source must queue, not assert"
|
| 39 |
+
assert conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0] == before, "no edge from one source"
|
| 40 |
+
print(f"{C_YEL}◐ source 1: queued (not asserted) — {r1['queued']}{C_RST}")
|
| 41 |
+
|
| 42 |
+
page2 = "We confirmed Muon does not work with 4bit during fine-tuning."
|
| 43 |
+
triples2 = scraper._extract_heuristic(page2, alias_index)
|
| 44 |
+
r2 = scraper.ingest(conn, triples2, "https://src-two.example/thread", resolver)
|
| 45 |
+
assert ("muon", "BREAKS", "bnb_4bit") in r2["promoted"], f"second source should promote, got {r2}"
|
| 46 |
+
after = conn.execute("SELECT COUNT(*) FROM edges").fetchone()[0]
|
| 47 |
+
assert after == before + 1, "exactly one new edge after corroboration"
|
| 48 |
+
print(f"{C_GRN}● source 2 (independent): PROMOTED to tier-2 edge — {r2['promoted']}{C_RST}")
|
| 49 |
+
|
| 50 |
+
# 4. it shows up in the live feed
|
| 51 |
+
top = scraper.feed(conn, n=3)
|
| 52 |
+
assert any(f["a"] == "Muon" and f["b"] == "bnb 4-bit" for f in top), f"edge not in feed: {top}"
|
| 53 |
+
print(f"\n{C_DIM}── live feed (newest edges) ──{C_RST}")
|
| 54 |
+
for f in top:
|
| 55 |
+
print(f" {f['a']} —{f['relation']}→ {f['b']} (T{f['tier']}) {C_DIM}{f['created_at'][:19]}{C_RST}")
|
| 56 |
+
|
| 57 |
+
print(f"\n{C_GRN}all assertions passed — gate keeps single-source claims out of the graph.{C_RST}")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
if __name__ == "__main__":
|
| 61 |
+
main()
|
frontend/app.jsx
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// FORGE — main app
|
| 2 |
+
// Wires the inventory, graph, recipe card, ticker, tooltip, and resolve state.
|
| 3 |
+
|
| 4 |
+
const { useState, useEffect, useMemo, useCallback, useRef } = React;
|
| 5 |
+
|
| 6 |
+
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
| 7 |
+
"showTicker": true,
|
| 8 |
+
"showSparks": true,
|
| 9 |
+
"sound": true,
|
| 10 |
+
"edgeIntensity": "balanced",
|
| 11 |
+
"demoMode": false,
|
| 12 |
+
"preset": "blank"
|
| 13 |
+
}/*EDITMODE-END*/;
|
| 14 |
+
|
| 15 |
+
function App() {
|
| 16 |
+
const nodes = window.FORGE_NODES;
|
| 17 |
+
const edges = window.FORGE_EDGES;
|
| 18 |
+
|
| 19 |
+
const [t, setTweak] = window.useTweaks(TWEAK_DEFAULTS);
|
| 20 |
+
|
| 21 |
+
const [selected, setSelected] = useState([]);
|
| 22 |
+
const [hover, setHover] = useState(null); // { node, edge, status, reasons, pos }
|
| 23 |
+
|
| 24 |
+
// Recompute resolved status whenever selection changes.
|
| 25 |
+
const resolved = useMemo(() => window.forgeResolve(selected), [selected]);
|
| 26 |
+
const recipe = useMemo(() => window.forgeRecipe(selected), [selected]);
|
| 27 |
+
|
| 28 |
+
const toggleSelect = useCallback((canon) => {
|
| 29 |
+
setSelected(prev => {
|
| 30 |
+
// Don't allow selecting blocked nodes — give feedback by briefly shaking
|
| 31 |
+
const r = window.forgeResolve(prev);
|
| 32 |
+
const cur = r[canon];
|
| 33 |
+
if (cur && cur.status === "blocked" && !prev.includes(canon)) {
|
| 34 |
+
flashBlocked(canon);
|
| 35 |
+
window.forgeSound && window.forgeSound.sizzle();
|
| 36 |
+
return prev;
|
| 37 |
+
}
|
| 38 |
+
if (prev.includes(canon)) {
|
| 39 |
+
window.forgeSound && window.forgeSound.thud();
|
| 40 |
+
return prev.filter(x => x !== canon);
|
| 41 |
+
}
|
| 42 |
+
window.forgeSound && window.forgeSound.clang();
|
| 43 |
+
return [...prev, canon];
|
| 44 |
+
});
|
| 45 |
+
}, []);
|
| 46 |
+
|
| 47 |
+
const addOnly = useCallback((canon) => {
|
| 48 |
+
setSelected(prev => {
|
| 49 |
+
if (prev.includes(canon)) return prev;
|
| 50 |
+
window.forgeSound && window.forgeSound.chime();
|
| 51 |
+
return [...prev, canon];
|
| 52 |
+
});
|
| 53 |
+
}, []);
|
| 54 |
+
|
| 55 |
+
const removeOnly = useCallback((canon) => {
|
| 56 |
+
setSelected(prev => {
|
| 57 |
+
if (!prev.includes(canon)) return prev;
|
| 58 |
+
window.forgeSound && window.forgeSound.thud();
|
| 59 |
+
return prev.filter(x => x !== canon);
|
| 60 |
+
});
|
| 61 |
+
}, []);
|
| 62 |
+
|
| 63 |
+
// ── Hover handlers ──────────────────────────────────────────────────────
|
| 64 |
+
const onHoverNode = useCallback((node, ev) => {
|
| 65 |
+
if (!node) { setHover(null); return; }
|
| 66 |
+
const r = resolved[node.canonical] || { status: "available", reasons: [] };
|
| 67 |
+
setHover({
|
| 68 |
+
node,
|
| 69 |
+
status: selected.includes(node.canonical) ? "selected" : r.status,
|
| 70 |
+
reasons: r.reasons || [],
|
| 71 |
+
pos: { x: ev?.clientX ?? 100, y: ev?.clientY ?? 100 },
|
| 72 |
+
});
|
| 73 |
+
}, [resolved, selected]);
|
| 74 |
+
|
| 75 |
+
const onHoverEdge = useCallback((edge, ev) => {
|
| 76 |
+
if (!edge) { setHover(null); return; }
|
| 77 |
+
setHover({ edge, pos: { x: ev?.clientX ?? 100, y: ev?.clientY ?? 100 } });
|
| 78 |
+
}, []);
|
| 79 |
+
|
| 80 |
+
// Track cursor for tooltip following
|
| 81 |
+
useEffect(() => {
|
| 82 |
+
const move = (ev) => {
|
| 83 |
+
setHover(h => h ? { ...h, pos: { x: ev.clientX, y: ev.clientY } } : h);
|
| 84 |
+
};
|
| 85 |
+
window.addEventListener("mousemove", move);
|
| 86 |
+
return () => window.removeEventListener("mousemove", move);
|
| 87 |
+
}, []);
|
| 88 |
+
|
| 89 |
+
// ── Demo path: scripted Muon → QLoRA → bnb_4bit → DLRS ───────────────────
|
| 90 |
+
const demoTimer = useRef(null);
|
| 91 |
+
useEffect(() => {
|
| 92 |
+
if (!t.demoMode) {
|
| 93 |
+
if (demoTimer.current) { clearTimeout(demoTimer.current); demoTimer.current = null; }
|
| 94 |
+
return;
|
| 95 |
+
}
|
| 96 |
+
const steps = [
|
| 97 |
+
{ at: 800, action: () => setSelected(["muon"]) },
|
| 98 |
+
{ at: 3200, action: () => setSelected(["muon", "qlora"]) },
|
| 99 |
+
{ at: 5800, action: () => setSelected(["muon", "qlora", "bnb_4bit"]) },
|
| 100 |
+
{ at: 8400, action: () => setSelected(["muon", "qlora", "bnb_4bit", "lora", "llama3", "dlrs"]) },
|
| 101 |
+
{ at: 14000, action: () => setSelected([]) },
|
| 102 |
+
];
|
| 103 |
+
setSelected([]);
|
| 104 |
+
let cancelled = false;
|
| 105 |
+
let i = 0;
|
| 106 |
+
const tick = () => {
|
| 107 |
+
if (cancelled || i >= steps.length) return;
|
| 108 |
+
steps[i].action();
|
| 109 |
+
i++;
|
| 110 |
+
if (i < steps.length) {
|
| 111 |
+
demoTimer.current = setTimeout(tick, steps[i].at - (steps[i-1]?.at || 0));
|
| 112 |
+
} else {
|
| 113 |
+
// loop
|
| 114 |
+
demoTimer.current = setTimeout(() => { i = 0; tick(); }, 2000);
|
| 115 |
+
}
|
| 116 |
+
};
|
| 117 |
+
demoTimer.current = setTimeout(tick, steps[0].at);
|
| 118 |
+
return () => { cancelled = true; if (demoTimer.current) clearTimeout(demoTimer.current); };
|
| 119 |
+
}, [t.demoMode]);
|
| 120 |
+
|
| 121 |
+
// Sync sound mute state with the tweak
|
| 122 |
+
useEffect(() => {
|
| 123 |
+
if (window.forgeSound) window.forgeSound.setMuted(!t.sound);
|
| 124 |
+
}, [t.sound]);
|
| 125 |
+
|
| 126 |
+
// Apply presets from Tweaks
|
| 127 |
+
useEffect(() => {
|
| 128 |
+
if (t.preset === "blank") return;
|
| 129 |
+
const presets = {
|
| 130 |
+
"qlora-llama": ["qlora", "lora", "bnb_4bit", "llama3", "dlrs"],
|
| 131 |
+
"muon-collide": ["muon", "adamw_8bit"],
|
| 132 |
+
"rick-tier1": ["per_layer_lr_rotation", "adamw_8bit"],
|
| 133 |
+
"vllm-ckpt": ["grad_ckpt", "vllm", "qlora"],
|
| 134 |
+
"chaos": ["chaos_inject", "jacobian_reg"],
|
| 135 |
+
};
|
| 136 |
+
if (presets[t.preset]) setSelected(presets[t.preset]);
|
| 137 |
+
// Reset the tweak so user can re-trigger
|
| 138 |
+
setTimeout(() => setTweak("preset", "blank"), 100);
|
| 139 |
+
}, [t.preset]);
|
| 140 |
+
|
| 141 |
+
return (
|
| 142 |
+
<div className="app">
|
| 143 |
+
{t.showSparks && <Sparks />}
|
| 144 |
+
|
| 145 |
+
<Topbar />
|
| 146 |
+
|
| 147 |
+
<div className="main">
|
| 148 |
+
<window.Inventory
|
| 149 |
+
nodes={nodes}
|
| 150 |
+
resolved={resolved}
|
| 151 |
+
selected={selected}
|
| 152 |
+
onToggleSelect={toggleSelect}
|
| 153 |
+
onHoverNode={onHoverNode}
|
| 154 |
+
/>
|
| 155 |
+
|
| 156 |
+
<window.ForgeGraph
|
| 157 |
+
nodes={nodes}
|
| 158 |
+
edges={edges}
|
| 159 |
+
resolved={resolved}
|
| 160 |
+
selected={selected}
|
| 161 |
+
onToggleSelect={toggleSelect}
|
| 162 |
+
onHoverNode={onHoverNode}
|
| 163 |
+
onHoverEdge={onHoverEdge}
|
| 164 |
+
/>
|
| 165 |
+
|
| 166 |
+
<window.RecipeCard
|
| 167 |
+
selected={selected}
|
| 168 |
+
nodes={nodes}
|
| 169 |
+
recipe={recipe}
|
| 170 |
+
onRemove={removeOnly}
|
| 171 |
+
onSelect={addOnly}
|
| 172 |
+
onHoverNode={onHoverNode}
|
| 173 |
+
/>
|
| 174 |
+
</div>
|
| 175 |
+
|
| 176 |
+
{t.showTicker && <window.DiscoveriesTicker />}
|
| 177 |
+
|
| 178 |
+
<window.EvidenceTip payload={hover} />
|
| 179 |
+
|
| 180 |
+
<window.TweaksPanel title="Tweaks">
|
| 181 |
+
<window.TweakSection label="Demo">
|
| 182 |
+
<window.TweakToggle label="Auto-demo path" value={t.demoMode}
|
| 183 |
+
onChange={v => setTweak("demoMode", v)} />
|
| 184 |
+
<window.TweakSelect
|
| 185 |
+
label="Preset build"
|
| 186 |
+
value={t.preset}
|
| 187 |
+
options={[
|
| 188 |
+
{ value: "blank", label: "— pick —" },
|
| 189 |
+
{ value: "qlora-llama", label: "QLoRA · Llama-3 · DLRS" },
|
| 190 |
+
{ value: "muon-collide", label: "Muon × AdamW 8-bit (breaks)" },
|
| 191 |
+
{ value: "rick-tier1", label: "Per-Layer LR × AdamW 8-bit (T1)" },
|
| 192 |
+
{ value: "vllm-ckpt", label: "vLLM × Grad Ckpt (breaks)" },
|
| 193 |
+
{ value: "chaos", label: "Chaos × Jacobian (breaks)" },
|
| 194 |
+
]}
|
| 195 |
+
onChange={v => setTweak("preset", v)}
|
| 196 |
+
/>
|
| 197 |
+
</window.TweakSection>
|
| 198 |
+
<window.TweakSection label="Atmosphere">
|
| 199 |
+
<window.TweakToggle label="Show ticker" value={t.showTicker} onChange={v => setTweak("showTicker", v)} />
|
| 200 |
+
<window.TweakToggle label="Background sparks" value={t.showSparks} onChange={v => setTweak("showSparks", v)} />
|
| 201 |
+
<window.TweakToggle label="Forge sounds" value={t.sound} onChange={v => setTweak("sound", v)} />
|
| 202 |
+
</window.TweakSection>
|
| 203 |
+
</window.TweaksPanel>
|
| 204 |
+
</div>
|
| 205 |
+
);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function Topbar() {
|
| 209 |
+
return (
|
| 210 |
+
<div className="topbar">
|
| 211 |
+
<div className="brand">
|
| 212 |
+
<div className="mark">
|
| 213 |
+
{/* Anvil + sparks emblem */}
|
| 214 |
+
<svg width="26" height="26" viewBox="0 0 32 32" fill="none">
|
| 215 |
+
<defs>
|
| 216 |
+
<linearGradient id="anvil-grad" x1="0" y1="0" x2="0" y2="1">
|
| 217 |
+
<stop offset="0" stopColor="#c4934e"/>
|
| 218 |
+
<stop offset="1" stopColor="#5a3818"/>
|
| 219 |
+
</linearGradient>
|
| 220 |
+
</defs>
|
| 221 |
+
{/* anvil silhouette */}
|
| 222 |
+
<path d="M5 14 h17 l3 -3 h4 v4 h-3 l-2 2 h-3 v4 h2 v3 h-13 v-3 h2 v-4 h-7 z"
|
| 223 |
+
fill="url(#anvil-grad)" stroke="#2a1a08" strokeWidth="0.7" strokeLinejoin="round"/>
|
| 224 |
+
{/* base */}
|
| 225 |
+
<rect x="10" y="25" width="13" height="2" fill="#2a1a08"/>
|
| 226 |
+
{/* hot dot on anvil face */}
|
| 227 |
+
<circle cx="17" cy="15" r="1.3" fill="#ffba5e" opacity="0.95">
|
| 228 |
+
<animate attributeName="opacity" values="0.6;1;0.6" dur="2.2s" repeatCount="indefinite"/>
|
| 229 |
+
</circle>
|
| 230 |
+
{/* sparks */}
|
| 231 |
+
<circle cx="9" cy="8" r="0.9" fill="#ffba5e"/>
|
| 232 |
+
<circle cx="13" cy="5" r="0.6" fill="#ff8a3d"/>
|
| 233 |
+
<circle cx="20" cy="6" r="0.7" fill="#ffba5e"/>
|
| 234 |
+
</svg>
|
| 235 |
+
</div>
|
| 236 |
+
<div>
|
| 237 |
+
<div className="name">FORGE</div>
|
| 238 |
+
<div className="tag">a compatibility graph for ML training</div>
|
| 239 |
+
</div>
|
| 240 |
+
</div>
|
| 241 |
+
|
| 242 |
+
<div className="legend">
|
| 243 |
+
<span className="swatch"><span className="sw t1" /> T1 · Legendary</span>
|
| 244 |
+
<span className="swatch"><span className="sw t2" /> T2 · Rare</span>
|
| 245 |
+
<span className="swatch"><span className="sw t3" /> T3 · Common</span>
|
| 246 |
+
</div>
|
| 247 |
+
|
| 248 |
+
<div className="crumbs">
|
| 249 |
+
<span className="api-pill"><span className="dot" /> api · online</span>
|
| 250 |
+
<span style={{ color: "var(--bone-4)" }}>
|
| 251 |
+
{window.FORGE_NODES.length} nodes · {window.FORGE_EDGES.length} edges
|
| 252 |
+
</span>
|
| 253 |
+
<span style={{ color: "var(--bone-4)" }}>hf.co/spaces/forge</span>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Sparks background — random spark spans positioned via JS
|
| 260 |
+
function Sparks() {
|
| 261 |
+
const sparks = useMemo(() => {
|
| 262 |
+
return Array.from({ length: 18 }, () => ({
|
| 263 |
+
left: Math.random() * 100,
|
| 264 |
+
delay: Math.random() * 16,
|
| 265 |
+
dur: 14 + Math.random() * 18,
|
| 266 |
+
size: 0.6 + Math.random() * 1.6,
|
| 267 |
+
}));
|
| 268 |
+
}, []);
|
| 269 |
+
return (
|
| 270 |
+
<div className="sparks">
|
| 271 |
+
{sparks.map((s, i) => (
|
| 272 |
+
<span key={i} style={{
|
| 273 |
+
left: s.left + "%",
|
| 274 |
+
animationDuration: s.dur + "s",
|
| 275 |
+
animationDelay: -s.delay + "s",
|
| 276 |
+
transform: `scale(${s.size})`,
|
| 277 |
+
}} />
|
| 278 |
+
))}
|
| 279 |
+
</div>
|
| 280 |
+
);
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
// briefly flash a node red on a blocked-click attempt
|
| 284 |
+
function flashBlocked(canon) {
|
| 285 |
+
const svg = document.querySelector(`g.node[data-canon="${cssEscape(canon)}"]`);
|
| 286 |
+
if (!svg) return;
|
| 287 |
+
svg.animate(
|
| 288 |
+
[
|
| 289 |
+
{ transform: svg.getAttribute("transform") + " translateX(0)" },
|
| 290 |
+
{ transform: svg.getAttribute("transform") + " translateX(-3px)" },
|
| 291 |
+
{ transform: svg.getAttribute("transform") + " translateX(3px)" },
|
| 292 |
+
{ transform: svg.getAttribute("transform") + " translateX(-2px)" },
|
| 293 |
+
{ transform: svg.getAttribute("transform") + " translateX(0)" },
|
| 294 |
+
],
|
| 295 |
+
{ duration: 220, easing: "ease-out" }
|
| 296 |
+
);
|
| 297 |
+
}
|
| 298 |
+
function cssEscape(s) {
|
| 299 |
+
if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
|
| 300 |
+
return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
// Fetch live data (or fall back to bundled mock) before first render.
|
| 304 |
+
(window.forgeBootstrap ? window.forgeBootstrap() : Promise.resolve())
|
| 305 |
+
.catch(() => {})
|
| 306 |
+
.finally(() => {
|
| 307 |
+
if (window.FORGE_LIVE) console.info("[forge] live API graph loaded");
|
| 308 |
+
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
| 309 |
+
});
|
frontend/data.js
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Forge — mock graph + resolve engine
|
| 2 |
+
// Matches the FastAPI shapes in the handoff doc, so swap to fetch() later.
|
| 3 |
+
|
| 4 |
+
window.FORGE_NODES = [
|
| 5 |
+
// optimizers
|
| 6 |
+
{ canonical: "adamw", name: "AdamW", type: "optimizer", description: "Workhorse decoupled-weight-decay optimizer. Safe default.", tier: 1 },
|
| 7 |
+
{ canonical: "adamw_8bit", name: "AdamW 8-bit", type: "optimizer", description: "bitsandbytes 8-bit AdamW. Saves VRAM. Default param groups can fight per-layer LR tricks.", tier: 1 },
|
| 8 |
+
{ canonical: "paged_adamw", name: "Paged AdamW", type: "optimizer", description: "CPU-paged optimizer states. For when you really can't fit.", tier: 2 },
|
| 9 |
+
{ canonical: "lion", name: "Lion", type: "optimizer", description: "Sign-momentum optimizer. Lower memory than AdamW; needs lower LR.", tier: 2 },
|
| 10 |
+
{ canonical: "muon", name: "Muon", type: "optimizer", description: "Newton–Schulz orthogonalized momentum. Faster convergence on hidden weights.", tier: 2 },
|
| 11 |
+
{ canonical: "sophia", name: "Sophia-G", type: "optimizer", description: "Hessian-informed second-order. Promising for LLMs.", tier: 3 },
|
| 12 |
+
{ canonical: "adafactor", name: "Adafactor", type: "optimizer", description: "Memory-light. Tricky LR schedule.", tier: 2 },
|
| 13 |
+
|
| 14 |
+
// schedulers
|
| 15 |
+
{ canonical: "cosine", name: "Cosine", type: "scheduler", description: "Cosine decay with warmup. Boring; works.", tier: 1 },
|
| 16 |
+
{ canonical: "onecycle", name: "OneCycle", type: "scheduler", description: "Aggressive warm-then-anneal. Faster but can overshoot on long runs.", tier: 2 },
|
| 17 |
+
{ canonical: "dlrs", name: "DLRS", type: "scheduler", description: "Rick's dynamic LR scheduler. #1 on val_loss in the bench.", tier: 1 },
|
| 18 |
+
{ canonical: "linear", name: "Linear", type: "scheduler", description: "Linear warmup → linear decay.", tier: 1 },
|
| 19 |
+
{ canonical: "wsd", name: "WSD", type: "scheduler", description: "Warmup–Stable–Decay. Continual-pretrain friendly.", tier: 2 },
|
| 20 |
+
{ canonical: "constant", name: "Constant", type: "scheduler", description: "Flat. Combine with manual restarts.", tier: 1 },
|
| 21 |
+
|
| 22 |
+
// techniques
|
| 23 |
+
{ canonical: "qlora", name: "QLoRA", type: "technique", description: "4-bit base + LoRA adapters. Lets a 70B fit on one card.", tier: 1 },
|
| 24 |
+
{ canonical: "lora", name: "LoRA", type: "technique", description: "Low-rank adapters. Cheap, composable, the default PEFT.", tier: 1 },
|
| 25 |
+
{ canonical: "fft", name: "Full Fine-Tune", type: "technique", description: "Update every parameter. Hungry. Mutually exclusive with adapter methods.", tier: 1 },
|
| 26 |
+
{ canonical: "grad_ckpt", name: "Grad Checkpoint", type: "technique", description: "Trade FLOPs for VRAM. Must be off for vLLM gen during training.", tier: 1 },
|
| 27 |
+
{ canonical: "per_layer_lr_rotation", name: "Per-Layer LR Rotation", type: "technique", description: "Rick's trick: rotate LR across layer bands per step. Needs custom param groups.", tier: 1 },
|
| 28 |
+
{ canonical: "chaos_inject", name: "Chaos Injectors", type: "technique", description: "Activation perturbation at hidden layers. NaN-prone without staged melt-in.", tier: 1 },
|
| 29 |
+
{ canonical: "jacobian_reg", name: "Jacobian Reg", type: "technique", description: "Smoothness penalty via Jacobian. Forward pass corrupts the injector cache.", tier: 1 },
|
| 30 |
+
{ canonical: "fsdp", name: "FSDP", type: "technique", description: "Fully-Sharded Data Parallel. Sharding for big models.", tier: 1 },
|
| 31 |
+
{ canonical: "ddp", name: "DDP", type: "technique", description: "Vanilla data-parallel. Cheap when the model fits.", tier: 1 },
|
| 32 |
+
{ canonical: "deepspeed_z3", name: "DeepSpeed ZeRO-3", type: "technique", description: "ZeRO stage 3 partitioning. Battle-tested.", tier: 1 },
|
| 33 |
+
{ canonical: "unsloth", name: "Unsloth", type: "technique", description: "Fused kernels for LoRA/QLoRA. PyTorch-only; tight coupling to bnb.", tier: 2 },
|
| 34 |
+
|
| 35 |
+
// quantization
|
| 36 |
+
{ canonical: "bnb_4bit", name: "bnb 4-bit", type: "quantization", description: "bitsandbytes NF4. The QLoRA base.", tier: 1 },
|
| 37 |
+
{ canonical: "bnb_8bit", name: "bnb 8-bit", type: "quantization", description: "LLM.int8(). Inference-leaning; training works.", tier: 1 },
|
| 38 |
+
{ canonical: "gptq", name: "GPTQ", type: "quantization", description: "Post-training quant. Inference-only for our purposes.", tier: 2 },
|
| 39 |
+
{ canonical: "awq", name: "AWQ", type: "quantization", description: "Activation-aware weight quant. Inference-time.", tier: 2 },
|
| 40 |
+
|
| 41 |
+
// architectures
|
| 42 |
+
{ canonical: "llama3", name: "Llama-3", type: "architecture", description: "Llama-3 8B / 70B family.", tier: 1 },
|
| 43 |
+
{ canonical: "mistral", name: "Mistral", type: "architecture", description: "Mistral / Mixtral.", tier: 1 },
|
| 44 |
+
{ canonical: "qwen2", name: "Qwen-2.5", type: "architecture", description: "Strong open multilingual base.", tier: 1 },
|
| 45 |
+
{ canonical: "distilbert", name: "DistilBERT", type: "architecture", description: "The bench model. SST-2 sandbox.", tier: 1 },
|
| 46 |
+
|
| 47 |
+
// inference
|
| 48 |
+
{ canonical: "vllm", name: "vLLM", type: "inference", description: "PagedAttention server. Needs grad-ckpt off during in-train generation.", tier: 1 },
|
| 49 |
+
{ canonical: "sglang", name: "SGLang", type: "inference", description: "Structured-gen server.", tier: 2 },
|
| 50 |
+
];
|
| 51 |
+
|
| 52 |
+
// edges. relations: REQUIRES, CONDITIONAL, COMPATIBLE, DEGRADES, BREAKS
|
| 53 |
+
// Direction: from → to. For REQUIRES: "from needs to to work".
|
| 54 |
+
// For BREAKS: symmetric in display but stored once.
|
| 55 |
+
window.FORGE_EDGES = [
|
| 56 |
+
// --- T1 verified seed (from the backend doc) -----------------------------
|
| 57 |
+
{ from: "per_layer_lr_rotation", to: "adamw_8bit", relation: "BREAKS", tier: 1, confidence: 1.0,
|
| 58 |
+
fix: "Pass custom optimizer_grouped_parameters — adamw_8bit's default decay/no-decay split overrides your per-layer LR bands.",
|
| 59 |
+
evidence: [{ url: "mempalace://chaos-injection-trainer-notes", quote: "adamw_8bit produces decay/no-decay param groups by default; per-layer LR rotation silently no-ops unless you pass optimizer_grouped_parameters yourself.", source_type: "practitioner_run", tier: 1 }] },
|
| 60 |
+
|
| 61 |
+
{ from: "jacobian_reg", to: "chaos_inject", relation: "BREAKS", tier: 1, confidence: 1.0,
|
| 62 |
+
fix: "Jacobian-reg's extra forward pass corrupts the injector activation cache → ortho_loss is poisoned. Disable one.",
|
| 63 |
+
evidence: [{ url: "mempalace://chaos-injection-trainer-notes", quote: "the jacobian reg forward overwrites the activation cache that chaos_inject samples from; ortho_loss explodes.", source_type: "practitioner_run", tier: 1 }] },
|
| 64 |
+
|
| 65 |
+
{ from: "chaos_inject", to: "staged_meltin", relation: "REQUIRES", tier: 1, confidence: 1.0,
|
| 66 |
+
fix: "Stage injectors in over ~100 steps; NaN at layer ~6 if injected from step 0.",
|
| 67 |
+
evidence: [{ url: "mempalace://chaos-injection-trainer-notes", quote: "NaN at injector layer ~6 if run from step 0; staged melt-in over 100 steps fixed it.", source_type: "practitioner_run", tier: 1 }] },
|
| 68 |
+
|
| 69 |
+
{ from: "grad_ckpt", to: "vllm", relation: "BREAKS", tier: 1, confidence: 1.0,
|
| 70 |
+
fix: "Disable gradient_checkpointing for the in-train vLLM gen pass. TRL-documented.",
|
| 71 |
+
evidence: [{ url: "https://huggingface.co/docs/trl", quote: "gradient_checkpointing must be disabled when generating with vLLM during training.", source_type: "official_docs", tier: 1 }] },
|
| 72 |
+
|
| 73 |
+
// --- core technique mutex / requires -------------------------------------
|
| 74 |
+
{ from: "qlora", to: "fft", relation: "BREAKS", tier: 1, confidence: 1.0,
|
| 75 |
+
fix: "QLoRA freezes the base model; Full Fine-Tune updates it. Pick one.",
|
| 76 |
+
evidence: [{ url: "https://arxiv.org/abs/2305.14314", quote: "QLoRA backprops gradients through a frozen 4-bit quantized model into low-rank adapters.", source_type: "paper", tier: 1 }] },
|
| 77 |
+
|
| 78 |
+
{ from: "qlora", to: "bnb_4bit", relation: "REQUIRES", tier: 1, confidence: 1.0,
|
| 79 |
+
fix: "QLoRA is defined as 4-bit NF4 base + LoRA adapters — load the base in bnb 4-bit.",
|
| 80 |
+
evidence: [{ url: "https://huggingface.co/docs/peft", quote: "QLoRA fine-tunes a 4-bit quantized base model loaded via bitsandbytes NF4.", source_type: "official_docs", tier: 1 }] },
|
| 81 |
+
|
| 82 |
+
{ from: "qlora", to: "lora", relation: "REQUIRES", tier: 1, confidence: 1.0,
|
| 83 |
+
fix: "QLoRA = 4-bit base + LoRA adapters. The adapter rank is your hyperparameter.",
|
| 84 |
+
evidence: [{ url: "https://arxiv.org/abs/2305.14314", quote: "QLoRA augments the frozen quantized model with Low-Rank Adapters.", source_type: "paper", tier: 1 }] },
|
| 85 |
+
|
| 86 |
+
{ from: "lora", to: "fft", relation: "BREAKS", tier: 2, confidence: 0.85,
|
| 87 |
+
fix: "Adapter-method and full fine-tune are mutually exclusive within one run.",
|
| 88 |
+
evidence: [{ url: "https://huggingface.co/docs/peft", quote: "PEFT methods freeze the base; choose either full fine-tuning or a PEFT method per run.", source_type: "official_docs", tier: 2 }] },
|
| 89 |
+
|
| 90 |
+
// --- optimizer ↔ quantization --------------------------------------------
|
| 91 |
+
{ from: "muon", to: "adamw_8bit", relation: "BREAKS", tier: 2, confidence: 0.8,
|
| 92 |
+
fix: "Muon owns the optimizer step for hidden weights; 8-bit AdamW state is incompatible with the Newton–Schulz update.",
|
| 93 |
+
evidence: [{ url: "https://kellerjordan.github.io/posts/muon/", quote: "Muon replaces the optimizer update for 2D weights; use AdamW for the rest, not its 8-bit variant.", source_type: "blog", tier: 2 }] },
|
| 94 |
+
|
| 95 |
+
{ from: "muon", to: "adamw", relation: "REQUIRES", tier: 2, confidence: 0.9,
|
| 96 |
+
fix: "Muon only updates 2D hidden weights — embeddings + biases still need AdamW.",
|
| 97 |
+
evidence: [{ url: "https://kellerjordan.github.io/posts/muon/", quote: "non-hidden parameters (embeddings, scalars) are handled by a standard AdamW.", source_type: "blog", tier: 2 }] },
|
| 98 |
+
|
| 99 |
+
{ from: "lion", to: "bnb_8bit", relation: "DEGRADES", tier: 3, confidence: 0.5,
|
| 100 |
+
fix: "Lion sign-update interacts poorly with 8-bit state quant — drop to 16-bit moments.",
|
| 101 |
+
evidence: [{ url: "https://github.com/bitsandbytes-foundation/bitsandbytes/issues", quote: "single user report: Lion+8-bit moments diverged at step 4k on a 7B base.", source_type: "issue", tier: 3 }] },
|
| 102 |
+
|
| 103 |
+
// --- unsloth / quant family ----------------------------------------------
|
| 104 |
+
{ from: "unsloth", to: "bnb_4bit", relation: "REQUIRES", tier: 1, confidence: 1.0,
|
| 105 |
+
fix: "Unsloth's fused kernels assume a bnb 4-bit base.",
|
| 106 |
+
evidence: [{ url: "https://github.com/unslothai/unsloth", quote: "Unsloth supports 4-bit quantized models via bitsandbytes for QLoRA fine-tuning.", source_type: "official_docs", tier: 1 }] },
|
| 107 |
+
|
| 108 |
+
{ from: "unsloth", to: "lora", relation: "REQUIRES", tier: 1, confidence: 1.0,
|
| 109 |
+
fix: "Unsloth's fast path is the LoRA / QLoRA path.",
|
| 110 |
+
evidence: [{ url: "https://github.com/unslothai/unsloth", quote: "Unsloth accelerates LoRA and QLoRA fine-tuning with custom Triton kernels.", source_type: "official_docs", tier: 1 }] },
|
| 111 |
+
|
| 112 |
+
{ from: "unsloth", to: "fsdp", relation: "BREAKS", tier: 2, confidence: 0.8,
|
| 113 |
+
fix: "Unsloth's custom kernels don't compose with FSDP sharding hooks today.",
|
| 114 |
+
evidence: [{ url: "https://github.com/unslothai/unsloth/issues", quote: "FSDP is not currently supported alongside Unsloth's fused kernels.", source_type: "issue", tier: 2 }] },
|
| 115 |
+
|
| 116 |
+
// --- inference quants (category errors) ----------------------------------
|
| 117 |
+
{ from: "awq", to: "fft", relation: "BREAKS", tier: 2, confidence: 0.85,
|
| 118 |
+
fix: "AWQ is an inference-time weight quant. You can't fine-tune through it.",
|
| 119 |
+
evidence: [{ url: "https://github.com/casper-hansen/AutoAWQ", quote: "AWQ is intended for post-training quantization for inference.", source_type: "official_docs", tier: 2 }] },
|
| 120 |
+
|
| 121 |
+
{ from: "gptq", to: "fft", relation: "BREAKS", tier: 2, confidence: 0.85,
|
| 122 |
+
fix: "GPTQ is post-training quant — frozen base only.",
|
| 123 |
+
evidence: [{ url: "https://arxiv.org/abs/2210.17323", quote: "GPTQ is a one-shot post-training quantization method.", source_type: "paper", tier: 2 }] },
|
| 124 |
+
|
| 125 |
+
// --- distributed -----------------------------------------------------------
|
| 126 |
+
{ from: "fsdp", to: "bnb_4bit", relation: "CONDITIONAL", tier: 2, confidence: 0.8, conditions: { plugin: "bnb-fsdp" },
|
| 127 |
+
fix: "Works only with the bnb-FSDP plugin; vanilla FSDP shards over uninitialized 4-bit weights.",
|
| 128 |
+
evidence: [{ url: "https://huggingface.co/docs/accelerate", quote: "FSDP + bitsandbytes 4-bit requires the bnb-fsdp wrap policy.", source_type: "official_docs", tier: 2 }] },
|
| 129 |
+
|
| 130 |
+
{ from: "deepspeed_z3", to: "bnb_8bit", relation: "DEGRADES", tier: 3, confidence: 0.5,
|
| 131 |
+
fix: "Reports of slowdown / hangs on multi-node Z3 + 8-bit. Use bf16 weights, 8-bit optimizer states only.",
|
| 132 |
+
evidence: [{ url: "https://github.com/microsoft/DeepSpeed/issues", quote: "Z3 + 8-bit weights hang on the param-gather step in some configs.", source_type: "issue", tier: 3 }] },
|
| 133 |
+
|
| 134 |
+
// --- scheduler benchmark relations ---------------------------------------
|
| 135 |
+
{ from: "dlrs", to: "distilbert", relation: "COMPATIBLE", tier: 1, confidence: 1.0,
|
| 136 |
+
fix: "DLRS #1 on the SST-2 bench: val_loss 0.2653, val_acc 0.890, steps_to_target 266.7 (n=3 seeds).",
|
| 137 |
+
evidence: [{ url: "https://huggingface.co/spaces/juiceb0xc0de/lr-scheduler-benchmark", quote: "DLRS leads on val_loss across 3 seeds on distilbert/sst2.", source_type: "benchmark", tier: 1 }] },
|
| 138 |
+
|
| 139 |
+
{ from: "onecycle", to: "distilbert", relation: "DEGRADES", tier: 1, confidence: 1.0,
|
| 140 |
+
fix: "OneCycle underperforms on the SST-2 bench: val_loss 0.4284 vs cohort cutoff 0.4022.",
|
| 141 |
+
evidence: [{ url: "https://huggingface.co/spaces/juiceb0xc0de/lr-scheduler-benchmark", quote: "OneCycle val_loss 0.4284 is above the cohort cutoff 0.4022.", source_type: "benchmark", tier: 1 }] },
|
| 142 |
+
|
| 143 |
+
// --- compatible/positive edges (just so the graph isn't all conflict) ----
|
| 144 |
+
{ from: "lora", to: "bnb_8bit", relation: "COMPATIBLE", tier: 2, confidence: 0.8, fix: "", evidence: [] },
|
| 145 |
+
{ from: "lora", to: "bnb_4bit", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 146 |
+
{ from: "grad_ckpt", to: "fsdp", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 147 |
+
{ from: "grad_ckpt", to: "qlora",relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 148 |
+
{ from: "cosine", to: "adamw", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 149 |
+
{ from: "dlrs", to: "adamw", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 150 |
+
{ from: "fsdp", to: "llama3", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 151 |
+
{ from: "qlora", to: "llama3", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 152 |
+
{ from: "qlora", to: "mistral", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 153 |
+
{ from: "qlora", to: "qwen2", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 154 |
+
{ from: "lora", to: "distilbert", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 155 |
+
{ from: "vllm", to: "llama3", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 156 |
+
{ from: "vllm", to: "mistral", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 157 |
+
{ from: "deepspeed_z3", to: "fft", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 158 |
+
{ from: "fsdp", to: "fft", relation: "COMPATIBLE", tier: 1, confidence: 1.0, fix: "", evidence: [] },
|
| 159 |
+
{ from: "muon", to: "llama3", relation: "COMPATIBLE", tier: 2, confidence: 0.8, fix: "", evidence: [] },
|
| 160 |
+
];
|
| 161 |
+
|
| 162 |
+
// add "staged melt-in" as a hidden technique reachable as a fix-target
|
| 163 |
+
window.FORGE_NODES.push({
|
| 164 |
+
canonical: "staged_meltin", name: "Staged Melt-In", type: "technique",
|
| 165 |
+
description: "Linear ramp-in of chaos injectors over N steps. Prevents NaN at layer ~6.",
|
| 166 |
+
tier: 1,
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
// ============================================================================
|
| 170 |
+
// RESOLVE ENGINE
|
| 171 |
+
// ============================================================================
|
| 172 |
+
// For each non-selected node N, look at edges between N and any selected S:
|
| 173 |
+
// BREAKS → blocked (red)
|
| 174 |
+
// REQUIRES(S → N) → S needs N; mark N "needed by S" (amber/conditional)
|
| 175 |
+
// REQUIRES(N → S) → N needs S, S is selected → that requirement is satisfied (ignore)
|
| 176 |
+
// CONDITIONAL → conditional (amber) with conditions
|
| 177 |
+
// DEGRADES → available + warning
|
| 178 |
+
// COMPATIBLE → available
|
| 179 |
+
// Plus: if N has any REQUIRES(N → X) where X is *not* selected and X is also not
|
| 180 |
+
// universally optional, N is conditional ("needs X").
|
| 181 |
+
|
| 182 |
+
window.forgeResolve = function (selectedCanons) {
|
| 183 |
+
const sel = new Set(selectedCanons);
|
| 184 |
+
const out = {};
|
| 185 |
+
const nodeByCanon = Object.fromEntries(window.FORGE_NODES.map(n => [n.canonical, n]));
|
| 186 |
+
const incoming = {};
|
| 187 |
+
const outgoing = {};
|
| 188 |
+
for (const e of window.FORGE_EDGES) {
|
| 189 |
+
(outgoing[e.from] = outgoing[e.from] || []).push(e);
|
| 190 |
+
(incoming[e.to] = incoming[e.to] || []).push(e);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
for (const n of window.FORGE_NODES) {
|
| 194 |
+
if (sel.has(n.canonical)) {
|
| 195 |
+
out[n.canonical] = { status: "selected", reasons: [], warnings: [] };
|
| 196 |
+
continue;
|
| 197 |
+
}
|
| 198 |
+
const reasons = [];
|
| 199 |
+
const warnings = [];
|
| 200 |
+
|
| 201 |
+
// edges adjacent to n
|
| 202 |
+
const adj = [...(outgoing[n.canonical] || []), ...(incoming[n.canonical] || [])];
|
| 203 |
+
|
| 204 |
+
for (const e of adj) {
|
| 205 |
+
const otherCanon = e.from === n.canonical ? e.to : e.from;
|
| 206 |
+
const direction = e.from === n.canonical ? "out" : "in"; // n -> other or other -> n
|
| 207 |
+
const other = nodeByCanon[otherCanon];
|
| 208 |
+
if (!other) continue;
|
| 209 |
+
|
| 210 |
+
if (sel.has(otherCanon)) {
|
| 211 |
+
if (e.relation === "BREAKS") {
|
| 212 |
+
reasons.push({
|
| 213 |
+
kind: "blocked",
|
| 214 |
+
label: `breaks with ${other.name}`,
|
| 215 |
+
fix: e.fix,
|
| 216 |
+
evidence: e.evidence,
|
| 217 |
+
tier: e.tier,
|
| 218 |
+
confidence: e.confidence,
|
| 219 |
+
otherCanon,
|
| 220 |
+
});
|
| 221 |
+
} else if (e.relation === "CONDITIONAL") {
|
| 222 |
+
reasons.push({
|
| 223 |
+
kind: "conditional",
|
| 224 |
+
label: `conditional on ${other.name}`,
|
| 225 |
+
fix: e.fix,
|
| 226 |
+
conditions: e.conditions,
|
| 227 |
+
evidence: e.evidence,
|
| 228 |
+
tier: e.tier,
|
| 229 |
+
otherCanon,
|
| 230 |
+
});
|
| 231 |
+
} else if (e.relation === "REQUIRES" && direction === "in") {
|
| 232 |
+
// other -> n means "other requires n". other is selected, n is not.
|
| 233 |
+
// n is a missing ingredient.
|
| 234 |
+
reasons.push({
|
| 235 |
+
kind: "needed-by",
|
| 236 |
+
label: `needed by ${other.name}`,
|
| 237 |
+
fix: e.fix,
|
| 238 |
+
evidence: e.evidence,
|
| 239 |
+
tier: e.tier,
|
| 240 |
+
otherCanon,
|
| 241 |
+
});
|
| 242 |
+
} else if (e.relation === "DEGRADES") {
|
| 243 |
+
warnings.push({
|
| 244 |
+
label: `degrades with ${other.name}`,
|
| 245 |
+
fix: e.fix,
|
| 246 |
+
evidence: e.evidence,
|
| 247 |
+
tier: e.tier,
|
| 248 |
+
otherCanon,
|
| 249 |
+
});
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
let status = "available";
|
| 255 |
+
if (reasons.some(r => r.kind === "blocked")) status = "blocked";
|
| 256 |
+
else if (reasons.some(r => r.kind === "conditional" || r.kind === "needed-by")) status = "conditional";
|
| 257 |
+
|
| 258 |
+
out[n.canonical] = { status, reasons, warnings };
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
return out;
|
| 262 |
+
};
|
| 263 |
+
|
| 264 |
+
// recipe payload
|
| 265 |
+
window.forgeRecipe = function (selectedCanons) {
|
| 266 |
+
const sel = new Set(selectedCanons);
|
| 267 |
+
const nodeByCanon = Object.fromEntries(window.FORGE_NODES.map(n => [n.canonical, n]));
|
| 268 |
+
const conflicts = [];
|
| 269 |
+
const unmet = [];
|
| 270 |
+
const warnings = [];
|
| 271 |
+
|
| 272 |
+
for (const e of window.FORGE_EDGES) {
|
| 273 |
+
const a = sel.has(e.from), b = sel.has(e.to);
|
| 274 |
+
if (a && b) {
|
| 275 |
+
if (e.relation === "BREAKS") {
|
| 276 |
+
conflicts.push({ a: nodeByCanon[e.from].name, b: nodeByCanon[e.to].name, fix: e.fix, tier: e.tier, evidence: e.evidence });
|
| 277 |
+
}
|
| 278 |
+
if (e.relation === "DEGRADES") {
|
| 279 |
+
warnings.push({ a: nodeByCanon[e.from].name, b: nodeByCanon[e.to].name, fix: e.fix, tier: e.tier, evidence: e.evidence });
|
| 280 |
+
}
|
| 281 |
+
} else if (a && !b && e.relation === "REQUIRES") {
|
| 282 |
+
unmet.push({ from: nodeByCanon[e.from].name, missing: nodeByCanon[e.to].name, missingCanon: e.to, fix: e.fix, tier: e.tier, evidence: e.evidence });
|
| 283 |
+
} else if (!a && b && e.relation === "REQUIRES") {
|
| 284 |
+
// selected b but not a; the requires goes from a→b meaning a needs b. a not selected, so not unmet here.
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
// scheduler benchmark cohort (mock real bench data)
|
| 289 |
+
const scheduler_picks = [
|
| 290 |
+
{ component: "DLRS", val_loss: 0.26528, val_acc: 0.89029, steps_to_target: 266.7, n_seeds: 3, source: "https://huggingface.co/spaces/juiceb0xc0de/lr-scheduler-benchmark", canonical: "dlrs" },
|
| 291 |
+
{ component: "Cosine", val_loss: 0.31417, val_acc: 0.87959, steps_to_target: 312.0, n_seeds: 3, source: "https://huggingface.co/spaces/juiceb0xc0de/lr-scheduler-benchmark", canonical: "cosine" },
|
| 292 |
+
{ component: "WSD", val_loss: 0.34081, val_acc: 0.86790, steps_to_target: 348.3, n_seeds: 3, source: "https://huggingface.co/spaces/juiceb0xc0de/lr-scheduler-benchmark", canonical: "wsd" },
|
| 293 |
+
];
|
| 294 |
+
const scheduler_warnings = [
|
| 295 |
+
{ component: "OneCycle", val_loss: 0.42844, cutoff: 0.4022, canonical: "onecycle" },
|
| 296 |
+
];
|
| 297 |
+
|
| 298 |
+
const valid = conflicts.length === 0 && unmet.length === 0;
|
| 299 |
+
|
| 300 |
+
return { selected: selectedCanons, valid, conflicts, unmet, warnings, scheduler_picks, scheduler_warnings };
|
| 301 |
+
};
|
| 302 |
+
|
| 303 |
+
// ============================================================================
|
| 304 |
+
// LIVE DISCOVERIES FEED (mock SSE)
|
| 305 |
+
// ============================================================================
|
| 306 |
+
window.FORGE_DISCOVERIES = [
|
| 307 |
+
{ a: "Gradient Checkpointing", relation: "COMPATIBLE", b: "LoRA", tier: 2, source: "huggingface.co/docs/peft", ts_offset: -8 },
|
| 308 |
+
{ a: "Muon", relation: "REQUIRES", b: "AdamW", tier: 2, source: "kellerjordan.github.io", ts_offset: -22 },
|
| 309 |
+
{ a: "DeepSpeed ZeRO-3", relation: "DEGRADES", b: "bnb 8-bit", tier: 3, source: "github.com/microsoft/DeepSpeed", ts_offset: -41 },
|
| 310 |
+
{ a: "QLoRA", relation: "COMPATIBLE", b: "Qwen-2.5", tier: 2, source: "qwenlm.github.io", ts_offset: -67 },
|
| 311 |
+
{ a: "FSDP", relation: "CONDITIONAL",b: "bnb 4-bit", tier: 2, source: "huggingface.co/docs/accelerate", ts_offset: -94 },
|
| 312 |
+
{ a: "Sophia-G", relation: "DEGRADES", b: "bnb 8-bit", tier: 3, source: "github.com/Liuhong99/Sophia", ts_offset: -128 },
|
| 313 |
+
{ a: "WSD", relation: "COMPATIBLE", b: "Continual PT", tier: 2, source: "arxiv.org/abs/2404.06395", ts_offset: -171 },
|
| 314 |
+
{ a: "Unsloth", relation: "BREAKS", b: "FSDP", tier: 2, source: "github.com/unslothai/unsloth", ts_offset: -210 },
|
| 315 |
+
{ a: "AWQ", relation: "BREAKS", b: "Full Fine-Tune",tier: 2, source: "github.com/casper-hansen/AutoAWQ", ts_offset: -266 },
|
| 316 |
+
{ a: "OneCycle", relation: "DEGRADES", b: "distilbert/sst2", tier: 1, source: "hf.co/spaces/juiceb0xc0de/lr-scheduler-benchmark", ts_offset: -311 },
|
| 317 |
+
];
|
| 318 |
+
|
| 319 |
+
// new live discoveries that stream in over time (offsets in seconds from "now")
|
| 320 |
+
window.FORGE_FUTURE_DISCOVERIES = [
|
| 321 |
+
{ a: "Per-Layer LR Rotation", relation: "BREAKS", b: "AdamW 8-bit", tier: 1, source: "mempalace://rick-notes" },
|
| 322 |
+
{ a: "Jacobian Reg", relation: "BREAKS", b: "Chaos Injectors", tier: 1, source: "mempalace://rick-notes" },
|
| 323 |
+
{ a: "Lion", relation: "DEGRADES", b: "bnb 8-bit", tier: 3, source: "github.com/bitsandbytes-foundation/bitsandbytes" },
|
| 324 |
+
{ a: "Paged AdamW", relation: "COMPATIBLE", b: "QLoRA", tier: 2, source: "huggingface.co/docs/transformers" },
|
| 325 |
+
{ a: "DLRS", relation: "COMPATIBLE", b: "WSD", tier: 2, source: "hf.co/spaces/juiceb0xc0de/lr-scheduler-benchmark" },
|
| 326 |
+
{ a: "Cosine", relation: "COMPATIBLE", b: "AdamW", tier: 1, source: "pytorch.org/docs" },
|
| 327 |
+
{ a: "FlashAttention-3", relation: "COMPATIBLE", b: "Llama-3", tier: 2, source: "github.com/Dao-AILab/flash-attention" },
|
| 328 |
+
{ a: "Liger Kernel", relation: "COMPATIBLE", b: "Unsloth", tier: 3, source: "github.com/linkedin/Liger-Kernel" },
|
| 329 |
+
];
|
| 330 |
+
|
| 331 |
+
// ============================================================================
|
| 332 |
+
// LIVE API BOOTSTRAP — prefer the live Forge API; fall back to the bundled mock.
|
| 333 |
+
// Default base is same-origin (the API serves this frontend in prod).
|
| 334 |
+
// Split-port dev: ?api=http://localhost:8010
|
| 335 |
+
// ============================================================================
|
| 336 |
+
window.FORGE_API = (function () {
|
| 337 |
+
const m = location.search.match(/[?&]api=([^&]+)/);
|
| 338 |
+
return m ? decodeURIComponent(m[1]) : "";
|
| 339 |
+
})();
|
| 340 |
+
window.FORGE_LIVE = false;
|
| 341 |
+
window.forgeBootstrap = async function () {
|
| 342 |
+
const base = window.FORGE_API.replace(/\/$/, "");
|
| 343 |
+
try {
|
| 344 |
+
const g = await (await fetch(base + "/graph", { cache: "no-store" })).json();
|
| 345 |
+
if (g && Array.isArray(g.nodes) && g.nodes.length && Array.isArray(g.edges)) {
|
| 346 |
+
const tierByNode = {};
|
| 347 |
+
for (const e of g.edges) {
|
| 348 |
+
for (const c of [e.from_canon, e.to_canon]) {
|
| 349 |
+
tierByNode[c] = Math.min(tierByNode[c] == null ? 9 : tierByNode[c], e.tier || 2);
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
window.FORGE_NODES = g.nodes.map(n => ({
|
| 353 |
+
canonical: n.canonical, name: n.name, type: n.type,
|
| 354 |
+
description: n.description || "", tier: tierByNode[n.canonical] || 2,
|
| 355 |
+
}));
|
| 356 |
+
window.FORGE_EDGES = g.edges.map(e => ({
|
| 357 |
+
from: e.from_canon, to: e.to_canon, relation: e.relation, tier: e.tier,
|
| 358 |
+
confidence: e.confidence, fix: e.fix || "", conditions: e.conditions || {},
|
| 359 |
+
evidence: e.evidence || [],
|
| 360 |
+
}));
|
| 361 |
+
window.FORGE_LIVE = true;
|
| 362 |
+
}
|
| 363 |
+
const feed = await (await fetch(base + "/feed?n=12", { cache: "no-store" })).json();
|
| 364 |
+
if (Array.isArray(feed) && feed.length) {
|
| 365 |
+
window.FORGE_DISCOVERIES = feed.map(f => ({
|
| 366 |
+
a: f.a, relation: f.relation, b: f.b, tier: f.tier, source: "bright data", ts_offset: 0,
|
| 367 |
+
}));
|
| 368 |
+
}
|
| 369 |
+
} catch (err) {
|
| 370 |
+
console.warn("[forge] live API unavailable, using bundled mock:", err.message);
|
| 371 |
+
}
|
| 372 |
+
return window.FORGE_LIVE;
|
| 373 |
+
};
|
frontend/graph.jsx
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Force-directed graph — the centerpiece.
|
| 2 |
+
// Uses d3-force (loaded from CDN in index.html) for the simulation. SVG render
|
| 3 |
+
// for the visual layer so state transitions are CSS-driven.
|
| 4 |
+
|
| 5 |
+
const { useEffect, useRef, useState, useMemo, useCallback } = React;
|
| 6 |
+
|
| 7 |
+
window.ForgeGraph = function ForgeGraph({
|
| 8 |
+
nodes,
|
| 9 |
+
edges,
|
| 10 |
+
resolved,
|
| 11 |
+
selected,
|
| 12 |
+
hoveredCanon,
|
| 13 |
+
onToggleSelect,
|
| 14 |
+
onHoverNode,
|
| 15 |
+
onHoverEdge,
|
| 16 |
+
}) {
|
| 17 |
+
const svgRef = useRef(null);
|
| 18 |
+
const containerRef = useRef(null);
|
| 19 |
+
const simRef = useRef(null);
|
| 20 |
+
const [size, setSize] = useState({ w: 1000, h: 700 });
|
| 21 |
+
const [transform, setTransform] = useState({ x: 0, y: 0, k: 1 });
|
| 22 |
+
const transformRef = useRef(transform);
|
| 23 |
+
transformRef.current = transform;
|
| 24 |
+
|
| 25 |
+
// resize observer
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
if (!containerRef.current) return;
|
| 28 |
+
const ro = new ResizeObserver(([e]) => {
|
| 29 |
+
const r = e.contentRect;
|
| 30 |
+
setSize({ w: Math.max(400, r.width), h: Math.max(400, r.height) });
|
| 31 |
+
});
|
| 32 |
+
ro.observe(containerRef.current);
|
| 33 |
+
return () => ro.disconnect();
|
| 34 |
+
}, []);
|
| 35 |
+
|
| 36 |
+
// Build simulation. Pin nodes by canonical so they keep identity across re-renders.
|
| 37 |
+
const simNodesRef = useRef(new Map());
|
| 38 |
+
|
| 39 |
+
useEffect(() => {
|
| 40 |
+
// (re)initialise simulation when node list size changes
|
| 41 |
+
const w = size.w, h = size.h;
|
| 42 |
+
const simNodes = nodes.map(n => {
|
| 43 |
+
const existing = simNodesRef.current.get(n.canonical);
|
| 44 |
+
if (existing) {
|
| 45 |
+
existing.node = n;
|
| 46 |
+
return existing;
|
| 47 |
+
}
|
| 48 |
+
const fresh = {
|
| 49 |
+
canonical: n.canonical,
|
| 50 |
+
node: n,
|
| 51 |
+
x: w / 2 + (Math.random() - 0.5) * 200,
|
| 52 |
+
y: h / 2 + (Math.random() - 0.5) * 200,
|
| 53 |
+
vx: 0, vy: 0,
|
| 54 |
+
};
|
| 55 |
+
simNodesRef.current.set(n.canonical, fresh);
|
| 56 |
+
return fresh;
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
const simEdges = edges.map(e => ({
|
| 60 |
+
source: e.from,
|
| 61 |
+
target: e.to,
|
| 62 |
+
relation: e.relation,
|
| 63 |
+
}));
|
| 64 |
+
|
| 65 |
+
const sim = d3.forceSimulation(simNodes)
|
| 66 |
+
.force("link", d3.forceLink(simEdges)
|
| 67 |
+
.id(d => d.canonical)
|
| 68 |
+
.distance(l => {
|
| 69 |
+
// Tight cluster for compatible/requires, push apart for breaks
|
| 70 |
+
if (l.relation === "BREAKS") return 230;
|
| 71 |
+
if (l.relation === "REQUIRES") return 140;
|
| 72 |
+
if (l.relation === "CONDITIONAL") return 160;
|
| 73 |
+
if (l.relation === "DEGRADES") return 200;
|
| 74 |
+
return 170;
|
| 75 |
+
})
|
| 76 |
+
.strength(l => {
|
| 77 |
+
if (l.relation === "REQUIRES") return 0.6;
|
| 78 |
+
if (l.relation === "BREAKS") return 0.15;
|
| 79 |
+
return 0.25;
|
| 80 |
+
})
|
| 81 |
+
)
|
| 82 |
+
.force("charge", d3.forceManyBody().strength(-580).distanceMax(450))
|
| 83 |
+
.force("center", d3.forceCenter(w / 2, h / 2).strength(0.04))
|
| 84 |
+
.force("collide", d3.forceCollide().radius(54).strength(0.9))
|
| 85 |
+
// Group similar types together vertically
|
| 86 |
+
.force("typeY", d3.forceY(d => typeYTarget(d.node.type, h)).strength(0.06))
|
| 87 |
+
.alpha(1)
|
| 88 |
+
.alphaDecay(0.025);
|
| 89 |
+
|
| 90 |
+
simRef.current = sim;
|
| 91 |
+
|
| 92 |
+
const renderTick = () => {
|
| 93 |
+
// direct DOM update for perf
|
| 94 |
+
const svg = svgRef.current;
|
| 95 |
+
if (!svg) return;
|
| 96 |
+
for (const n of simNodes) {
|
| 97 |
+
const el = svg.querySelector(`g.node[data-canon="${cssEscape(n.canonical)}"]`);
|
| 98 |
+
if (el) el.setAttribute("transform", `translate(${n.x.toFixed(2)},${n.y.toFixed(2)})`);
|
| 99 |
+
}
|
| 100 |
+
for (let i = 0; i < simEdges.length; i++) {
|
| 101 |
+
const e = simEdges[i];
|
| 102 |
+
const path = svg.querySelector(`g.edge[data-id="${i}"] path.edge-path`);
|
| 103 |
+
if (path && e.source.x != null) {
|
| 104 |
+
const x1 = e.source.x, y1 = e.source.y;
|
| 105 |
+
const x2 = e.target.x, y2 = e.target.y;
|
| 106 |
+
const dx = x2 - x1, dy = y2 - y1;
|
| 107 |
+
const len = Math.sqrt(dx*dx + dy*dy) || 1;
|
| 108 |
+
// perpendicular unit vector × offset amount (slight molten droop)
|
| 109 |
+
const nx = -dy / len, ny = dx / len;
|
| 110 |
+
const sag = Math.min(40, len * 0.18) * ((i % 2) ? 1 : -1) * (e.relation === "BREAKS" ? 0.6 : 1);
|
| 111 |
+
const cx = (x1 + x2) / 2 + nx * sag;
|
| 112 |
+
const cy = (y1 + y2) / 2 + ny * sag;
|
| 113 |
+
const d = `M ${x1.toFixed(1)} ${y1.toFixed(1)} Q ${cx.toFixed(1)} ${cy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
|
| 114 |
+
path.setAttribute("d", d);
|
| 115 |
+
const glow = svg.querySelector(`g.edge[data-id="${i}"] path.edge-glow`);
|
| 116 |
+
if (glow) glow.setAttribute("d", d);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
};
|
| 120 |
+
sim.on("tick", renderTick);
|
| 121 |
+
|
| 122 |
+
// Pre-tick a chunk so the layout settles before the first paint,
|
| 123 |
+
// and so we have a fallback layout even if d3.timer doesn't fire
|
| 124 |
+
// in this preview iframe.
|
| 125 |
+
for (let i = 0; i < 80; i++) sim.tick();
|
| 126 |
+
renderTick();
|
| 127 |
+
|
| 128 |
+
// Belt-and-braces: drive ticks via a manual rAF loop in case d3.timer
|
| 129 |
+
// is paused (some preview iframes throttle d3-timer).
|
| 130 |
+
let rafHandle;
|
| 131 |
+
const loop = () => {
|
| 132 |
+
if (sim.alpha() > sim.alphaMin()) {
|
| 133 |
+
sim.tick();
|
| 134 |
+
renderTick();
|
| 135 |
+
}
|
| 136 |
+
rafHandle = requestAnimationFrame(loop);
|
| 137 |
+
};
|
| 138 |
+
rafHandle = requestAnimationFrame(loop);
|
| 139 |
+
|
| 140 |
+
return () => { sim.stop(); cancelAnimationFrame(rafHandle); };
|
| 141 |
+
}, [nodes.length, edges.length, size.w, size.h]);
|
| 142 |
+
|
| 143 |
+
// Kick the simulation when selection changes so layout adapts to highlighted edges.
|
| 144 |
+
useEffect(() => {
|
| 145 |
+
if (simRef.current) {
|
| 146 |
+
simRef.current.alpha(0.35).restart();
|
| 147 |
+
}
|
| 148 |
+
}, [selected.length]);
|
| 149 |
+
|
| 150 |
+
// Pan & zoom (basic)
|
| 151 |
+
useEffect(() => {
|
| 152 |
+
const svg = svgRef.current;
|
| 153 |
+
if (!svg) return;
|
| 154 |
+
let dragging = false;
|
| 155 |
+
let last = null;
|
| 156 |
+
const onDown = (ev) => {
|
| 157 |
+
// Only pan when target is not a node card
|
| 158 |
+
if (ev.target.closest("g.node")) return;
|
| 159 |
+
dragging = true;
|
| 160 |
+
last = { x: ev.clientX, y: ev.clientY };
|
| 161 |
+
svg.style.cursor = "grabbing";
|
| 162 |
+
};
|
| 163 |
+
const onMove = (ev) => {
|
| 164 |
+
if (!dragging) return;
|
| 165 |
+
const dx = ev.clientX - last.x;
|
| 166 |
+
const dy = ev.clientY - last.y;
|
| 167 |
+
last = { x: ev.clientX, y: ev.clientY };
|
| 168 |
+
setTransform(t => ({ ...t, x: t.x + dx, y: t.y + dy }));
|
| 169 |
+
};
|
| 170 |
+
const onUp = () => { dragging = false; svg.style.cursor = "grab"; };
|
| 171 |
+
const onWheel = (ev) => {
|
| 172 |
+
ev.preventDefault();
|
| 173 |
+
const dir = ev.deltaY < 0 ? 1.1 : 1 / 1.1;
|
| 174 |
+
setTransform(t => {
|
| 175 |
+
const k = Math.max(0.4, Math.min(2.2, t.k * dir));
|
| 176 |
+
// zoom centered on cursor
|
| 177 |
+
const rect = svg.getBoundingClientRect();
|
| 178 |
+
const cx = ev.clientX - rect.left;
|
| 179 |
+
const cy = ev.clientY - rect.top;
|
| 180 |
+
const dx = (cx - t.x) * (1 - dir);
|
| 181 |
+
const dy = (cy - t.y) * (1 - dir);
|
| 182 |
+
return { x: t.x + dx, y: t.y + dy, k };
|
| 183 |
+
});
|
| 184 |
+
};
|
| 185 |
+
svg.addEventListener("mousedown", onDown);
|
| 186 |
+
window.addEventListener("mousemove", onMove);
|
| 187 |
+
window.addEventListener("mouseup", onUp);
|
| 188 |
+
svg.addEventListener("wheel", onWheel, { passive: false });
|
| 189 |
+
return () => {
|
| 190 |
+
svg.removeEventListener("mousedown", onDown);
|
| 191 |
+
window.removeEventListener("mousemove", onMove);
|
| 192 |
+
window.removeEventListener("mouseup", onUp);
|
| 193 |
+
svg.removeEventListener("wheel", onWheel);
|
| 194 |
+
};
|
| 195 |
+
}, []);
|
| 196 |
+
|
| 197 |
+
const resetView = () => setTransform({ x: 0, y: 0, k: 1 });
|
| 198 |
+
const zoom = (dir) => setTransform(t => ({ ...t, k: Math.max(0.4, Math.min(2.2, t.k * dir)) }));
|
| 199 |
+
|
| 200 |
+
const selectedSet = useMemo(() => new Set(selected), [selected]);
|
| 201 |
+
|
| 202 |
+
// map edges to their indices so the simulation can find them
|
| 203 |
+
const edgeData = useMemo(() => edges.map((e, i) => ({ ...e, _i: i })), [edges]);
|
| 204 |
+
|
| 205 |
+
// Which edges are "hot" — incident to a selected node
|
| 206 |
+
const hotEdge = (e) => selectedSet.has(e.from) || selectedSet.has(e.to);
|
| 207 |
+
|
| 208 |
+
// For "needs-by" rendering: which nodes are amber because some selected node requires them?
|
| 209 |
+
const neededBy = useMemo(() => {
|
| 210 |
+
const m = {};
|
| 211 |
+
for (const e of edges) {
|
| 212 |
+
if (e.relation === "REQUIRES" && selectedSet.has(e.from) && !selectedSet.has(e.to)) {
|
| 213 |
+
(m[e.to] = m[e.to] || []).push(e.from);
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
return m;
|
| 217 |
+
}, [edges, selectedSet]);
|
| 218 |
+
|
| 219 |
+
// Helpers
|
| 220 |
+
function classFor(canon) {
|
| 221 |
+
const r = resolved[canon];
|
| 222 |
+
if (!r) return "s-available";
|
| 223 |
+
return "s-" + r.status;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
return (
|
| 227 |
+
<div className="stage" ref={containerRef}>
|
| 228 |
+
<svg
|
| 229 |
+
ref={svgRef}
|
| 230 |
+
className="graph"
|
| 231 |
+
width={size.w} height={size.h}
|
| 232 |
+
viewBox={`0 0 ${size.w} ${size.h}`}
|
| 233 |
+
preserveAspectRatio="xMidYMid meet"
|
| 234 |
+
>
|
| 235 |
+
<defs>
|
| 236 |
+
{/* Molten plate gradient — the "just-forged" red-hot interior */}
|
| 237 |
+
<radialGradient id="grad-molten-plate" cx="50%" cy="55%" r="65%">
|
| 238 |
+
<stop offset="0%" stopColor="#5a1f0a" />
|
| 239 |
+
<stop offset="35%" stopColor="#3a1408" />
|
| 240 |
+
<stop offset="70%" stopColor="#1a0807" />
|
| 241 |
+
<stop offset="100%" stopColor="#0a0303" />
|
| 242 |
+
</radialGradient>
|
| 243 |
+
{/* Forge ember glow filter (used on selected plates) */}
|
| 244 |
+
<filter id="node-ember-glow" x="-50%" y="-50%" width="200%" height="200%">
|
| 245 |
+
<feGaussianBlur stdDeviation="2.6" result="blur" />
|
| 246 |
+
<feFlood floodColor="#ff6a1f" floodOpacity="0.55" />
|
| 247 |
+
<feComposite in2="blur" operator="in" />
|
| 248 |
+
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
| 249 |
+
</filter>
|
| 250 |
+
{/* Painterly noise overlay for plate backgrounds */}
|
| 251 |
+
<filter id="plate-grain" x="0" y="0" width="100%" height="100%">
|
| 252 |
+
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="3" />
|
| 253 |
+
<feColorMatrix values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0" />
|
| 254 |
+
<feComposite in2="SourceGraphic" operator="in" />
|
| 255 |
+
</filter>
|
| 256 |
+
{/* Arrow for REQUIRES edges */}
|
| 257 |
+
<marker id="arrow-requires" viewBox="0 0 10 10" refX="9" refY="5"
|
| 258 |
+
markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
| 259 |
+
<path d="M0 0 L10 5 L0 10 z" fill="rgba(212,164,68,0.7)" />
|
| 260 |
+
</marker>
|
| 261 |
+
{/* Tier-rarity coin gradients — T1 legendary, T2 rare, T3 common */}
|
| 262 |
+
<radialGradient id="brass-t1" cx="40%" cy="35%" r="75%">
|
| 263 |
+
<stop offset="0%" stopColor="#ffd09a" />
|
| 264 |
+
<stop offset="45%" stopColor="#ff8a3d" />
|
| 265 |
+
<stop offset="100%" stopColor="#6a2a08" />
|
| 266 |
+
</radialGradient>
|
| 267 |
+
<radialGradient id="brass-t2" cx="40%" cy="35%" r="75%">
|
| 268 |
+
<stop offset="0%" stopColor="#b8d4f0" />
|
| 269 |
+
<stop offset="45%" stopColor="#5b9bd8" />
|
| 270 |
+
<stop offset="100%" stopColor="#1c3a5a" />
|
| 271 |
+
</radialGradient>
|
| 272 |
+
<radialGradient id="brass-t3" cx="40%" cy="35%" r="75%">
|
| 273 |
+
<stop offset="0%" stopColor="#cac4b6" />
|
| 274 |
+
<stop offset="45%" stopColor="#9a9285" />
|
| 275 |
+
<stop offset="100%" stopColor="#3a3528" />
|
| 276 |
+
</radialGradient>
|
| 277 |
+
</defs>
|
| 278 |
+
|
| 279 |
+
<g transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}>
|
| 280 |
+
{/* Edges */}
|
| 281 |
+
<g className="edges">
|
| 282 |
+
{edgeData.map((e, i) => {
|
| 283 |
+
const hot = hotEdge(e);
|
| 284 |
+
const anySelected = selectedSet.size > 0;
|
| 285 |
+
const faded = anySelected && !hot && e.relation !== "BREAKS";
|
| 286 |
+
const relCls = e.relation.toLowerCase();
|
| 287 |
+
return (
|
| 288 |
+
<g key={i}
|
| 289 |
+
className={`edge ${relCls} ${hot ? "hot" : ""} ${faded ? "faded" : ""}`}
|
| 290 |
+
data-id={i}
|
| 291 |
+
onMouseEnter={ev => onHoverEdge && onHoverEdge(e, ev)}
|
| 292 |
+
onMouseLeave={() => onHoverEdge && onHoverEdge(null)}
|
| 293 |
+
>
|
| 294 |
+
{/* glow overlay drawn first (under) for hot edges */}
|
| 295 |
+
<path className="edge-glow" />
|
| 296 |
+
<path className="edge-path"
|
| 297 |
+
markerEnd={e.relation === "REQUIRES" ? "url(#arrow-requires)" : undefined} />
|
| 298 |
+
</g>
|
| 299 |
+
);
|
| 300 |
+
})}
|
| 301 |
+
</g>
|
| 302 |
+
|
| 303 |
+
{/* Nodes — ornate brass-framed runestone plaques */}
|
| 304 |
+
<g className="nodes">
|
| 305 |
+
{nodes.map(n => {
|
| 306 |
+
const meta = window.TYPE_META[n.type] || window.TYPE_META.technique;
|
| 307 |
+
const status = (resolved[n.canonical] || {}).status || "available";
|
| 308 |
+
const needs = neededBy[n.canonical] || [];
|
| 309 |
+
const isSel = selectedSet.has(n.canonical);
|
| 310 |
+
const cls = isSel ? "s-selected" : ("s-" + status);
|
| 311 |
+
|
| 312 |
+
// Octagon vertices (flat-top, 64×64). Computed once below.
|
| 313 |
+
const OCT_OUTER = "-13.3,-32 13.3,-32 32,-13.3 32,13.3 13.3,32 -13.3,32 -32,13.3 -32,-13.3";
|
| 314 |
+
const OCT_BRASS = "-11.2,-27 11.2,-27 27,-11.2 27,11.2 11.2,27 -11.2,27 -27,11.2 -27,-11.2";
|
| 315 |
+
const OCT_HILT = "-9.5,-23 9.5,-23 23,-9.5 23,9.5 9.5,23 -9.5,23 -23,9.5 -23,-9.5";
|
| 316 |
+
// Four corner stud positions (on the diagonal short edges)
|
| 317 |
+
const STUDS = [[-22.6,-22.6], [22.6,-22.6], [22.6,22.6], [-22.6,22.6]];
|
| 318 |
+
const tierGrad = `url(#brass-t${n.tier})`;
|
| 319 |
+
|
| 320 |
+
const needsLabel = needs.length > 0 ? `needs ${truncate(displayName(needs[0]), 14)}` : "";
|
| 321 |
+
const chipW = Math.max(70, needsLabel.length * 5 + 16);
|
| 322 |
+
|
| 323 |
+
return (
|
| 324 |
+
<g
|
| 325 |
+
key={n.canonical}
|
| 326 |
+
className={`node ${cls}`}
|
| 327 |
+
data-canon={n.canonical}
|
| 328 |
+
style={{ ["--type-color"]: meta.color }}
|
| 329 |
+
onClick={() => onToggleSelect(n.canonical)}
|
| 330 |
+
onMouseEnter={ev => onHoverNode(n, ev)}
|
| 331 |
+
onMouseLeave={() => onHoverNode(null)}
|
| 332 |
+
>
|
| 333 |
+
{/* outer pulse halo (only visible per state) */}
|
| 334 |
+
<polygon className="node-aura" points={OCT_OUTER}
|
| 335 |
+
transform="scale(1.25)" />
|
| 336 |
+
|
| 337 |
+
{/* outer black/iron bezel */}
|
| 338 |
+
<polygon className="node-frame-outer" points={OCT_OUTER} />
|
| 339 |
+
|
| 340 |
+
{/* the stone plate (interior) */}
|
| 341 |
+
<polygon className="node-plate" points={OCT_BRASS} />
|
| 342 |
+
|
| 343 |
+
{/* type-colored gem glow behind icon */}
|
| 344 |
+
<ellipse className="node-gem" cx="0" cy="-2" rx="14" ry="11" />
|
| 345 |
+
|
| 346 |
+
{/* brass frame ring */}
|
| 347 |
+
<polygon className="node-frame" points={OCT_BRASS} />
|
| 348 |
+
<polygon className="node-frame-inner" points={OCT_HILT} />
|
| 349 |
+
|
| 350 |
+
{/* brass corner studs */}
|
| 351 |
+
{STUDS.map(([sx,sy], si) => (
|
| 352 |
+
<circle key={si} className="node-stud" cx={sx} cy={sy} r="1.8" />
|
| 353 |
+
))}
|
| 354 |
+
|
| 355 |
+
{/* icon — type glyph */}
|
| 356 |
+
<g className="node-icon" transform="translate(-12,-14) scale(1.0)"
|
| 357 |
+
dangerouslySetInnerHTML={{ __html: meta.icon }} />
|
| 358 |
+
|
| 359 |
+
{/* name ribbon below the plate */}
|
| 360 |
+
<path className="node-ribbon"
|
| 361 |
+
d="M -33 33 L -38 47 L -28 44 L -22 48 L -16 44 L -10 48 L -4 44 L 2 48 L 8 44 L 14 48 L 20 44 L 26 48 L 34 47 L 33 33 Z" />
|
| 362 |
+
<text className="node-name" y="44.5">{truncate(n.name, 14)}</text>
|
| 363 |
+
|
| 364 |
+
{/* tier wax-seal medallion top-right */}
|
| 365 |
+
<g className="node-seal" transform="translate(22,-22)">
|
| 366 |
+
<circle className="node-seal-disc" r="8.5" fill={tierGrad} />
|
| 367 |
+
<circle r="6.5" fill="none" stroke="rgba(0,0,0,0.4)" strokeWidth="0.5" />
|
| 368 |
+
<text y="2.5">T{n.tier}</text>
|
| 369 |
+
</g>
|
| 370 |
+
|
| 371 |
+
{/* lock graphic for blocked state — over the icon, ominous */}
|
| 372 |
+
<g className="node-lock" transform="translate(0,-2)">
|
| 373 |
+
<path className="lock-shackle" d="M -5 -2 v -4 a 5 5 0 0 1 10 0 v 4" />
|
| 374 |
+
<rect className="lock-body" x="-7" y="-2" width="14" height="11" rx="1.5" />
|
| 375 |
+
<circle className="lock-keyhole" cx="0" cy="2" r="1.4" />
|
| 376 |
+
<rect className="lock-keyhole" x="-0.5" y="2" width="1" height="3.5" />
|
| 377 |
+
</g>
|
| 378 |
+
|
| 379 |
+
{/* "needs X" pinned scroll-tag for conditional */}
|
| 380 |
+
{(status === "conditional" && needs.length > 0) && (
|
| 381 |
+
<g className="needs-chip" transform="translate(0,60)">
|
| 382 |
+
<rect x={-chipW/2} y="-7" width={chipW} height="14" rx="2" />
|
| 383 |
+
<circle className="chip-pin" cx={-chipW/2} cy="0" r="1.5" />
|
| 384 |
+
<circle className="chip-pin" cx={chipW/2} cy="0" r="1.5" />
|
| 385 |
+
<text y="2.5">{needsLabel}</text>
|
| 386 |
+
</g>
|
| 387 |
+
)}
|
| 388 |
+
</g>
|
| 389 |
+
);
|
| 390 |
+
})}
|
| 391 |
+
</g>
|
| 392 |
+
</g>
|
| 393 |
+
</svg>
|
| 394 |
+
|
| 395 |
+
{/* corner controls */}
|
| 396 |
+
<div className="stage-controls">
|
| 397 |
+
<button className="ctrl" onClick={() => zoom(1.15)} title="Zoom in">
|
| 398 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3M8 11h6M11 8v6"/></svg>
|
| 399 |
+
</button>
|
| 400 |
+
<button className="ctrl" onClick={() => zoom(1/1.15)} title="Zoom out">
|
| 401 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3M8 11h6"/></svg>
|
| 402 |
+
</button>
|
| 403 |
+
<button className="ctrl" onClick={resetView} title="Reset view">
|
| 404 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/></svg>
|
| 405 |
+
</button>
|
| 406 |
+
</div>
|
| 407 |
+
|
| 408 |
+
<div className="stage-toolbar">
|
| 409 |
+
<span className="pill"><span className="dot" style={{ background: "var(--ember-1)", boxShadow: "0 0 6px var(--ember-1)"}}/>The Forge</span>
|
| 410 |
+
<span className="pill" title="API base URL">
|
| 411 |
+
api · localhost:8010
|
| 412 |
+
</span>
|
| 413 |
+
</div>
|
| 414 |
+
|
| 415 |
+
{selectedSet.size === 0 && (
|
| 416 |
+
<div className="stage-hint">
|
| 417 |
+
<div className="ring" />
|
| 418 |
+
<h3>Lay an ingredient on the anvil</h3>
|
| 419 |
+
<p>Pick a component from the inventory.<br/>Incompatible nodes will grey out with citations.</p>
|
| 420 |
+
</div>
|
| 421 |
+
)}
|
| 422 |
+
|
| 423 |
+
<div className="anvil-watermark">forge · 2026</div>
|
| 424 |
+
</div>
|
| 425 |
+
);
|
| 426 |
+
};
|
| 427 |
+
|
| 428 |
+
// helpers
|
| 429 |
+
function typeYTarget(type, h) {
|
| 430 |
+
const order = ["architecture", "technique", "optimizer", "scheduler", "quantization", "inference"];
|
| 431 |
+
const i = order.indexOf(type);
|
| 432 |
+
if (i === -1) return h / 2;
|
| 433 |
+
// distribute around vertical center with mild attraction
|
| 434 |
+
const span = h * 0.55;
|
| 435 |
+
return (h / 2) + (i - (order.length - 1) / 2) * (span / order.length);
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
function cssEscape(s) {
|
| 439 |
+
if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
|
| 440 |
+
return String(s).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
function displayName(canon) {
|
| 444 |
+
const n = (window.FORGE_NODES || []).find(x => x.canonical === canon);
|
| 445 |
+
return n ? n.name : canon;
|
| 446 |
+
}
|
| 447 |
+
function truncate(s, n) { return s && s.length > n ? s.slice(0, n - 1) + "…" : s; }
|
frontend/icons.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Icons for component types — hand-drawn SVG paths that read as D&D iconography
|
| 2 |
+
// at small sizes (24x24 viewBox). Used both in the inventory rail and graph nodes.
|
| 3 |
+
|
| 4 |
+
window.TYPE_META = {
|
| 5 |
+
optimizer: {
|
| 6 |
+
label: "Optimizer",
|
| 7 |
+
color: "var(--t-optimizer)",
|
| 8 |
+
glow: "var(--t-optimizer-glow)",
|
| 9 |
+
// a forge gear — toothed wheel
|
| 10 |
+
icon: `<g><circle cx="12" cy="12" r="3.2"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/></g>`,
|
| 11 |
+
},
|
| 12 |
+
scheduler: {
|
| 13 |
+
label: "Scheduler",
|
| 14 |
+
color: "var(--t-scheduler)",
|
| 15 |
+
glow: "var(--t-scheduler-glow)",
|
| 16 |
+
// an hourglass/clock
|
| 17 |
+
icon: `<g><circle cx="12" cy="12" r="8"/><path d="M12 7v5l3 2"/></g>`,
|
| 18 |
+
},
|
| 19 |
+
technique: {
|
| 20 |
+
label: "Technique",
|
| 21 |
+
color: "var(--t-technique)",
|
| 22 |
+
glow: "var(--t-technique-glow)",
|
| 23 |
+
// arcane sigil — diamond inside triangle
|
| 24 |
+
icon: `<g><path d="M12 3l9 16H3z"/><path d="M12 9l4 7H8z"/></g>`,
|
| 25 |
+
},
|
| 26 |
+
quantization: {
|
| 27 |
+
label: "Quantization",
|
| 28 |
+
color: "var(--t-quantization)",
|
| 29 |
+
glow: "var(--t-quantization-glow)",
|
| 30 |
+
// stacked bars compressed
|
| 31 |
+
icon: `<g><rect x="4" y="13" width="3" height="7" rx="0.5"/><rect x="10.5" y="9" width="3" height="11" rx="0.5"/><rect x="17" y="5" width="3" height="15" rx="0.5"/></g>`,
|
| 32 |
+
},
|
| 33 |
+
architecture: {
|
| 34 |
+
label: "Architecture",
|
| 35 |
+
color: "var(--t-architecture)",
|
| 36 |
+
glow: "var(--t-architecture-glow)",
|
| 37 |
+
// temple pillars / colonnade
|
| 38 |
+
icon: `<g><path d="M3 7l9-4 9 4"/><path d="M5 7v12M19 7v12M12 7v12"/><path d="M3 21h18"/></g>`,
|
| 39 |
+
},
|
| 40 |
+
inference: {
|
| 41 |
+
label: "Inference",
|
| 42 |
+
color: "var(--t-inference)",
|
| 43 |
+
glow: "var(--t-inference-glow)",
|
| 44 |
+
// arrow with spark
|
| 45 |
+
icon: `<g><path d="M4 12h13"/><path d="M13 6l6 6-6 6"/></g>`,
|
| 46 |
+
},
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
// helper: render the inline icon path inside an SVG <g> with stroke set by callers.
|
| 50 |
+
window.renderTypeIcon = function (type, props) {
|
| 51 |
+
const meta = window.TYPE_META[type] || window.TYPE_META.technique;
|
| 52 |
+
return { html: meta.icon };
|
| 53 |
+
};
|
frontend/index.html
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Forge — ML training compatibility graph</title>
|
| 7 |
+
|
| 8 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 9 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;1,400;1,500&family=Geist:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
| 11 |
+
|
| 12 |
+
<link rel="stylesheet" href="styles.css">
|
| 13 |
+
|
| 14 |
+
<!-- React + Babel (pinned) -->
|
| 15 |
+
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
| 16 |
+
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
| 17 |
+
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
| 18 |
+
|
| 19 |
+
<!-- d3 (force simulation) -->
|
| 20 |
+
<script src="https://unpkg.com/d3@7.9.0/dist/d3.min.js"></script>
|
| 21 |
+
|
| 22 |
+
</head>
|
| 23 |
+
<body>
|
| 24 |
+
<div id="root"></div>
|
| 25 |
+
|
| 26 |
+
<!-- Mock data + resolver (matches FastAPI /resolve, /recipe shapes) -->
|
| 27 |
+
<script src="data.js"></script>
|
| 28 |
+
<!-- Type icons / metadata -->
|
| 29 |
+
<script src="icons.js"></script>
|
| 30 |
+
<!-- Forge sounds (Web Audio, no assets) -->
|
| 31 |
+
<script src="sound.js"></script>
|
| 32 |
+
|
| 33 |
+
<!-- Tweaks shell (host protocol + form controls) -->
|
| 34 |
+
<script type="text/babel" src="tweaks-panel.jsx"></script>
|
| 35 |
+
|
| 36 |
+
<!-- App pieces -->
|
| 37 |
+
<script type="text/babel" src="graph.jsx"></script>
|
| 38 |
+
<script type="text/babel" src="inventory.jsx"></script>
|
| 39 |
+
<script type="text/babel" src="recipe.jsx"></script>
|
| 40 |
+
<script type="text/babel" src="ticker.jsx"></script>
|
| 41 |
+
<script type="text/babel" src="tooltip.jsx"></script>
|
| 42 |
+
<script type="text/babel" src="app.jsx"></script>
|
| 43 |
+
|
| 44 |
+
</body>
|
| 45 |
+
</html>
|
frontend/inventory.jsx
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Left rail — the "inventory". Filterable component palette.
|
| 2 |
+
|
| 3 |
+
window.Inventory = function Inventory({
|
| 4 |
+
nodes,
|
| 5 |
+
resolved,
|
| 6 |
+
selected,
|
| 7 |
+
onToggleSelect,
|
| 8 |
+
onHoverNode,
|
| 9 |
+
}) {
|
| 10 |
+
const [filter, setFilter] = React.useState("all");
|
| 11 |
+
const [q, setQ] = React.useState("");
|
| 12 |
+
const selectedSet = React.useMemo(() => new Set(selected), [selected]);
|
| 13 |
+
|
| 14 |
+
// Group nodes by type, ordered.
|
| 15 |
+
const typeOrder = ["optimizer", "scheduler", "technique", "quantization", "architecture", "inference"];
|
| 16 |
+
const counts = React.useMemo(() => {
|
| 17 |
+
const c = { all: nodes.length };
|
| 18 |
+
for (const t of typeOrder) c[t] = 0;
|
| 19 |
+
for (const n of nodes) c[n.type] = (c[n.type] || 0) + 1;
|
| 20 |
+
return c;
|
| 21 |
+
}, [nodes]);
|
| 22 |
+
|
| 23 |
+
const filtered = React.useMemo(() => {
|
| 24 |
+
const lq = q.trim().toLowerCase();
|
| 25 |
+
return nodes.filter(n => {
|
| 26 |
+
if (filter !== "all" && n.type !== filter) return false;
|
| 27 |
+
if (lq && !(n.name.toLowerCase().includes(lq) || n.canonical.includes(lq))) return false;
|
| 28 |
+
return true;
|
| 29 |
+
});
|
| 30 |
+
}, [nodes, filter, q]);
|
| 31 |
+
|
| 32 |
+
const grouped = React.useMemo(() => {
|
| 33 |
+
const g = {};
|
| 34 |
+
for (const n of filtered) (g[n.type] = g[n.type] || []).push(n);
|
| 35 |
+
return g;
|
| 36 |
+
}, [filtered]);
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="rail-left">
|
| 40 |
+
<div className="rail-head">
|
| 41 |
+
<h2>Inventory</h2>
|
| 42 |
+
<div className="sub">{nodes.length} components</div>
|
| 43 |
+
</div>
|
| 44 |
+
|
| 45 |
+
<div className="filters">
|
| 46 |
+
<button className={"chip " + (filter === "all" ? "on" : "")}
|
| 47 |
+
onClick={() => setFilter("all")}>
|
| 48 |
+
All <span className="ct">{counts.all}</span>
|
| 49 |
+
</button>
|
| 50 |
+
{typeOrder.map(t => (
|
| 51 |
+
counts[t] > 0 && (
|
| 52 |
+
<button key={t}
|
| 53 |
+
className={"chip " + (filter === t ? "on" : "")}
|
| 54 |
+
onClick={() => setFilter(t)}>
|
| 55 |
+
{window.TYPE_META[t].label}
|
| 56 |
+
<span className="ct">{counts[t]}</span>
|
| 57 |
+
</button>
|
| 58 |
+
)
|
| 59 |
+
))}
|
| 60 |
+
</div>
|
| 61 |
+
|
| 62 |
+
<div className="search">
|
| 63 |
+
<input
|
| 64 |
+
type="text"
|
| 65 |
+
placeholder="search components…"
|
| 66 |
+
value={q}
|
| 67 |
+
onChange={e => setQ(e.target.value)}
|
| 68 |
+
/>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div className="inv-list">
|
| 72 |
+
{typeOrder.map(t => {
|
| 73 |
+
const list = grouped[t];
|
| 74 |
+
if (!list || list.length === 0) return null;
|
| 75 |
+
return (
|
| 76 |
+
<div key={t} style={{ marginBottom: 6 }}>
|
| 77 |
+
<div className="inv-group-title">
|
| 78 |
+
{window.TYPE_META[t].label}
|
| 79 |
+
</div>
|
| 80 |
+
{list.map(n => {
|
| 81 |
+
const r = resolved[n.canonical] || {};
|
| 82 |
+
const isSelected = selectedSet.has(n.canonical);
|
| 83 |
+
const cls = [
|
| 84 |
+
"inv-row",
|
| 85 |
+
`tier-${n.tier}`,
|
| 86 |
+
isSelected ? "selected" : "",
|
| 87 |
+
r.status === "blocked" ? "blocked" : "",
|
| 88 |
+
r.status === "conditional" ? "dim" : "",
|
| 89 |
+
].filter(Boolean).join(" ");
|
| 90 |
+
const meta = window.TYPE_META[n.type];
|
| 91 |
+
const metaText =
|
| 92 |
+
isSelected ? "equipped"
|
| 93 |
+
: r.status === "blocked" ? `blocked · ${r.reasons[0]?.label || "breaks"}`
|
| 94 |
+
: r.status === "conditional" ? (r.reasons[0]?.label || "awaiting")
|
| 95 |
+
: null;
|
| 96 |
+
return (
|
| 97 |
+
<div
|
| 98 |
+
key={n.canonical}
|
| 99 |
+
className={cls}
|
| 100 |
+
style={{ ["--type-color"]: meta.color }}
|
| 101 |
+
onClick={() => onToggleSelect(n.canonical)}
|
| 102 |
+
onMouseEnter={e => onHoverNode(n, e)}
|
| 103 |
+
onMouseLeave={() => onHoverNode(null)}
|
| 104 |
+
>
|
| 105 |
+
<div className="type-ic">
|
| 106 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"
|
| 107 |
+
strokeLinecap="round" strokeLinejoin="round"
|
| 108 |
+
dangerouslySetInnerHTML={{ __html: meta.icon }} />
|
| 109 |
+
</div>
|
| 110 |
+
<div>
|
| 111 |
+
<div className="inv-name">{n.name}</div>
|
| 112 |
+
<div className="inv-meta">
|
| 113 |
+
{metaText ? (
|
| 114 |
+
<span style={{
|
| 115 |
+
color: isSelected ? "var(--ember-2)"
|
| 116 |
+
: r.status === "blocked" ? "var(--rust)"
|
| 117 |
+
: "var(--amber)"
|
| 118 |
+
}}>{metaText}</span>
|
| 119 |
+
) : (
|
| 120 |
+
<span className="canon">{n.canonical}</span>
|
| 121 |
+
)}
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
<div className={`tier-pip t${n.tier}`} title={`Tier ${n.tier}`}>T{n.tier}</div>
|
| 125 |
+
</div>
|
| 126 |
+
);
|
| 127 |
+
})}
|
| 128 |
+
</div>
|
| 129 |
+
);
|
| 130 |
+
})}
|
| 131 |
+
{filtered.length === 0 && (
|
| 132 |
+
<div style={{ padding: "32px 16px", textAlign: "center", color: "var(--bone-4)", fontSize: 11, fontFamily: "var(--f-mono)" }}>
|
| 133 |
+
no components match
|
| 134 |
+
</div>
|
| 135 |
+
)}
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
};
|
frontend/recipe.jsx
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Right rail — the Recipe Card.
|
| 2 |
+
|
| 3 |
+
window.RecipeCard = function RecipeCard({
|
| 4 |
+
selected,
|
| 5 |
+
nodes,
|
| 6 |
+
recipe,
|
| 7 |
+
onRemove,
|
| 8 |
+
onSelect,
|
| 9 |
+
onHoverNode,
|
| 10 |
+
}) {
|
| 11 |
+
const byCanon = React.useMemo(() => Object.fromEntries(nodes.map(n => [n.canonical, n])), [nodes]);
|
| 12 |
+
const empty = selected.length === 0;
|
| 13 |
+
const conflicts = recipe.conflicts || [];
|
| 14 |
+
const unmet = recipe.unmet || [];
|
| 15 |
+
const warnings = recipe.warnings || [];
|
| 16 |
+
|
| 17 |
+
let statusCls = "empty", statusText = "no build";
|
| 18 |
+
if (!empty) {
|
| 19 |
+
if (conflicts.length > 0) { statusCls = "broken"; statusText = "conflicts"; }
|
| 20 |
+
else if (unmet.length > 0) { statusCls = "warn"; statusText = "incomplete"; }
|
| 21 |
+
else { statusCls = "valid"; statusText = "valid"; }
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<div className="rail-right">
|
| 26 |
+
<div className="rail-head">
|
| 27 |
+
<div>
|
| 28 |
+
<h2>Recipe</h2>
|
| 29 |
+
<div className="sub">your build</div>
|
| 30 |
+
</div>
|
| 31 |
+
<div className={`recipe-status ${statusCls}`}>{statusText}</div>
|
| 32 |
+
</div>
|
| 33 |
+
|
| 34 |
+
<div className="recipe-body">
|
| 35 |
+
{/* Build chips */}
|
| 36 |
+
<div className="section">
|
| 37 |
+
<h3>Selected <span className="ct">{selected.length}</span></h3>
|
| 38 |
+
{empty ? (
|
| 39 |
+
<div className="empty-state" style={{ padding: "20px 6px 12px" }}>
|
| 40 |
+
<div className="glyph">
|
| 41 |
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 42 |
+
<path d="M12 3l9 5v8l-9 5-9-5V8z"/>
|
| 43 |
+
<path d="M3 8l9 5 9-5M12 13v9"/>
|
| 44 |
+
</svg>
|
| 45 |
+
</div>
|
| 46 |
+
<div className="ttl">Empty anvil</div>
|
| 47 |
+
<div className="sub">pick from inventory</div>
|
| 48 |
+
</div>
|
| 49 |
+
) : (
|
| 50 |
+
<div className="build-chips">
|
| 51 |
+
{selected.map(c => {
|
| 52 |
+
const n = byCanon[c];
|
| 53 |
+
if (!n) return null;
|
| 54 |
+
const meta = window.TYPE_META[n.type];
|
| 55 |
+
return (
|
| 56 |
+
<div
|
| 57 |
+
key={c}
|
| 58 |
+
className="build-chip"
|
| 59 |
+
onMouseEnter={e => onHoverNode(n, e)}
|
| 60 |
+
onMouseLeave={() => onHoverNode(null)}
|
| 61 |
+
onClick={() => onRemove(c)}
|
| 62 |
+
>
|
| 63 |
+
<span className="dot" style={{ background: meta.color }} />
|
| 64 |
+
{n.name}
|
| 65 |
+
<span className="x">×</span>
|
| 66 |
+
</div>
|
| 67 |
+
);
|
| 68 |
+
})}
|
| 69 |
+
</div>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
|
| 73 |
+
{/* Conflicts */}
|
| 74 |
+
{conflicts.length > 0 && (
|
| 75 |
+
<div className="section">
|
| 76 |
+
<h3>Conflicts <span className="ct">{conflicts.length}</span></h3>
|
| 77 |
+
{conflicts.map((c, i) => (
|
| 78 |
+
<div key={i} className="entry conflict">
|
| 79 |
+
<div className="title conflict">
|
| 80 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 81 |
+
<path d="M12 9v4M12 17h.01"/>
|
| 82 |
+
<path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
| 83 |
+
</svg>
|
| 84 |
+
{c.a} <span style={{ color: "var(--bone-4)" }}>↔</span> {c.b}
|
| 85 |
+
</div>
|
| 86 |
+
<div className="fix">{c.fix}</div>
|
| 87 |
+
<div className="meta">
|
| 88 |
+
<span className={`tier-badge t${c.tier}`}>T{c.tier}</span>
|
| 89 |
+
{c.evidence && c.evidence[0] && (
|
| 90 |
+
<a href={c.evidence[0].url} target="_blank" rel="noopener">
|
| 91 |
+
{prettySource(c.evidence[0].url)}
|
| 92 |
+
</a>
|
| 93 |
+
)}
|
| 94 |
+
</div>
|
| 95 |
+
</div>
|
| 96 |
+
))}
|
| 97 |
+
</div>
|
| 98 |
+
)}
|
| 99 |
+
|
| 100 |
+
{/* Unmet requirements */}
|
| 101 |
+
{unmet.length > 0 && (
|
| 102 |
+
<div className="section">
|
| 103 |
+
<h3>Unmet requirements <span className="ct">{unmet.length}</span></h3>
|
| 104 |
+
{unmet.map((u, i) => (
|
| 105 |
+
<div key={i} className="entry unmet">
|
| 106 |
+
<div className="title unmet">
|
| 107 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 108 |
+
<circle cx="12" cy="12" r="9"/>
|
| 109 |
+
<path d="M12 8v4M12 16h.01"/>
|
| 110 |
+
</svg>
|
| 111 |
+
{u.from} needs <strong style={{ color: "var(--bone-1)" }}>{u.missing}</strong>
|
| 112 |
+
</div>
|
| 113 |
+
<div className="fix">{u.fix}</div>
|
| 114 |
+
<div className="meta">
|
| 115 |
+
<span className={`tier-badge t${u.tier}`}>T{u.tier}</span>
|
| 116 |
+
{u.evidence && u.evidence[0] && (
|
| 117 |
+
<a href={u.evidence[0].url} target="_blank" rel="noopener">
|
| 118 |
+
{prettySource(u.evidence[0].url)}
|
| 119 |
+
</a>
|
| 120 |
+
)}
|
| 121 |
+
</div>
|
| 122 |
+
<button className="add-btn" onClick={() => onSelect(u.missingCanon)}>
|
| 123 |
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M12 5v14M5 12h14"/></svg>
|
| 124 |
+
add {u.missing}
|
| 125 |
+
</button>
|
| 126 |
+
</div>
|
| 127 |
+
))}
|
| 128 |
+
</div>
|
| 129 |
+
)}
|
| 130 |
+
|
| 131 |
+
{/* Soft warnings */}
|
| 132 |
+
{warnings.length > 0 && (
|
| 133 |
+
<div className="section">
|
| 134 |
+
<h3>Warnings <span className="ct">{warnings.length}</span></h3>
|
| 135 |
+
{warnings.map((w, i) => (
|
| 136 |
+
<div key={i} className="entry warning">
|
| 137 |
+
<div className="title warning">{w.a} ↔ {w.b}</div>
|
| 138 |
+
<div className="fix">{w.fix}</div>
|
| 139 |
+
</div>
|
| 140 |
+
))}
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
|
| 144 |
+
{/* Scheduler bench */}
|
| 145 |
+
{!empty && (
|
| 146 |
+
<div className="section">
|
| 147 |
+
<h3>Scheduler picks
|
| 148 |
+
<span style={{ marginLeft: 6, fontFamily: "var(--f-mono)", fontSize: 8.5, color: "var(--bone-4)", letterSpacing: "0.16em" }}>
|
| 149 |
+
· distilbert/sst2 · n=3 seeds
|
| 150 |
+
</span>
|
| 151 |
+
</h3>
|
| 152 |
+
<div className="bench-table">
|
| 153 |
+
<div className="br head">
|
| 154 |
+
<div>component</div>
|
| 155 |
+
<div className="val">val_loss</div>
|
| 156 |
+
<div className="val">val_acc</div>
|
| 157 |
+
</div>
|
| 158 |
+
{(recipe.scheduler_picks || []).map((s, i) => (
|
| 159 |
+
<div key={s.canonical} className={"br " + (i === 0 ? "best" : "")}
|
| 160 |
+
onMouseEnter={e => onHoverNode(byCanon[s.canonical], e)}
|
| 161 |
+
onMouseLeave={() => onHoverNode(null)}
|
| 162 |
+
onClick={() => onSelect(s.canonical)}
|
| 163 |
+
style={{ cursor: "pointer" }}
|
| 164 |
+
>
|
| 165 |
+
<div>{s.component}</div>
|
| 166 |
+
<div className="val">{s.val_loss.toFixed(4)}</div>
|
| 167 |
+
<div className="val">{(s.val_acc * 100).toFixed(2)}%</div>
|
| 168 |
+
</div>
|
| 169 |
+
))}
|
| 170 |
+
{(recipe.scheduler_warnings || []).map((s, i) => (
|
| 171 |
+
<div key={s.canonical} className="br warn"
|
| 172 |
+
onMouseEnter={e => onHoverNode(byCanon[s.canonical], e)}
|
| 173 |
+
onMouseLeave={() => onHoverNode(null)}
|
| 174 |
+
>
|
| 175 |
+
<div>{s.component} <span style={{ color: "var(--rust)", fontSize: 9, marginLeft: 4 }}>(above cohort cutoff)</span></div>
|
| 176 |
+
<div className="val">{s.val_loss.toFixed(4)}</div>
|
| 177 |
+
<div className="val">—</div>
|
| 178 |
+
</div>
|
| 179 |
+
))}
|
| 180 |
+
</div>
|
| 181 |
+
<div style={{ marginTop: 8, fontFamily: "var(--f-mono)", fontSize: 9, color: "var(--bone-4)", letterSpacing: "0.12em" }}>
|
| 182 |
+
★ leader · benchmark from hf.co/spaces/juiceb0xc0de/lr-scheduler-benchmark
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
)}
|
| 186 |
+
|
| 187 |
+
{/* Config snippet */}
|
| 188 |
+
{!empty && conflicts.length === 0 && unmet.length === 0 && (
|
| 189 |
+
<div className="section" style={{ borderBottom: "none" }}>
|
| 190 |
+
<h3>Config snippet</h3>
|
| 191 |
+
<pre style={{
|
| 192 |
+
margin: 0,
|
| 193 |
+
fontFamily: "var(--f-mono)",
|
| 194 |
+
fontSize: 11,
|
| 195 |
+
lineHeight: 1.6,
|
| 196 |
+
color: "var(--bone-2)",
|
| 197 |
+
background: "var(--coal-0)",
|
| 198 |
+
border: "1px solid var(--coal-5)",
|
| 199 |
+
borderRadius: 4,
|
| 200 |
+
padding: "10px 12px",
|
| 201 |
+
overflowX: "auto",
|
| 202 |
+
whiteSpace: "pre",
|
| 203 |
+
}}>{configSnippet(selected, byCanon)}</pre>
|
| 204 |
+
</div>
|
| 205 |
+
)}
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
);
|
| 209 |
+
};
|
| 210 |
+
|
| 211 |
+
function prettySource(url) {
|
| 212 |
+
if (!url) return "";
|
| 213 |
+
if (url.startsWith("mempalace://")) return "mempalace · rick";
|
| 214 |
+
try {
|
| 215 |
+
const u = new URL(url);
|
| 216 |
+
return u.hostname.replace(/^www\./, "") + (u.pathname.length > 1 ? u.pathname.split("/").slice(0,2).join("/") : "");
|
| 217 |
+
} catch {
|
| 218 |
+
return url;
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function configSnippet(selected, byCanon) {
|
| 223 |
+
const lines = ["# forge.recipe.yaml"];
|
| 224 |
+
const byType = {};
|
| 225 |
+
for (const c of selected) {
|
| 226 |
+
const n = byCanon[c]; if (!n) continue;
|
| 227 |
+
(byType[n.type] = byType[n.type] || []).push(n);
|
| 228 |
+
}
|
| 229 |
+
const order = ["architecture", "technique", "optimizer", "scheduler", "quantization", "inference"];
|
| 230 |
+
for (const t of order) {
|
| 231 |
+
if (!byType[t]) continue;
|
| 232 |
+
lines.push(`${t}:`);
|
| 233 |
+
for (const n of byType[t]) {
|
| 234 |
+
lines.push(` - ${n.canonical} # ${n.name}`);
|
| 235 |
+
}
|
| 236 |
+
}
|
| 237 |
+
return lines.join("\n");
|
| 238 |
+
}
|
frontend/sound.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Forge sound — synthesised metallic clangs, sizzles, and bell chimes.
|
| 2 |
+
// No assets, no preload — just short Web Audio bursts on demand.
|
| 3 |
+
|
| 4 |
+
(function () {
|
| 5 |
+
let ctx = null;
|
| 6 |
+
let muted = false;
|
| 7 |
+
|
| 8 |
+
function ensure() {
|
| 9 |
+
if (!ctx) {
|
| 10 |
+
try { ctx = new (window.AudioContext || window.webkitAudioContext)(); } catch { return null; }
|
| 11 |
+
}
|
| 12 |
+
if (ctx.state === "suspended") ctx.resume().catch(() => {});
|
| 13 |
+
return ctx;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
// Damped sinusoid hit — "metal strike" feel
|
| 17 |
+
function envelope(node, attack, decay, sustain, release, peak = 1.0) {
|
| 18 |
+
const t0 = ctx.currentTime;
|
| 19 |
+
node.gain.cancelScheduledValues(t0);
|
| 20 |
+
node.gain.setValueAtTime(0, t0);
|
| 21 |
+
node.gain.linearRampToValueAtTime(peak, t0 + attack);
|
| 22 |
+
node.gain.exponentialRampToValueAtTime(Math.max(0.001, sustain), t0 + attack + decay);
|
| 23 |
+
node.gain.exponentialRampToValueAtTime(0.001, t0 + attack + decay + release);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// ── CLANG (on add) — two stacked sines with metallic inharmonic ratios
|
| 27 |
+
function clang() {
|
| 28 |
+
if (!ensure() || muted) return;
|
| 29 |
+
const out = ctx.createGain();
|
| 30 |
+
out.gain.value = 0.18;
|
| 31 |
+
out.connect(ctx.destination);
|
| 32 |
+
|
| 33 |
+
const partials = [
|
| 34 |
+
{ f: 320, mul: 1.0, d: 0.6 },
|
| 35 |
+
{ f: 740, mul: 0.55, d: 0.45 },
|
| 36 |
+
{ f: 1240, mul: 0.30, d: 0.35 },
|
| 37 |
+
{ f: 1980, mul: 0.18, d: 0.22 },
|
| 38 |
+
];
|
| 39 |
+
for (const p of partials) {
|
| 40 |
+
const o = ctx.createOscillator();
|
| 41 |
+
const g = ctx.createGain();
|
| 42 |
+
o.type = "sine";
|
| 43 |
+
o.frequency.value = p.f * (0.99 + Math.random() * 0.02);
|
| 44 |
+
o.connect(g).connect(out);
|
| 45 |
+
envelope(g, 0.003, 0.03, p.mul * 0.18, p.d, p.mul);
|
| 46 |
+
const t0 = ctx.currentTime;
|
| 47 |
+
o.start(t0);
|
| 48 |
+
o.stop(t0 + 0.7);
|
| 49 |
+
}
|
| 50 |
+
// bright transient (noise burst, very short)
|
| 51 |
+
const buf = ctx.createBuffer(1, ctx.sampleRate * 0.06, ctx.sampleRate);
|
| 52 |
+
const data = buf.getChannelData(0);
|
| 53 |
+
for (let i = 0; i < data.length; i++) data[i] = (Math.random() * 2 - 1) * Math.pow(1 - i/data.length, 2);
|
| 54 |
+
const src = ctx.createBufferSource();
|
| 55 |
+
src.buffer = buf;
|
| 56 |
+
const hp = ctx.createBiquadFilter();
|
| 57 |
+
hp.type = "highpass";
|
| 58 |
+
hp.frequency.value = 1800;
|
| 59 |
+
const tg = ctx.createGain();
|
| 60 |
+
tg.gain.value = 0.35;
|
| 61 |
+
src.connect(hp).connect(tg).connect(out);
|
| 62 |
+
src.start();
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// ── THUD (on remove) — low boom, no metallic ring
|
| 66 |
+
function thud() {
|
| 67 |
+
if (!ensure() || muted) return;
|
| 68 |
+
const o = ctx.createOscillator();
|
| 69 |
+
const g = ctx.createGain();
|
| 70 |
+
o.type = "sine";
|
| 71 |
+
const t0 = ctx.currentTime;
|
| 72 |
+
o.frequency.setValueAtTime(140, t0);
|
| 73 |
+
o.frequency.exponentialRampToValueAtTime(50, t0 + 0.25);
|
| 74 |
+
g.gain.setValueAtTime(0, t0);
|
| 75 |
+
g.gain.linearRampToValueAtTime(0.18, t0 + 0.005);
|
| 76 |
+
g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.28);
|
| 77 |
+
o.connect(g).connect(ctx.destination);
|
| 78 |
+
o.start(t0); o.stop(t0 + 0.32);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
// ── SIZZLE (on blocked attempt) — filtered noise hiss
|
| 82 |
+
function sizzle() {
|
| 83 |
+
if (!ensure() || muted) return;
|
| 84 |
+
const dur = 0.32;
|
| 85 |
+
const buf = ctx.createBuffer(1, ctx.sampleRate * dur, ctx.sampleRate);
|
| 86 |
+
const data = buf.getChannelData(0);
|
| 87 |
+
for (let i = 0; i < data.length; i++) {
|
| 88 |
+
const env = Math.pow(1 - i/data.length, 1.4);
|
| 89 |
+
data[i] = (Math.random() * 2 - 1) * env;
|
| 90 |
+
}
|
| 91 |
+
const src = ctx.createBufferSource();
|
| 92 |
+
src.buffer = buf;
|
| 93 |
+
const bp = ctx.createBiquadFilter();
|
| 94 |
+
bp.type = "bandpass";
|
| 95 |
+
bp.frequency.value = 1800;
|
| 96 |
+
bp.Q.value = 1.4;
|
| 97 |
+
const g = ctx.createGain();
|
| 98 |
+
g.gain.value = 0.20;
|
| 99 |
+
src.connect(bp).connect(g).connect(ctx.destination);
|
| 100 |
+
const t0 = ctx.currentTime;
|
| 101 |
+
src.start(t0);
|
| 102 |
+
bp.frequency.setValueAtTime(1800, t0);
|
| 103 |
+
bp.frequency.exponentialRampToValueAtTime(600, t0 + dur);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// ── CHIME (on something positive resolving) — soft bell
|
| 107 |
+
function chime() {
|
| 108 |
+
if (!ensure() || muted) return;
|
| 109 |
+
const out = ctx.createGain();
|
| 110 |
+
out.gain.value = 0.12;
|
| 111 |
+
out.connect(ctx.destination);
|
| 112 |
+
const partials = [880, 1320, 1760];
|
| 113 |
+
for (let i = 0; i < partials.length; i++) {
|
| 114 |
+
const o = ctx.createOscillator();
|
| 115 |
+
const g = ctx.createGain();
|
| 116 |
+
o.type = "triangle";
|
| 117 |
+
o.frequency.value = partials[i];
|
| 118 |
+
o.connect(g).connect(out);
|
| 119 |
+
envelope(g, 0.005, 0.04, 0.08, 0.6 - i*0.1, 0.55 - i*0.15);
|
| 120 |
+
const t0 = ctx.currentTime;
|
| 121 |
+
o.start(t0);
|
| 122 |
+
o.stop(t0 + 0.7);
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
window.forgeSound = {
|
| 127 |
+
clang, thud, sizzle, chime,
|
| 128 |
+
setMuted(v) { muted = !!v; },
|
| 129 |
+
isMuted() { return muted; },
|
| 130 |
+
};
|
| 131 |
+
})();
|
frontend/styles.css
ADDED
|
@@ -0,0 +1,1407 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================================
|
| 2 |
+
FORGE — Compatibility Graph for ML Training Components
|
| 3 |
+
Aesthetic: dark coal + ember glow + tactile cards. D&D character sheet
|
| 4 |
+
meets a modern dev tool. Smoothness is the point.
|
| 5 |
+
============================================================================ */
|
| 6 |
+
|
| 7 |
+
:root {
|
| 8 |
+
/* Coal & steel base */
|
| 9 |
+
--coal-0: #07090c; /* pitch — under everything */
|
| 10 |
+
--coal-1: #0b0e13; /* canvas */
|
| 11 |
+
--coal-2: #11151c; /* rail / panel */
|
| 12 |
+
--coal-3: #161b25; /* card body */
|
| 13 |
+
--coal-4: #1d2330; /* card hover */
|
| 14 |
+
--coal-5: #262d3d; /* divider */
|
| 15 |
+
--steel-1: #2a3346; /* border on cards */
|
| 16 |
+
--steel-2: #3a455c; /* border bright */
|
| 17 |
+
--steel-3: #5a6580; /* dim icon */
|
| 18 |
+
|
| 19 |
+
/* Forge palette */
|
| 20 |
+
--ember-1: #ff6a1f; /* hot iron */
|
| 21 |
+
--ember-2: #ff8a3d; /* sparks */
|
| 22 |
+
--ember-3: #ffba5e; /* slag */
|
| 23 |
+
--ember-glow: rgba(255, 106, 31, 0.45);
|
| 24 |
+
/* Rarity / tier palette (Diablo-style loot)
|
| 25 |
+
T1 = legendary orange · T2 = rare blue · T3 = common grey */
|
| 26 |
+
--brass-1: #ff8a3d; /* tier 1 — legendary ember */
|
| 27 |
+
--brass-2: #5b9bd8; /* tier 2 — rare cobalt */
|
| 28 |
+
--brass-3: #9a9285; /* tier 3 — common bone */
|
| 29 |
+
--tier1-glow: rgba(255, 138, 61, 0.55);
|
| 30 |
+
--tier2-glow: rgba(91, 155, 216, 0.55);
|
| 31 |
+
--tier3-glow: rgba(154, 146, 133, 0.40);
|
| 32 |
+
--rust: #c8403c; /* blocked / breaks */
|
| 33 |
+
--rust-2: #8a2520;
|
| 34 |
+
--amber: #e0a13c; /* conditional */
|
| 35 |
+
--amber-2: #b07a1f;
|
| 36 |
+
--bone-1: #efeae0; /* text bright */
|
| 37 |
+
--bone-2: #cfc8b8;
|
| 38 |
+
--bone-3: #8c8676; /* dim */
|
| 39 |
+
--bone-4: #5e5a4f; /* very dim */
|
| 40 |
+
|
| 41 |
+
/* Type palette (component categories) */
|
| 42 |
+
--t-optimizer: #d4a444; /* brass */
|
| 43 |
+
--t-scheduler: #5fb3d4; /* sky steel */
|
| 44 |
+
--t-technique: #a78bfa; /* arcane violet */
|
| 45 |
+
--t-quantization: #ff6a1f; /* hot iron */
|
| 46 |
+
--t-architecture: #6ec7a0; /* patina */
|
| 47 |
+
--t-inference: #e16d8e; /* magenta steel */
|
| 48 |
+
|
| 49 |
+
/* Type glow (same hue, low alpha) */
|
| 50 |
+
--t-optimizer-glow: rgba(212, 164, 68, 0.55);
|
| 51 |
+
--t-scheduler-glow: rgba(95, 179, 212, 0.55);
|
| 52 |
+
--t-technique-glow: rgba(167, 139, 250, 0.55);
|
| 53 |
+
--t-quantization-glow: rgba(255, 106, 31, 0.55);
|
| 54 |
+
--t-architecture-glow: rgba(110, 199, 160, 0.55);
|
| 55 |
+
--t-inference-glow: rgba(225, 109, 142, 0.55);
|
| 56 |
+
|
| 57 |
+
/* Type families for the available "ready" state — desaturated steel-blue. */
|
| 58 |
+
--steel-glow: rgba(120, 156, 200, 0.18);
|
| 59 |
+
|
| 60 |
+
/* Type */
|
| 61 |
+
--f-display: "Cinzel", "Cormorant Garamond", Georgia, serif;
|
| 62 |
+
--f-serif: "Cormorant Garamond", Georgia, serif;
|
| 63 |
+
--f-ui: "Geist", "Space Grotesk", -apple-system, system-ui, sans-serif;
|
| 64 |
+
--f-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, Menlo, monospace;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
* { box-sizing: border-box; }
|
| 68 |
+
html, body { margin: 0; padding: 0; }
|
| 69 |
+
html, body, #root { height: 100%; overflow: hidden; }
|
| 70 |
+
|
| 71 |
+
body {
|
| 72 |
+
background: var(--coal-1);
|
| 73 |
+
color: var(--bone-1);
|
| 74 |
+
font-family: var(--f-ui);
|
| 75 |
+
font-size: 13px;
|
| 76 |
+
line-height: 1.45;
|
| 77 |
+
-webkit-font-smoothing: antialiased;
|
| 78 |
+
text-rendering: optimizeLegibility;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/* ── Atmosphere: subtle hex grid + corner ember bloom ─────────────────────── */
|
| 82 |
+
body::before {
|
| 83 |
+
content: "";
|
| 84 |
+
position: fixed; inset: 0;
|
| 85 |
+
background-image:
|
| 86 |
+
/* molten heart at bottom-center of viewport */
|
| 87 |
+
radial-gradient(circle at 50% 92%, rgba(255, 60, 30, 0.18) 0%, transparent 30%),
|
| 88 |
+
radial-gradient(circle at 50% 102%, rgba(180, 30, 10, 0.35) 0%, transparent 16%),
|
| 89 |
+
/* hearth-glow + cold corner */
|
| 90 |
+
radial-gradient(ellipse at 8% 85%, rgba(255, 106, 31, 0.10), transparent 45%),
|
| 91 |
+
radial-gradient(ellipse at 92% 10%, rgba(95, 100, 140, 0.06), transparent 55%),
|
| 92 |
+
/* deep vignette */
|
| 93 |
+
radial-gradient(ellipse at 50% 50%, transparent 30%, rgba(0,0,0,0.55) 110%);
|
| 94 |
+
pointer-events: none;
|
| 95 |
+
z-index: 0;
|
| 96 |
+
}
|
| 97 |
+
body::after {
|
| 98 |
+
content: "";
|
| 99 |
+
position: fixed; inset: 0;
|
| 100 |
+
/* painterly canvas grain — SVG turbulence baked into a data URI */
|
| 101 |
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='240' height='240'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch' seed='4'/><feColorMatrix values='0 0 0 0 0.55 0 0 0 0 0.35 0 0 0 0 0.15 0 0 0 0.20 0'/></filter><rect width='100%25' height='100%25' filter='url(%23n)'/></svg>");
|
| 102 |
+
background-size: 220px 220px;
|
| 103 |
+
opacity: 0.55;
|
| 104 |
+
mix-blend-mode: overlay;
|
| 105 |
+
pointer-events: none;
|
| 106 |
+
z-index: 0;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/* Light sparks falling in the background (cheap CSS) */
|
| 110 |
+
.sparks {
|
| 111 |
+
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
| 112 |
+
overflow: hidden;
|
| 113 |
+
}
|
| 114 |
+
.sparks span {
|
| 115 |
+
position: absolute;
|
| 116 |
+
width: 2px; height: 2px; border-radius: 50%;
|
| 117 |
+
background: var(--ember-2);
|
| 118 |
+
box-shadow: 0 0 6px var(--ember-1);
|
| 119 |
+
opacity: 0;
|
| 120 |
+
animation: spark-fall linear infinite;
|
| 121 |
+
}
|
| 122 |
+
@keyframes spark-fall {
|
| 123 |
+
0% { transform: translateY(-10vh); opacity: 0; }
|
| 124 |
+
15% { opacity: 0.7; }
|
| 125 |
+
85% { opacity: 0.4; }
|
| 126 |
+
100% { transform: translateY(110vh); opacity: 0; }
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
/* ── App shell ───────────────────────────────────────────────────────────── */
|
| 130 |
+
#root { position: relative; z-index: 1; }
|
| 131 |
+
|
| 132 |
+
.app {
|
| 133 |
+
display: grid;
|
| 134 |
+
grid-template-rows: 64px 1fr;
|
| 135 |
+
height: 100vh;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
/* ── Top bar ─────────────────────────────────────────────────────────────── */
|
| 139 |
+
.topbar {
|
| 140 |
+
display: flex;
|
| 141 |
+
align-items: center;
|
| 142 |
+
gap: 24px;
|
| 143 |
+
padding: 0 22px;
|
| 144 |
+
border-bottom: 1px solid #1a1208;
|
| 145 |
+
background:
|
| 146 |
+
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='200' height='200'><filter id='g'><feTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='2' stitchTiles='stitch' seed='2'/><feColorMatrix values='0 0 0 0 0.20 0 0 0 0 0.15 0 0 0 0 0.08 0 0 0 0.15 0'/></filter><rect width='100%25' height='100%25' filter='url(%23g)'/></svg>"),
|
| 147 |
+
linear-gradient(180deg, #1a1208 0%, #0f0a05 50%, #1a1208 100%);
|
| 148 |
+
background-blend-mode: overlay, normal;
|
| 149 |
+
position: relative;
|
| 150 |
+
z-index: 5;
|
| 151 |
+
box-shadow: 0 1px 0 rgba(212, 164, 68, 0.18), 0 4px 16px rgba(0,0,0,0.6);
|
| 152 |
+
}
|
| 153 |
+
.topbar::before {
|
| 154 |
+
content: "";
|
| 155 |
+
position: absolute; left: 0; right: 0; bottom: 0;
|
| 156 |
+
height: 1px;
|
| 157 |
+
background: linear-gradient(90deg, transparent 0%, rgba(212, 164, 68, 0.35) 50%, transparent 100%);
|
| 158 |
+
pointer-events: none;
|
| 159 |
+
}
|
| 160 |
+
.topbar .brand {
|
| 161 |
+
display: flex; align-items: center; gap: 14px;
|
| 162 |
+
}
|
| 163 |
+
.topbar .brand .mark {
|
| 164 |
+
width: 36px; height: 36px;
|
| 165 |
+
display: grid; place-items: center;
|
| 166 |
+
border-radius: 0;
|
| 167 |
+
background:
|
| 168 |
+
radial-gradient(circle at 50% 65%, #4a1c0a 0%, #0f0805 70%);
|
| 169 |
+
border: 1px solid #5a3010;
|
| 170 |
+
box-shadow:
|
| 171 |
+
inset 0 0 14px rgba(255, 106, 31, 0.55),
|
| 172 |
+
inset 0 -2px 0 rgba(255, 138, 61, 0.4),
|
| 173 |
+
0 0 12px rgba(255,106,31,0.25);
|
| 174 |
+
position: relative;
|
| 175 |
+
}
|
| 176 |
+
.topbar .brand .mark svg { display: block; }
|
| 177 |
+
.topbar .brand .name {
|
| 178 |
+
font-family: var(--f-display);
|
| 179 |
+
font-size: 26px;
|
| 180 |
+
font-weight: 600;
|
| 181 |
+
letter-spacing: 0.16em;
|
| 182 |
+
color: #f6e4c8;
|
| 183 |
+
text-shadow:
|
| 184 |
+
0 0 1px #000,
|
| 185 |
+
0 1px 0 #000,
|
| 186 |
+
0 0 10px rgba(255, 138, 61, 0.30);
|
| 187 |
+
}
|
| 188 |
+
.topbar .brand .tag {
|
| 189 |
+
font-family: var(--f-serif);
|
| 190 |
+
font-style: italic;
|
| 191 |
+
font-size: 11px;
|
| 192 |
+
letter-spacing: 0.04em;
|
| 193 |
+
color: var(--brass-2);
|
| 194 |
+
padding-top: 4px;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.topbar .crumbs {
|
| 198 |
+
display: flex; align-items: center; gap: 12px;
|
| 199 |
+
margin-left: auto;
|
| 200 |
+
font-family: var(--f-mono);
|
| 201 |
+
font-size: 11px;
|
| 202 |
+
color: var(--bone-3);
|
| 203 |
+
}
|
| 204 |
+
.topbar .crumbs .api-pill {
|
| 205 |
+
display: inline-flex; align-items: center; gap: 6px;
|
| 206 |
+
padding: 4px 8px;
|
| 207 |
+
border: 1px solid var(--coal-5);
|
| 208 |
+
border-radius: 4px;
|
| 209 |
+
background: var(--coal-2);
|
| 210 |
+
}
|
| 211 |
+
.topbar .crumbs .dot {
|
| 212 |
+
width: 6px; height: 6px; border-radius: 50%;
|
| 213 |
+
background: var(--t-architecture);
|
| 214 |
+
box-shadow: 0 0 6px var(--t-architecture);
|
| 215 |
+
animation: pulse-dot 2s ease-in-out infinite;
|
| 216 |
+
}
|
| 217 |
+
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.5; } }
|
| 218 |
+
|
| 219 |
+
.topbar .legend {
|
| 220 |
+
display: flex; align-items: center; gap: 18px;
|
| 221 |
+
font-family: var(--f-display);
|
| 222 |
+
font-size: 11px;
|
| 223 |
+
text-transform: uppercase;
|
| 224 |
+
letter-spacing: 0.18em;
|
| 225 |
+
color: var(--bone-3);
|
| 226 |
+
}
|
| 227 |
+
.topbar .legend .swatch {
|
| 228 |
+
display: inline-flex; align-items: center; gap: 7px;
|
| 229 |
+
}
|
| 230 |
+
.topbar .legend .sw {
|
| 231 |
+
width: 11px; height: 11px; border-radius: 50%;
|
| 232 |
+
border: 1px solid rgba(0,0,0,0.55);
|
| 233 |
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.2);
|
| 234 |
+
}
|
| 235 |
+
.topbar .legend .sw.t1 { background: radial-gradient(circle at 35% 30%, #ffd09a, #ff8a3d 50%, #6a2a08); box-shadow: 0 0 8px var(--tier1-glow), inset 0 1px 0 rgba(255,255,255,0.2); }
|
| 236 |
+
.topbar .legend .sw.t2 { background: radial-gradient(circle at 35% 30%, #b8d4f0, #5b9bd8 50%, #1c3a5a); box-shadow: 0 0 6px var(--tier2-glow), inset 0 1px 0 rgba(255,255,255,0.2); }
|
| 237 |
+
.topbar .legend .sw.t3 { background: radial-gradient(circle at 35% 30%, #cac4b6, #9a9285 50%, #3a3528); box-shadow: 0 0 3px var(--tier3-glow), inset 0 1px 0 rgba(255,255,255,0.2); }
|
| 238 |
+
|
| 239 |
+
/* ── Main grid ───────────────────────────────────────────────────────────── */
|
| 240 |
+
.main {
|
| 241 |
+
display: grid;
|
| 242 |
+
grid-template-columns: 280px 1fr 360px;
|
| 243 |
+
height: 100%;
|
| 244 |
+
min-height: 0;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/* ── Left rail: inventory ────────────────────────────────────────────────── */
|
| 248 |
+
.rail-left {
|
| 249 |
+
border-right: 1px solid var(--coal-5);
|
| 250 |
+
background:
|
| 251 |
+
/* parchment grain */
|
| 252 |
+
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='p'><feTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='2' stitchTiles='stitch' seed='7'/><feColorMatrix values='0 0 0 0 0.40 0 0 0 0 0.30 0 0 0 0 0.18 0 0 0 0.12 0'/></filter><rect width='100%25' height='100%25' filter='url(%23p)'/></svg>"),
|
| 253 |
+
linear-gradient(180deg, var(--coal-2) 0%, var(--coal-1) 100%);
|
| 254 |
+
background-blend-mode: overlay, normal;
|
| 255 |
+
display: flex; flex-direction: column;
|
| 256 |
+
min-height: 0;
|
| 257 |
+
position: relative;
|
| 258 |
+
}
|
| 259 |
+
.rail-left::before,
|
| 260 |
+
.rail-right::before {
|
| 261 |
+
/* hammered-iron seam down the inner edge */
|
| 262 |
+
content: "";
|
| 263 |
+
position: absolute; top: 0; bottom: 0;
|
| 264 |
+
width: 1px;
|
| 265 |
+
background: linear-gradient(180deg, transparent 0%, rgba(212,164,68,0.25) 15%, rgba(212,164,68,0.25) 85%, transparent 100%);
|
| 266 |
+
pointer-events: none;
|
| 267 |
+
}
|
| 268 |
+
.rail-left::before { right: 0; }
|
| 269 |
+
.rail-right::before { left: 0; }
|
| 270 |
+
.rail-left .rail-head {
|
| 271 |
+
padding: 16px 18px 12px;
|
| 272 |
+
border-bottom: 1px solid var(--coal-5);
|
| 273 |
+
position: relative;
|
| 274 |
+
}
|
| 275 |
+
.rail-left .rail-head::after,
|
| 276 |
+
.rail-right .rail-head::after {
|
| 277 |
+
/* filigree ornament under the section header */
|
| 278 |
+
content: "";
|
| 279 |
+
position: absolute; left: 18px; right: 18px; bottom: -1px;
|
| 280 |
+
height: 1px;
|
| 281 |
+
background:
|
| 282 |
+
linear-gradient(90deg, transparent 0%, rgba(212,164,68,0.5) 50%, transparent 100%);
|
| 283 |
+
}
|
| 284 |
+
.rail-left .rail-head h2,
|
| 285 |
+
.rail-right .rail-head h2 {
|
| 286 |
+
font-family: var(--f-display);
|
| 287 |
+
font-size: 19px;
|
| 288 |
+
font-weight: 500;
|
| 289 |
+
margin: 0 0 3px;
|
| 290 |
+
letter-spacing: 0.10em;
|
| 291 |
+
text-transform: uppercase;
|
| 292 |
+
color: var(--bone-1);
|
| 293 |
+
text-shadow: 0 1px 0 rgba(0,0,0,0.7);
|
| 294 |
+
}
|
| 295 |
+
.rail-left .rail-head .sub {
|
| 296 |
+
font-family: var(--f-serif);
|
| 297 |
+
font-style: italic;
|
| 298 |
+
font-size: 12px;
|
| 299 |
+
letter-spacing: 0.02em;
|
| 300 |
+
color: var(--bone-3);
|
| 301 |
+
}
|
| 302 |
+
.rail-left .filters {
|
| 303 |
+
display: flex; flex-wrap: wrap; gap: 5px;
|
| 304 |
+
padding: 12px 14px 10px;
|
| 305 |
+
border-bottom: 1px solid var(--coal-5);
|
| 306 |
+
}
|
| 307 |
+
.rail-left .filters .chip {
|
| 308 |
+
font-family: var(--f-display);
|
| 309 |
+
font-size: 10.5px;
|
| 310 |
+
letter-spacing: 0.10em;
|
| 311 |
+
text-transform: uppercase;
|
| 312 |
+
padding: 4px 9px;
|
| 313 |
+
border-radius: 0;
|
| 314 |
+
background: var(--coal-1);
|
| 315 |
+
border: 1px solid var(--coal-5);
|
| 316 |
+
border-top-color: rgba(255,255,255,0.05);
|
| 317 |
+
color: var(--bone-3);
|
| 318 |
+
cursor: pointer;
|
| 319 |
+
transition: all 140ms ease;
|
| 320 |
+
font-weight: 500;
|
| 321 |
+
}
|
| 322 |
+
.rail-left .filters .chip:hover { color: var(--bone-1); border-color: var(--brass-3); }
|
| 323 |
+
.rail-left .filters .chip.on {
|
| 324 |
+
color: var(--coal-0);
|
| 325 |
+
background: linear-gradient(180deg, var(--brass-1) 0%, #c46822 100%);
|
| 326 |
+
border-color: var(--brass-1);
|
| 327 |
+
box-shadow: 0 0 8px rgba(255, 138, 61, 0.4), inset 0 1px 0 rgba(255,255,255,0.2);
|
| 328 |
+
}
|
| 329 |
+
.rail-left .filters .chip .ct {
|
| 330 |
+
margin-left: 5px;
|
| 331 |
+
opacity: 0.7;
|
| 332 |
+
font-family: var(--f-mono);
|
| 333 |
+
font-size: 9.5px;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
.rail-left .search {
|
| 337 |
+
padding: 10px 14px;
|
| 338 |
+
border-bottom: 1px solid var(--coal-5);
|
| 339 |
+
}
|
| 340 |
+
.rail-left .search input {
|
| 341 |
+
width: 100%;
|
| 342 |
+
background: var(--coal-0);
|
| 343 |
+
border: 1px solid var(--coal-5);
|
| 344 |
+
border-top-color: rgba(0,0,0,0.6);
|
| 345 |
+
border-radius: 0;
|
| 346 |
+
padding: 7px 12px;
|
| 347 |
+
color: var(--bone-1);
|
| 348 |
+
font-family: var(--f-serif);
|
| 349 |
+
font-style: italic;
|
| 350 |
+
font-size: 13px;
|
| 351 |
+
outline: none;
|
| 352 |
+
box-shadow: inset 0 1px 3px rgba(0,0,0,0.5);
|
| 353 |
+
}
|
| 354 |
+
.rail-left .search input::placeholder { color: var(--bone-4); }
|
| 355 |
+
.rail-left .search input:focus { border-color: var(--brass-1); box-shadow: inset 0 1px 3px rgba(0,0,0,0.5), 0 0 0 1px rgba(255, 138, 61, 0.25); }
|
| 356 |
+
|
| 357 |
+
/* ── Loot-sidebar item cards ────────────────────────────────────────────── */
|
| 358 |
+
.rail-left .inv-list {
|
| 359 |
+
flex: 1; min-height: 0;
|
| 360 |
+
overflow-y: auto;
|
| 361 |
+
padding: 4px 10px 100px;
|
| 362 |
+
}
|
| 363 |
+
.inv-group-title {
|
| 364 |
+
font-family: var(--f-display);
|
| 365 |
+
font-size: 10px;
|
| 366 |
+
letter-spacing: 0.30em;
|
| 367 |
+
text-transform: uppercase;
|
| 368 |
+
color: var(--brass-2);
|
| 369 |
+
padding: 14px 4px 6px;
|
| 370 |
+
display: flex; align-items: center; gap: 8px;
|
| 371 |
+
}
|
| 372 |
+
.inv-group-title::before, .inv-group-title::after {
|
| 373 |
+
content: "";
|
| 374 |
+
flex: 1; height: 1px;
|
| 375 |
+
background: linear-gradient(90deg, transparent, rgba(91,155,216,0.35), transparent);
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.rail-left .inv-row {
|
| 379 |
+
display: grid;
|
| 380 |
+
grid-template-columns: 36px 1fr auto;
|
| 381 |
+
gap: 10px;
|
| 382 |
+
align-items: center;
|
| 383 |
+
padding: 8px 10px;
|
| 384 |
+
margin-bottom: 5px;
|
| 385 |
+
border-radius: 0;
|
| 386 |
+
cursor: pointer;
|
| 387 |
+
position: relative;
|
| 388 |
+
background:
|
| 389 |
+
linear-gradient(180deg, rgba(40,30,18,0.25) 0%, rgba(15,12,8,0.65) 100%);
|
| 390 |
+
border: 1px solid var(--coal-5);
|
| 391 |
+
border-left: 2px solid var(--rarity, var(--brass-3));
|
| 392 |
+
transition: background 160ms ease, border-color 160ms ease, box-shadow 200ms ease, transform 120ms ease;
|
| 393 |
+
}
|
| 394 |
+
/* rarity colors set per row (T1/T2/T3) */
|
| 395 |
+
.rail-left .inv-row.tier-1 { --rarity: var(--brass-1); }
|
| 396 |
+
.rail-left .inv-row.tier-2 { --rarity: var(--brass-2); }
|
| 397 |
+
.rail-left .inv-row.tier-3 { --rarity: var(--brass-3); }
|
| 398 |
+
|
| 399 |
+
.rail-left .inv-row::before {
|
| 400 |
+
/* glint along the top edge */
|
| 401 |
+
content: "";
|
| 402 |
+
position: absolute; left: 0; right: 0; top: 0; height: 1px;
|
| 403 |
+
background: linear-gradient(90deg, transparent, var(--rarity), transparent);
|
| 404 |
+
opacity: 0.35;
|
| 405 |
+
}
|
| 406 |
+
.rail-left .inv-row:hover {
|
| 407 |
+
background: linear-gradient(180deg, rgba(60,42,22,0.35) 0%, rgba(20,14,10,0.7) 100%);
|
| 408 |
+
border-color: var(--rarity);
|
| 409 |
+
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.04), 0 0 14px -2px var(--rarity);
|
| 410 |
+
transform: translateX(1px);
|
| 411 |
+
}
|
| 412 |
+
.rail-left .inv-row.selected {
|
| 413 |
+
background:
|
| 414 |
+
linear-gradient(180deg, rgba(255,106,31,0.18) 0%, rgba(60,18,8,0.55) 100%);
|
| 415 |
+
border-color: var(--ember-1);
|
| 416 |
+
border-left-color: var(--ember-1);
|
| 417 |
+
box-shadow:
|
| 418 |
+
inset 0 0 0 1px rgba(255,160,80,0.18),
|
| 419 |
+
0 0 18px -2px var(--ember-glow);
|
| 420 |
+
}
|
| 421 |
+
.rail-left .inv-row.selected::after {
|
| 422 |
+
content: "❖";
|
| 423 |
+
position: absolute; left: -8px; top: 50%;
|
| 424 |
+
transform: translateY(-50%);
|
| 425 |
+
color: var(--ember-1);
|
| 426 |
+
font-size: 10px;
|
| 427 |
+
text-shadow: 0 0 6px var(--ember-1);
|
| 428 |
+
}
|
| 429 |
+
.rail-left .inv-row.dim { opacity: 0.40; filter: grayscale(80%); }
|
| 430 |
+
.rail-left .inv-row.dim:hover { opacity: 0.95; filter: grayscale(20%); }
|
| 431 |
+
.rail-left .inv-row.blocked { opacity: 0.35; filter: grayscale(100%) brightness(0.65); }
|
| 432 |
+
.rail-left .inv-row.blocked .inv-name { color: var(--rust); text-decoration: line-through; text-decoration-color: rgba(200,64,60,0.5); }
|
| 433 |
+
|
| 434 |
+
/* item icon — ornate brass frame */
|
| 435 |
+
.rail-left .inv-row .type-ic {
|
| 436 |
+
width: 34px; height: 34px;
|
| 437 |
+
display: grid; place-items: center;
|
| 438 |
+
background:
|
| 439 |
+
radial-gradient(circle at 50% 40%, rgba(255,255,255,0.06) 0%, transparent 60%),
|
| 440 |
+
var(--coal-0);
|
| 441 |
+
border: 1px solid var(--rarity, var(--brass-3));
|
| 442 |
+
position: relative;
|
| 443 |
+
color: var(--type-color, var(--bone-3));
|
| 444 |
+
box-shadow: inset 0 0 8px rgba(0,0,0,0.6);
|
| 445 |
+
}
|
| 446 |
+
.rail-left .inv-row .type-ic::before,
|
| 447 |
+
.rail-left .inv-row .type-ic::after {
|
| 448 |
+
/* brass corner notches */
|
| 449 |
+
content: "";
|
| 450 |
+
position: absolute;
|
| 451 |
+
width: 4px; height: 4px;
|
| 452 |
+
border: 1px solid var(--rarity, var(--brass-3));
|
| 453 |
+
}
|
| 454 |
+
.rail-left .inv-row .type-ic::before { top: -1px; left: -1px; border-right: none; border-bottom: none; }
|
| 455 |
+
.rail-left .inv-row .type-ic::after { bottom: -1px; right: -1px; border-left: none; border-top: none; }
|
| 456 |
+
|
| 457 |
+
.rail-left .inv-row .inv-name {
|
| 458 |
+
font-family: var(--f-display);
|
| 459 |
+
font-size: 13px;
|
| 460 |
+
font-weight: 500;
|
| 461 |
+
color: var(--bone-1);
|
| 462 |
+
letter-spacing: 0.02em;
|
| 463 |
+
line-height: 1.2;
|
| 464 |
+
margin-bottom: 2px;
|
| 465 |
+
}
|
| 466 |
+
.rail-left .inv-row.tier-1 .inv-name { color: var(--brass-1); }
|
| 467 |
+
.rail-left .inv-row.tier-2 .inv-name { color: var(--brass-2); }
|
| 468 |
+
.rail-left .inv-row.tier-3 .inv-name { color: #c8c0b0; }
|
| 469 |
+
.rail-left .inv-row.selected .inv-name { color: #fff5e8; }
|
| 470 |
+
|
| 471 |
+
.rail-left .inv-row .inv-meta {
|
| 472 |
+
font-family: var(--f-serif);
|
| 473 |
+
font-style: italic;
|
| 474 |
+
font-size: 11px;
|
| 475 |
+
letter-spacing: 0.01em;
|
| 476 |
+
color: var(--bone-4);
|
| 477 |
+
}
|
| 478 |
+
.rail-left .inv-row .inv-meta .canon {
|
| 479 |
+
font-family: var(--f-mono);
|
| 480 |
+
font-style: normal;
|
| 481 |
+
font-size: 9.5px;
|
| 482 |
+
letter-spacing: 0;
|
| 483 |
+
opacity: 0.7;
|
| 484 |
+
}
|
| 485 |
+
.rail-left .inv-row .tier-pip {
|
| 486 |
+
width: 22px; height: 22px;
|
| 487 |
+
border-radius: 50%;
|
| 488 |
+
display: grid; place-items: center;
|
| 489 |
+
font-family: var(--f-display);
|
| 490 |
+
font-size: 10px;
|
| 491 |
+
font-weight: 600;
|
| 492 |
+
color: var(--coal-0);
|
| 493 |
+
border: 1px solid rgba(0,0,0,0.5);
|
| 494 |
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.18), 0 1px 2px rgba(0,0,0,0.6);
|
| 495 |
+
letter-spacing: 0;
|
| 496 |
+
}
|
| 497 |
+
.rail-left .inv-row .tier-pip.t1 { background: radial-gradient(circle at 35% 30%, #ffd09a, #ff8a3d 50%, #6a2a08); }
|
| 498 |
+
.rail-left .inv-row .tier-pip.t2 { background: radial-gradient(circle at 35% 30%, #b8d4f0, #5b9bd8 50%, #1c3a5a); }
|
| 499 |
+
.rail-left .inv-row .tier-pip.t3 { background: radial-gradient(circle at 35% 30%, #cac4b6, #9a9285 50%, #3a3528); }
|
| 500 |
+
|
| 501 |
+
/* ── Center: graph stage ─────────────────────────────────────────────────── */
|
| 502 |
+
.stage {
|
| 503 |
+
position: relative;
|
| 504 |
+
min-height: 0;
|
| 505 |
+
overflow: hidden;
|
| 506 |
+
background:
|
| 507 |
+
radial-gradient(ellipse at 50% 65%, rgba(255, 106, 31, 0.07) 0%, transparent 55%),
|
| 508 |
+
var(--coal-1);
|
| 509 |
+
}
|
| 510 |
+
.stage svg.graph {
|
| 511 |
+
position: absolute; inset: 0;
|
| 512 |
+
width: 100%; height: 100%;
|
| 513 |
+
cursor: grab;
|
| 514 |
+
user-select: none;
|
| 515 |
+
}
|
| 516 |
+
.stage svg.graph:active { cursor: grabbing; }
|
| 517 |
+
|
| 518 |
+
/* anvil-mark watermark at bottom center of stage */
|
| 519 |
+
.stage .anvil-watermark {
|
| 520 |
+
position: absolute;
|
| 521 |
+
left: 50%; bottom: 18px;
|
| 522 |
+
transform: translateX(-50%);
|
| 523 |
+
font-family: var(--f-mono);
|
| 524 |
+
font-size: 9px;
|
| 525 |
+
text-transform: uppercase;
|
| 526 |
+
letter-spacing: 0.4em;
|
| 527 |
+
color: var(--bone-4);
|
| 528 |
+
opacity: 0.5;
|
| 529 |
+
pointer-events: none;
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
/* Empty hint overlay */
|
| 533 |
+
.stage .stage-hint {
|
| 534 |
+
position: absolute;
|
| 535 |
+
left: 50%; top: 26%;
|
| 536 |
+
transform: translateX(-50%);
|
| 537 |
+
text-align: center;
|
| 538 |
+
pointer-events: none;
|
| 539 |
+
z-index: 2;
|
| 540 |
+
max-width: 380px;
|
| 541 |
+
background: none;
|
| 542 |
+
padding: 0;
|
| 543 |
+
opacity: 0.6;
|
| 544 |
+
}
|
| 545 |
+
.stage .stage-hint .ring {
|
| 546 |
+
width: 40px; height: 40px;
|
| 547 |
+
border: 1px dashed rgba(255,255,255,0.10);
|
| 548 |
+
border-radius: 50%;
|
| 549 |
+
margin: 0 auto 10px;
|
| 550 |
+
position: relative;
|
| 551 |
+
animation: gentle-rotate 40s linear infinite;
|
| 552 |
+
}
|
| 553 |
+
@keyframes gentle-rotate { to { transform: rotate(360deg); } }
|
| 554 |
+
.stage .stage-hint .ring::before, .stage .stage-hint .ring::after {
|
| 555 |
+
content: ""; position: absolute; inset: 12px;
|
| 556 |
+
border: 1px dashed rgba(255,255,255,0.05);
|
| 557 |
+
border-radius: 50%;
|
| 558 |
+
}
|
| 559 |
+
.stage .stage-hint .ring::after { inset: 24px; border-style: dotted; }
|
| 560 |
+
.stage .stage-hint h3 {
|
| 561 |
+
font-family: var(--f-display);
|
| 562 |
+
font-size: 17px;
|
| 563 |
+
font-weight: 400;
|
| 564 |
+
margin: 0 0 4px;
|
| 565 |
+
letter-spacing: 0.01em;
|
| 566 |
+
color: var(--bone-2);
|
| 567 |
+
}
|
| 568 |
+
.stage .stage-hint p {
|
| 569 |
+
font-family: var(--f-mono);
|
| 570 |
+
font-size: 11px;
|
| 571 |
+
letter-spacing: 0.04em;
|
| 572 |
+
color: var(--bone-3);
|
| 573 |
+
margin: 0;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
/* Stage chrome: controls in the corners */
|
| 577 |
+
.stage .stage-controls {
|
| 578 |
+
position: absolute; top: 12px; left: 12px;
|
| 579 |
+
display: flex; flex-direction: column; gap: 6px;
|
| 580 |
+
z-index: 4;
|
| 581 |
+
}
|
| 582 |
+
.stage .stage-controls .ctrl {
|
| 583 |
+
width: 32px; height: 32px;
|
| 584 |
+
display: grid; place-items: center;
|
| 585 |
+
background: var(--coal-2);
|
| 586 |
+
border: 1px solid var(--coal-5);
|
| 587 |
+
border-radius: 4px;
|
| 588 |
+
color: var(--bone-3);
|
| 589 |
+
cursor: pointer;
|
| 590 |
+
transition: all 120ms ease;
|
| 591 |
+
}
|
| 592 |
+
.stage .stage-controls .ctrl:hover {
|
| 593 |
+
color: var(--bone-1);
|
| 594 |
+
border-color: var(--steel-2);
|
| 595 |
+
background: var(--coal-3);
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
.stage .stage-toolbar {
|
| 599 |
+
position: absolute; top: 12px; right: 12px;
|
| 600 |
+
display: flex; gap: 6px;
|
| 601 |
+
z-index: 4;
|
| 602 |
+
font-family: var(--f-mono);
|
| 603 |
+
font-size: 10px;
|
| 604 |
+
text-transform: uppercase;
|
| 605 |
+
letter-spacing: 0.16em;
|
| 606 |
+
}
|
| 607 |
+
.stage .stage-toolbar .pill {
|
| 608 |
+
padding: 5px 10px;
|
| 609 |
+
background: var(--coal-2);
|
| 610 |
+
border: 1px solid var(--coal-5);
|
| 611 |
+
border-radius: 3px;
|
| 612 |
+
color: var(--bone-3);
|
| 613 |
+
display: inline-flex; align-items: center; gap: 6px;
|
| 614 |
+
}
|
| 615 |
+
.stage .stage-toolbar .pill .dot {
|
| 616 |
+
width: 6px; height: 6px; border-radius: 50%;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
/* ── Node visuals — arcane brass-framed plaques ──────────────────────────── */
|
| 620 |
+
g.node { cursor: pointer; }
|
| 621 |
+
g.node * { transition: opacity 240ms ease, fill 220ms ease, stroke 220ms ease, filter 240ms ease; }
|
| 622 |
+
|
| 623 |
+
/* outer halo — only visible on hover/selected/conditional */
|
| 624 |
+
g.node .node-aura {
|
| 625 |
+
fill: none;
|
| 626 |
+
stroke: none;
|
| 627 |
+
pointer-events: none;
|
| 628 |
+
opacity: 0;
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
/* the stone plate sitting behind the brass frame */
|
| 632 |
+
g.node .node-plate {
|
| 633 |
+
fill: var(--coal-2);
|
| 634 |
+
stroke: rgba(0,0,0,0.5);
|
| 635 |
+
stroke-width: 1.5;
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
/* the gem behind the icon — type-colored glow */
|
| 639 |
+
g.node .node-gem {
|
| 640 |
+
fill: var(--type-color);
|
| 641 |
+
opacity: 0.18;
|
| 642 |
+
filter: blur(2px);
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
/* the ornate brass frame — outer + inner stroke for a beveled look */
|
| 646 |
+
g.node .node-frame-outer { fill: none; stroke: #2a2017; stroke-width: 2.2; }
|
| 647 |
+
g.node .node-frame { fill: none; stroke: #6b5026; stroke-width: 1.2; }
|
| 648 |
+
g.node .node-frame-inner { fill: none; stroke: rgba(255,200,120,0.18); stroke-width: 0.7; }
|
| 649 |
+
|
| 650 |
+
/* corner studs — tiny brass rivets */
|
| 651 |
+
g.node .node-stud {
|
| 652 |
+
fill: #8a6a32;
|
| 653 |
+
stroke: #2a2017;
|
| 654 |
+
stroke-width: 0.6;
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
/* the icon glyph */
|
| 658 |
+
g.node .node-icon {
|
| 659 |
+
fill: none;
|
| 660 |
+
stroke-width: 1.6;
|
| 661 |
+
stroke-linecap: round;
|
| 662 |
+
stroke-linejoin: round;
|
| 663 |
+
pointer-events: none;
|
| 664 |
+
stroke: var(--type-color, var(--bone-2));
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
/* the name on its etched ribbon below */
|
| 668 |
+
g.node .node-ribbon {
|
| 669 |
+
fill: rgba(11,14,19,0.85);
|
| 670 |
+
stroke: rgba(107, 80, 38, 0.7);
|
| 671 |
+
stroke-width: 0.7;
|
| 672 |
+
}
|
| 673 |
+
g.node .node-name {
|
| 674 |
+
fill: var(--bone-1);
|
| 675 |
+
font-family: var(--f-display);
|
| 676 |
+
font-size: 10.5px;
|
| 677 |
+
font-weight: 500;
|
| 678 |
+
letter-spacing: 0.04em;
|
| 679 |
+
text-anchor: middle;
|
| 680 |
+
pointer-events: none;
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
/* tier wax-seal medallion top-right */
|
| 684 |
+
g.node .node-seal {
|
| 685 |
+
pointer-events: none;
|
| 686 |
+
}
|
| 687 |
+
g.node .node-seal-disc {
|
| 688 |
+
stroke: #1a1208;
|
| 689 |
+
stroke-width: 1;
|
| 690 |
+
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.7));
|
| 691 |
+
}
|
| 692 |
+
g.node .node-seal text {
|
| 693 |
+
font-family: var(--f-display);
|
| 694 |
+
font-size: 7.5px;
|
| 695 |
+
font-weight: 700;
|
| 696 |
+
text-anchor: middle;
|
| 697 |
+
fill: #1a1208;
|
| 698 |
+
letter-spacing: 0.04em;
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
/* ─── STATE: available ─── cool steel, ready to be set on the anvil */
|
| 702 |
+
g.node.s-available .node-plate { fill: var(--coal-2); }
|
| 703 |
+
g.node.s-available .node-icon { opacity: 0.9; }
|
| 704 |
+
g.node.s-available:hover .node-plate { fill: var(--coal-3); }
|
| 705 |
+
g.node.s-available:hover .node-aura {
|
| 706 |
+
opacity: 0.6;
|
| 707 |
+
stroke: var(--type-color);
|
| 708 |
+
stroke-width: 1.2;
|
| 709 |
+
fill: none;
|
| 710 |
+
}
|
| 711 |
+
g.node.s-available:hover .node-gem { opacity: 0.32; }
|
| 712 |
+
g.node.s-available:hover .node-frame { stroke: #9a7434; }
|
| 713 |
+
|
| 714 |
+
/* ─── STATE: selected ─── molten, just-forged, red-hot ─────────────────── */
|
| 715 |
+
g.node.s-selected .node-plate {
|
| 716 |
+
fill: url(#grad-molten-plate);
|
| 717 |
+
filter: url(#node-ember-glow);
|
| 718 |
+
}
|
| 719 |
+
g.node.s-selected .node-frame { stroke: #d4a049; stroke-width: 1.5; }
|
| 720 |
+
g.node.s-selected .node-frame-outer { stroke: #3a1a08; }
|
| 721 |
+
g.node.s-selected .node-frame-inner { stroke: rgba(255,200,120,0.45); }
|
| 722 |
+
g.node.s-selected .node-stud { fill: #d4a049; stroke: #3a1a08; }
|
| 723 |
+
g.node.s-selected .node-gem { fill: var(--ember-1); opacity: 0.55; }
|
| 724 |
+
g.node.s-selected .node-icon { stroke: #fff5e8; opacity: 1; filter: drop-shadow(0 0 3px var(--ember-1)); }
|
| 725 |
+
g.node.s-selected .node-name { fill: #fff5e8; }
|
| 726 |
+
g.node.s-selected .node-ribbon { stroke: #d4a049; fill: rgba(40,15,5,0.92); }
|
| 727 |
+
g.node.s-selected .node-aura {
|
| 728 |
+
opacity: 1;
|
| 729 |
+
stroke: var(--ember-1);
|
| 730 |
+
stroke-width: 1.5;
|
| 731 |
+
fill: none;
|
| 732 |
+
animation: pulse-ring 2.6s ease-in-out infinite;
|
| 733 |
+
}
|
| 734 |
+
@keyframes pulse-ring {
|
| 735 |
+
0%,100% { opacity: 0.85; stroke-width: 1.5; }
|
| 736 |
+
50% { opacity: 0.25; stroke-width: 4; }
|
| 737 |
+
}
|
| 738 |
+
|
| 739 |
+
/* ─── STATE: conditional ─── cold ash, dimmed, dashed amber rim ────────── */
|
| 740 |
+
g.node.s-conditional .node-plate {
|
| 741 |
+
fill: var(--coal-1);
|
| 742 |
+
filter: grayscale(85%) brightness(0.7);
|
| 743 |
+
}
|
| 744 |
+
g.node.s-conditional .node-frame { stroke: #3a2f1c; }
|
| 745 |
+
g.node.s-conditional .node-frame-outer { stroke: #1a1208; }
|
| 746 |
+
g.node.s-conditional .node-frame-inner { stroke: rgba(255,200,120,0.05); }
|
| 747 |
+
g.node.s-conditional .node-stud { fill: #3a2f1c; }
|
| 748 |
+
g.node.s-conditional .node-gem { opacity: 0.05; }
|
| 749 |
+
g.node.s-conditional .node-icon { stroke: var(--bone-4); opacity: 0.5; }
|
| 750 |
+
g.node.s-conditional .node-name { fill: var(--bone-4); }
|
| 751 |
+
g.node.s-conditional .node-ribbon { fill: rgba(11,14,19,0.6); stroke: rgba(60,60,60,0.4); }
|
| 752 |
+
g.node.s-conditional .node-aura {
|
| 753 |
+
opacity: 0.7;
|
| 754 |
+
stroke: var(--amber);
|
| 755 |
+
stroke-width: 1;
|
| 756 |
+
stroke-dasharray: 3 4;
|
| 757 |
+
fill: none;
|
| 758 |
+
animation: spin-dashes 22s linear infinite;
|
| 759 |
+
}
|
| 760 |
+
@keyframes spin-dashes { to { stroke-dashoffset: -100; } }
|
| 761 |
+
g.node.s-conditional:hover .node-plate { filter: grayscale(50%) brightness(0.95); }
|
| 762 |
+
g.node.s-conditional:hover .node-icon { opacity: 0.85; }
|
| 763 |
+
g.node.s-conditional:hover .node-name { fill: var(--bone-2); }
|
| 764 |
+
|
| 765 |
+
/* ─── STATE: blocked ─── rusted, chained ───────────────────────────────── */
|
| 766 |
+
g.node.s-blocked .node-plate {
|
| 767 |
+
fill: var(--coal-1);
|
| 768 |
+
filter: grayscale(100%) brightness(0.55);
|
| 769 |
+
}
|
| 770 |
+
g.node.s-blocked .node-frame { stroke: var(--rust-2); }
|
| 771 |
+
g.node.s-blocked .node-frame-outer { stroke: #1a0808; }
|
| 772 |
+
g.node.s-blocked .node-frame-inner { stroke: rgba(200,64,60,0.2); }
|
| 773 |
+
g.node.s-blocked .node-stud { fill: var(--rust-2); }
|
| 774 |
+
g.node.s-blocked .node-gem { opacity: 0; }
|
| 775 |
+
g.node.s-blocked .node-icon { stroke: var(--rust); opacity: 0.45; }
|
| 776 |
+
g.node.s-blocked .node-name { fill: var(--rust); opacity: 0.7; }
|
| 777 |
+
g.node.s-blocked .node-ribbon { fill: rgba(15,5,5,0.85); stroke: rgba(138,37,32,0.5); }
|
| 778 |
+
g.node.s-blocked .node-aura {
|
| 779 |
+
opacity: 0.85;
|
| 780 |
+
stroke: var(--rust);
|
| 781 |
+
stroke-width: 1.2;
|
| 782 |
+
fill: none;
|
| 783 |
+
}
|
| 784 |
+
g.node.s-blocked:hover .node-plate { filter: grayscale(60%) brightness(0.85); }
|
| 785 |
+
g.node.s-blocked:hover .node-icon { opacity: 0.85; }
|
| 786 |
+
|
| 787 |
+
/* Lock chain + padlock (drawn in JSX) */
|
| 788 |
+
g.node .node-lock { opacity: 0; }
|
| 789 |
+
g.node.s-blocked .node-lock { opacity: 1; }
|
| 790 |
+
g.node .node-lock .lock-body { fill: var(--rust-2); stroke: var(--bone-3); stroke-width: 0.6; }
|
| 791 |
+
g.node .node-lock .lock-shackle { fill: none; stroke: var(--bone-3); stroke-width: 1.1; stroke-linecap: round; }
|
| 792 |
+
g.node .node-lock .lock-keyhole { fill: var(--coal-0); }
|
| 793 |
+
|
| 794 |
+
/* "needs X" tag for conditional */
|
| 795 |
+
g.node .needs-chip { opacity: 0; pointer-events: none; }
|
| 796 |
+
g.node.s-conditional .needs-chip { opacity: 1; }
|
| 797 |
+
g.node .needs-chip rect {
|
| 798 |
+
fill: rgba(40, 26, 8, 0.9);
|
| 799 |
+
stroke: var(--amber);
|
| 800 |
+
stroke-width: 0.7;
|
| 801 |
+
}
|
| 802 |
+
g.node .needs-chip .chip-pin {
|
| 803 |
+
fill: var(--amber);
|
| 804 |
+
}
|
| 805 |
+
g.node .needs-chip text {
|
| 806 |
+
fill: var(--amber);
|
| 807 |
+
font-family: var(--f-mono);
|
| 808 |
+
font-size: 7.5px;
|
| 809 |
+
font-weight: 500;
|
| 810 |
+
text-transform: uppercase;
|
| 811 |
+
letter-spacing: 0.12em;
|
| 812 |
+
text-anchor: middle;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
/* ── Edges — molten veins / etched-iron chains ──────────────────────────── */
|
| 816 |
+
g.edge path { fill: none; transition: stroke 220ms ease, opacity 220ms ease, stroke-width 220ms ease; }
|
| 817 |
+
g.edge path.edge-glow { display: none; }
|
| 818 |
+
g.edge path.edge-path { stroke-linecap: round; }
|
| 819 |
+
|
| 820 |
+
/* base relations */
|
| 821 |
+
g.edge.compatible .edge-path { stroke: rgba(212, 164, 68, 0.20); stroke-width: 1.1; }
|
| 822 |
+
g.edge.requires .edge-path { stroke: rgba(255, 160, 60, 0.50); stroke-width: 1.4; }
|
| 823 |
+
g.edge.conditional .edge-path { stroke: rgba(224, 161, 60, 0.45); stroke-width: 1.2; stroke-dasharray: 4 5; }
|
| 824 |
+
g.edge.degrades .edge-path { stroke: rgba(225, 109, 142, 0.40); stroke-width: 1; stroke-dasharray: 1 4; }
|
| 825 |
+
g.edge.breaks .edge-path {
|
| 826 |
+
stroke: var(--rust);
|
| 827 |
+
stroke-width: 1.6;
|
| 828 |
+
opacity: 0.95;
|
| 829 |
+
filter: drop-shadow(0 0 4px rgba(200,64,60,0.7));
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
/* hot edges (incident to a selection) — molten ember overlay */
|
| 833 |
+
g.edge.hot path.edge-glow {
|
| 834 |
+
display: inline;
|
| 835 |
+
fill: none;
|
| 836 |
+
stroke-linecap: round;
|
| 837 |
+
}
|
| 838 |
+
g.edge.hot.requires .edge-path {
|
| 839 |
+
stroke: var(--ember-2);
|
| 840 |
+
stroke-width: 2.2;
|
| 841 |
+
stroke-dasharray: 8 6;
|
| 842 |
+
filter: drop-shadow(0 0 6px var(--ember-glow));
|
| 843 |
+
animation: edge-march 1.2s linear infinite;
|
| 844 |
+
}
|
| 845 |
+
g.edge.hot.requires .edge-glow {
|
| 846 |
+
stroke: rgba(255, 138, 61, 0.35);
|
| 847 |
+
stroke-width: 7;
|
| 848 |
+
filter: blur(2.2px);
|
| 849 |
+
}
|
| 850 |
+
@keyframes edge-march { to { stroke-dashoffset: -28; } }
|
| 851 |
+
|
| 852 |
+
g.edge.hot.breaks .edge-path {
|
| 853 |
+
stroke: var(--rust);
|
| 854 |
+
stroke-width: 2.4;
|
| 855 |
+
filter: drop-shadow(0 0 7px rgba(200,64,60,1));
|
| 856 |
+
animation: edge-pulse-rust 1.6s ease-in-out infinite;
|
| 857 |
+
}
|
| 858 |
+
g.edge.hot.breaks .edge-glow {
|
| 859 |
+
stroke: rgba(200, 64, 60, 0.45);
|
| 860 |
+
stroke-width: 9;
|
| 861 |
+
filter: blur(3px);
|
| 862 |
+
animation: edge-pulse-rust 1.6s ease-in-out infinite;
|
| 863 |
+
}
|
| 864 |
+
@keyframes edge-pulse-rust {
|
| 865 |
+
0%,100% { opacity: 0.65; }
|
| 866 |
+
50% { opacity: 1; }
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
g.edge.hot.conditional .edge-path,
|
| 870 |
+
g.edge.hot.compatible .edge-path {
|
| 871 |
+
stroke: rgba(255, 191, 90, 0.85);
|
| 872 |
+
stroke-width: 1.8;
|
| 873 |
+
filter: drop-shadow(0 0 4px rgba(255, 138, 61, 0.5));
|
| 874 |
+
}
|
| 875 |
+
g.edge.hot.conditional .edge-glow,
|
| 876 |
+
g.edge.hot.compatible .edge-glow {
|
| 877 |
+
stroke: rgba(255, 138, 61, 0.30);
|
| 878 |
+
stroke-width: 5;
|
| 879 |
+
filter: blur(2px);
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
/* faded edges */
|
| 883 |
+
g.edge.faded .edge-path { opacity: 0.10; }
|
| 884 |
+
g.edge.faded .edge-glow { display: none; }
|
| 885 |
+
|
| 886 |
+
/* ── Right rail: recipe card ─────────────────────────────────────────────── */
|
| 887 |
+
.rail-right {
|
| 888 |
+
border-left: 1px solid var(--coal-5);
|
| 889 |
+
background:
|
| 890 |
+
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='p'><feTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='2' stitchTiles='stitch' seed='12'/><feColorMatrix values='0 0 0 0 0.40 0 0 0 0 0.30 0 0 0 0 0.18 0 0 0 0.12 0'/></filter><rect width='100%25' height='100%25' filter='url(%23p)'/></svg>"),
|
| 891 |
+
linear-gradient(180deg, var(--coal-2) 0%, var(--coal-1) 100%);
|
| 892 |
+
background-blend-mode: overlay, normal;
|
| 893 |
+
display: flex; flex-direction: column;
|
| 894 |
+
min-height: 0;
|
| 895 |
+
position: relative;
|
| 896 |
+
}
|
| 897 |
+
.rail-right .rail-head {
|
| 898 |
+
padding: 16px 18px 12px;
|
| 899 |
+
border-bottom: 1px solid var(--coal-5);
|
| 900 |
+
display: flex; align-items: flex-end; justify-content: space-between; gap: 8px;
|
| 901 |
+
position: relative;
|
| 902 |
+
}
|
| 903 |
+
.rail-right .rail-head .sub {
|
| 904 |
+
font-family: var(--f-serif);
|
| 905 |
+
font-style: italic;
|
| 906 |
+
font-size: 12px;
|
| 907 |
+
letter-spacing: 0.02em;
|
| 908 |
+
color: var(--bone-3);
|
| 909 |
+
}
|
| 910 |
+
.rail-right .recipe-status {
|
| 911 |
+
font-family: var(--f-display);
|
| 912 |
+
font-size: 10px;
|
| 913 |
+
text-transform: uppercase;
|
| 914 |
+
letter-spacing: 0.22em;
|
| 915 |
+
padding: 4px 10px;
|
| 916 |
+
border-radius: 0;
|
| 917 |
+
font-weight: 500;
|
| 918 |
+
text-shadow: 0 1px 0 rgba(0,0,0,0.5);
|
| 919 |
+
}
|
| 920 |
+
.recipe-status.valid { background: rgba(110,199,160,0.10); color: var(--t-architecture); border: 1px solid rgba(110,199,160,0.4); }
|
| 921 |
+
.recipe-status.warn { background: rgba(224,161,60,0.10); color: var(--amber); border: 1px solid rgba(224,161,60,0.4); }
|
| 922 |
+
.recipe-status.broken { background: rgba(200,64,60,0.12); color: var(--rust); border: 1px solid rgba(200,64,60,0.45); }
|
| 923 |
+
.recipe-status.empty { background: var(--coal-1); color: var(--bone-4); border: 1px solid var(--coal-5); }
|
| 924 |
+
|
| 925 |
+
.rail-right .recipe-body {
|
| 926 |
+
flex: 1; min-height: 0;
|
| 927 |
+
overflow-y: auto;
|
| 928 |
+
}
|
| 929 |
+
|
| 930 |
+
.section {
|
| 931 |
+
padding: 16px 18px;
|
| 932 |
+
border-bottom: 1px solid var(--coal-5);
|
| 933 |
+
position: relative;
|
| 934 |
+
}
|
| 935 |
+
.section + .section::before {
|
| 936 |
+
content: "❖";
|
| 937 |
+
position: absolute; left: 50%; top: -8px;
|
| 938 |
+
transform: translateX(-50%);
|
| 939 |
+
font-size: 9px;
|
| 940 |
+
color: var(--brass-2);
|
| 941 |
+
background: var(--coal-1);
|
| 942 |
+
padding: 0 6px;
|
| 943 |
+
letter-spacing: 0.6em;
|
| 944 |
+
opacity: 0.7;
|
| 945 |
+
}
|
| 946 |
+
.section h3 {
|
| 947 |
+
font-family: var(--f-display);
|
| 948 |
+
font-size: 11px;
|
| 949 |
+
letter-spacing: 0.24em;
|
| 950 |
+
text-transform: uppercase;
|
| 951 |
+
color: var(--brass-2);
|
| 952 |
+
font-weight: 500;
|
| 953 |
+
margin: 0 0 12px;
|
| 954 |
+
display: flex; align-items: center; gap: 10px;
|
| 955 |
+
text-shadow: 0 1px 0 rgba(0,0,0,0.5);
|
| 956 |
+
}
|
| 957 |
+
.section h3::before {
|
| 958 |
+
content: "";
|
| 959 |
+
width: 14px; height: 1px;
|
| 960 |
+
background: linear-gradient(90deg, transparent, var(--brass-2));
|
| 961 |
+
}
|
| 962 |
+
.section h3::after {
|
| 963 |
+
content: "";
|
| 964 |
+
flex: 1; height: 1px;
|
| 965 |
+
background: linear-gradient(90deg, rgba(212,164,68,0.25), transparent);
|
| 966 |
+
}
|
| 967 |
+
.section h3 .ct {
|
| 968 |
+
display: inline-grid; place-items: center;
|
| 969 |
+
min-width: 20px; height: 18px;
|
| 970 |
+
padding: 0 6px;
|
| 971 |
+
background: var(--coal-0);
|
| 972 |
+
border: 1px solid var(--brass-3);
|
| 973 |
+
border-radius: 0;
|
| 974 |
+
font-family: var(--f-mono);
|
| 975 |
+
font-size: 9px;
|
| 976 |
+
color: var(--brass-1);
|
| 977 |
+
letter-spacing: 0;
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
/* selected build chips — equipped item slots */
|
| 981 |
+
.build-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
| 982 |
+
.build-chip {
|
| 983 |
+
display: inline-flex; align-items: center; gap: 7px;
|
| 984 |
+
padding: 6px 8px 6px 6px;
|
| 985 |
+
background:
|
| 986 |
+
linear-gradient(180deg, rgba(60,30,8,0.4) 0%, rgba(15,8,4,0.7) 100%);
|
| 987 |
+
border: 1px solid var(--ember-1);
|
| 988 |
+
border-radius: 0;
|
| 989 |
+
font-family: var(--f-display);
|
| 990 |
+
font-size: 12.5px;
|
| 991 |
+
letter-spacing: 0.02em;
|
| 992 |
+
color: #fff5e8;
|
| 993 |
+
cursor: pointer;
|
| 994 |
+
position: relative;
|
| 995 |
+
box-shadow:
|
| 996 |
+
inset 0 0 8px rgba(255, 106, 31, 0.18),
|
| 997 |
+
0 0 6px var(--ember-glow);
|
| 998 |
+
transition: transform 120ms ease, box-shadow 200ms ease;
|
| 999 |
+
}
|
| 1000 |
+
.build-chip:hover {
|
| 1001 |
+
transform: translateY(-1px);
|
| 1002 |
+
box-shadow: inset 0 0 10px rgba(255,138,61,0.25), 0 0 12px var(--ember-1);
|
| 1003 |
+
}
|
| 1004 |
+
.build-chip .dot {
|
| 1005 |
+
width: 9px; height: 9px;
|
| 1006 |
+
border-radius: 50%;
|
| 1007 |
+
border: 1px solid rgba(0,0,0,0.4);
|
| 1008 |
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.25);
|
| 1009 |
+
}
|
| 1010 |
+
.build-chip .x {
|
| 1011 |
+
color: var(--bone-4);
|
| 1012 |
+
margin-left: 3px;
|
| 1013 |
+
font-size: 14px;
|
| 1014 |
+
line-height: 1;
|
| 1015 |
+
font-family: var(--f-mono);
|
| 1016 |
+
}
|
| 1017 |
+
.build-chip:hover .x { color: var(--rust); }
|
| 1018 |
+
|
| 1019 |
+
/* conflict / unmet entries — scribed crafting warnings */
|
| 1020 |
+
.entry {
|
| 1021 |
+
position: relative;
|
| 1022 |
+
padding: 10px 12px 11px 14px;
|
| 1023 |
+
border-radius: 0;
|
| 1024 |
+
margin-bottom: 7px;
|
| 1025 |
+
background:
|
| 1026 |
+
linear-gradient(180deg, rgba(40,18,8,0.25) 0%, rgba(15,8,4,0.65) 100%);
|
| 1027 |
+
border: 1px solid rgba(60, 38, 18, 0.7);
|
| 1028 |
+
border-left-width: 2px;
|
| 1029 |
+
font-size: 12px;
|
| 1030 |
+
}
|
| 1031 |
+
.entry.conflict { border-left-color: var(--rust); box-shadow: 0 0 10px -2px rgba(200,64,60,0.25); }
|
| 1032 |
+
.entry.unmet { border-left-color: var(--amber); box-shadow: 0 0 10px -2px rgba(224,161,60,0.25); }
|
| 1033 |
+
.entry.warning { border-left-color: var(--t-inference); }
|
| 1034 |
+
.entry .title {
|
| 1035 |
+
font-family: var(--f-display);
|
| 1036 |
+
font-size: 13px;
|
| 1037 |
+
font-weight: 500;
|
| 1038 |
+
letter-spacing: 0.01em;
|
| 1039 |
+
margin-bottom: 5px;
|
| 1040 |
+
display: flex; align-items: center; gap: 6px;
|
| 1041 |
+
}
|
| 1042 |
+
.entry .title.conflict { color: var(--rust); }
|
| 1043 |
+
.entry .title.unmet { color: var(--amber); }
|
| 1044 |
+
.entry .title.warning { color: var(--t-inference); }
|
| 1045 |
+
.entry .fix {
|
| 1046 |
+
font-family: var(--f-serif);
|
| 1047 |
+
font-style: italic;
|
| 1048 |
+
font-size: 12.5px;
|
| 1049 |
+
line-height: 1.5;
|
| 1050 |
+
color: var(--bone-2);
|
| 1051 |
+
letter-spacing: 0.005em;
|
| 1052 |
+
}
|
| 1053 |
+
.entry .meta {
|
| 1054 |
+
margin-top: 7px;
|
| 1055 |
+
display: flex; align-items: center; gap: 8px;
|
| 1056 |
+
font-family: var(--f-mono);
|
| 1057 |
+
font-size: 9px;
|
| 1058 |
+
text-transform: uppercase;
|
| 1059 |
+
letter-spacing: 0.16em;
|
| 1060 |
+
color: var(--bone-4);
|
| 1061 |
+
}
|
| 1062 |
+
.entry .meta a { color: var(--bone-3); text-decoration: none; border-bottom: 1px dotted var(--bone-4); }
|
| 1063 |
+
.entry .meta a:hover { color: var(--ember-2); border-color: var(--ember-2); }
|
| 1064 |
+
.entry .add-btn {
|
| 1065 |
+
margin-top: 9px;
|
| 1066 |
+
font-family: var(--f-display);
|
| 1067 |
+
font-size: 11px;
|
| 1068 |
+
letter-spacing: 0.14em;
|
| 1069 |
+
text-transform: uppercase;
|
| 1070 |
+
padding: 6px 11px;
|
| 1071 |
+
background: linear-gradient(180deg, rgba(224,161,60,0.18) 0%, rgba(120,80,20,0.18) 100%);
|
| 1072 |
+
color: var(--amber);
|
| 1073 |
+
border: 1px solid var(--amber);
|
| 1074 |
+
border-radius: 0;
|
| 1075 |
+
cursor: pointer;
|
| 1076 |
+
display: inline-flex; align-items: center; gap: 7px;
|
| 1077 |
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.08), 0 0 8px rgba(224,161,60,0.18);
|
| 1078 |
+
}
|
| 1079 |
+
.entry .add-btn:hover {
|
| 1080 |
+
background: linear-gradient(180deg, rgba(224,161,60,0.30) 0%, rgba(120,80,20,0.30) 100%);
|
| 1081 |
+
color: #fff5e8;
|
| 1082 |
+
box-shadow: inset 0 1px 0 rgba(255,255,255,0.14), 0 0 14px var(--ember-glow);
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
/* scheduler picks table */
|
| 1086 |
+
.bench-table {
|
| 1087 |
+
display: grid;
|
| 1088 |
+
grid-template-columns: 1fr auto auto;
|
| 1089 |
+
font-family: var(--f-mono);
|
| 1090 |
+
font-size: 11px;
|
| 1091 |
+
border: 1px solid var(--coal-5);
|
| 1092 |
+
border-radius: 4px;
|
| 1093 |
+
overflow: hidden;
|
| 1094 |
+
}
|
| 1095 |
+
.bench-table .br {
|
| 1096 |
+
display: contents;
|
| 1097 |
+
}
|
| 1098 |
+
.bench-table .br > * {
|
| 1099 |
+
padding: 7px 10px;
|
| 1100 |
+
border-bottom: 1px solid var(--coal-5);
|
| 1101 |
+
background: var(--coal-2);
|
| 1102 |
+
color: var(--bone-2);
|
| 1103 |
+
}
|
| 1104 |
+
.bench-table .br.head > * {
|
| 1105 |
+
background: var(--coal-3);
|
| 1106 |
+
color: var(--bone-4);
|
| 1107 |
+
font-size: 9px;
|
| 1108 |
+
text-transform: uppercase;
|
| 1109 |
+
letter-spacing: 0.16em;
|
| 1110 |
+
}
|
| 1111 |
+
.bench-table .br:last-child > * { border-bottom: none; }
|
| 1112 |
+
.bench-table .br > *:first-child { color: var(--bone-1); }
|
| 1113 |
+
.bench-table .br .val {
|
| 1114 |
+
text-align: right;
|
| 1115 |
+
font-variant-numeric: tabular-nums;
|
| 1116 |
+
}
|
| 1117 |
+
.bench-table .br.best > *:first-child::before {
|
| 1118 |
+
content: "★";
|
| 1119 |
+
color: var(--brass-1);
|
| 1120 |
+
margin-right: 6px;
|
| 1121 |
+
}
|
| 1122 |
+
.bench-table .br.warn .val { color: var(--rust); }
|
| 1123 |
+
|
| 1124 |
+
/* empty state */
|
| 1125 |
+
.empty-state {
|
| 1126 |
+
padding: 30px 18px;
|
| 1127 |
+
text-align: center;
|
| 1128 |
+
color: var(--bone-4);
|
| 1129 |
+
}
|
| 1130 |
+
.empty-state .glyph {
|
| 1131 |
+
width: 64px; height: 64px;
|
| 1132 |
+
margin: 0 auto 12px;
|
| 1133 |
+
display: grid; place-items: center;
|
| 1134 |
+
border-radius: 50%;
|
| 1135 |
+
border: 1px dashed var(--coal-5);
|
| 1136 |
+
color: var(--bone-4);
|
| 1137 |
+
}
|
| 1138 |
+
.empty-state .ttl {
|
| 1139 |
+
font-family: var(--f-display);
|
| 1140 |
+
font-size: 16px;
|
| 1141 |
+
color: var(--bone-2);
|
| 1142 |
+
margin-bottom: 4px;
|
| 1143 |
+
}
|
| 1144 |
+
.empty-state .sub {
|
| 1145 |
+
font-family: var(--f-mono);
|
| 1146 |
+
font-size: 10px;
|
| 1147 |
+
text-transform: uppercase;
|
| 1148 |
+
letter-spacing: 0.16em;
|
| 1149 |
+
}
|
| 1150 |
+
|
| 1151 |
+
/* ── Live Discoveries ticker (bottom-center floating) ────────────────────── */
|
| 1152 |
+
.ticker {
|
| 1153 |
+
position: fixed;
|
| 1154 |
+
bottom: 0;
|
| 1155 |
+
left: 50%;
|
| 1156 |
+
transform: translateX(-50%);
|
| 1157 |
+
width: min(600px, calc(100% - 660px));
|
| 1158 |
+
max-width: 600px;
|
| 1159 |
+
z-index: 10;
|
| 1160 |
+
pointer-events: none;
|
| 1161 |
+
padding-bottom: 14px;
|
| 1162 |
+
}
|
| 1163 |
+
.ticker::before {
|
| 1164 |
+
/* the hearth itself — a glowing mouth at the very bottom */
|
| 1165 |
+
content: "";
|
| 1166 |
+
position: absolute; left: 10%; right: 10%; bottom: 0;
|
| 1167 |
+
height: 14px;
|
| 1168 |
+
background:
|
| 1169 |
+
radial-gradient(ellipse at 50% 100%, rgba(255, 138, 61, 0.7) 0%, rgba(180, 30, 10, 0.5) 35%, transparent 70%);
|
| 1170 |
+
filter: blur(4px);
|
| 1171 |
+
pointer-events: none;
|
| 1172 |
+
}
|
| 1173 |
+
.ticker .ticker-head {
|
| 1174 |
+
display: flex; align-items: center; gap: 10px;
|
| 1175 |
+
font-family: var(--f-display);
|
| 1176 |
+
font-size: 10.5px;
|
| 1177 |
+
text-transform: uppercase;
|
| 1178 |
+
letter-spacing: 0.28em;
|
| 1179 |
+
color: var(--brass-1);
|
| 1180 |
+
margin-bottom: 8px;
|
| 1181 |
+
padding: 0 6px;
|
| 1182 |
+
text-shadow: 0 0 8px var(--ember-glow);
|
| 1183 |
+
}
|
| 1184 |
+
.ticker .ticker-head .dot {
|
| 1185 |
+
width: 7px; height: 7px; border-radius: 50%;
|
| 1186 |
+
background: var(--ember-1);
|
| 1187 |
+
box-shadow: 0 0 8px var(--ember-1), 0 0 16px var(--ember-2);
|
| 1188 |
+
animation: pulse-dot 1.4s ease-in-out infinite;
|
| 1189 |
+
}
|
| 1190 |
+
.ticker .ticker-head .label { color: var(--brass-1); }
|
| 1191 |
+
.ticker .ticker-head .src {
|
| 1192 |
+
color: var(--bone-4);
|
| 1193 |
+
font-family: var(--f-serif);
|
| 1194 |
+
font-style: italic;
|
| 1195 |
+
text-transform: none;
|
| 1196 |
+
letter-spacing: 0.02em;
|
| 1197 |
+
font-size: 11px;
|
| 1198 |
+
}
|
| 1199 |
+
.ticker .ticker-head .feed {
|
| 1200 |
+
margin-left: auto;
|
| 1201 |
+
font-family: var(--f-mono);
|
| 1202 |
+
font-size: 9.5px;
|
| 1203 |
+
letter-spacing: 0.05em;
|
| 1204 |
+
color: var(--bone-3);
|
| 1205 |
+
text-transform: none;
|
| 1206 |
+
text-shadow: none;
|
| 1207 |
+
}
|
| 1208 |
+
|
| 1209 |
+
.ticker .ticker-list {
|
| 1210 |
+
display: flex; flex-direction: column-reverse;
|
| 1211 |
+
gap: 5px;
|
| 1212 |
+
pointer-events: auto;
|
| 1213 |
+
max-height: 158px;
|
| 1214 |
+
overflow: hidden;
|
| 1215 |
+
mask-image: linear-gradient(to top, black 65%, transparent 100%);
|
| 1216 |
+
}
|
| 1217 |
+
|
| 1218 |
+
.disc-row {
|
| 1219 |
+
display: grid;
|
| 1220 |
+
grid-template-columns: auto 1fr auto auto;
|
| 1221 |
+
align-items: center;
|
| 1222 |
+
gap: 10px;
|
| 1223 |
+
padding: 7px 11px;
|
| 1224 |
+
background: rgba(15, 12, 9, 0.85);
|
| 1225 |
+
backdrop-filter: blur(6px);
|
| 1226 |
+
border: 1px solid rgba(70, 50, 28, 0.6);
|
| 1227 |
+
border-left-width: 2px;
|
| 1228 |
+
border-radius: 0;
|
| 1229 |
+
font-family: var(--f-mono);
|
| 1230 |
+
font-size: 11px;
|
| 1231 |
+
color: var(--bone-2);
|
| 1232 |
+
animation: ember-rise 700ms cubic-bezier(0.16, 0.92, 0.30, 1.05);
|
| 1233 |
+
position: relative;
|
| 1234 |
+
}
|
| 1235 |
+
.disc-row::before {
|
| 1236 |
+
content: "";
|
| 1237 |
+
position: absolute; left: -1px; top: -1px; bottom: -1px;
|
| 1238 |
+
width: 1px;
|
| 1239 |
+
background: linear-gradient(180deg, transparent, var(--ember-2), transparent);
|
| 1240 |
+
opacity: 0.7;
|
| 1241 |
+
animation: ember-fade 1800ms ease-out forwards;
|
| 1242 |
+
}
|
| 1243 |
+
.disc-row.r-breaks { border-left-color: var(--rust); }
|
| 1244 |
+
.disc-row.r-conditional { border-left-color: var(--amber); }
|
| 1245 |
+
.disc-row.r-requires { border-left-color: var(--ember-1); }
|
| 1246 |
+
.disc-row.r-degrades { border-left-color: var(--t-inference); }
|
| 1247 |
+
.disc-row.r-compatible { border-left-color: var(--brass-1); }
|
| 1248 |
+
|
| 1249 |
+
@keyframes ember-rise {
|
| 1250 |
+
0% { opacity: 0; transform: translateY(28px) scale(0.94); filter: brightness(1.6) drop-shadow(0 0 12px var(--ember-1)); }
|
| 1251 |
+
40% { opacity: 1; filter: brightness(1.3) drop-shadow(0 0 8px var(--ember-1)); }
|
| 1252 |
+
100% { opacity: 1; transform: translateY(0) scale(1); filter: brightness(1) drop-shadow(0 0 0 transparent); }
|
| 1253 |
+
}
|
| 1254 |
+
@keyframes ember-fade {
|
| 1255 |
+
0% { opacity: 1; }
|
| 1256 |
+
100% { opacity: 0.3; }
|
| 1257 |
+
}
|
| 1258 |
+
|
| 1259 |
+
.disc-row .rel {
|
| 1260 |
+
font-family: var(--f-mono);
|
| 1261 |
+
font-size: 9px;
|
| 1262 |
+
letter-spacing: 0.16em;
|
| 1263 |
+
text-transform: uppercase;
|
| 1264 |
+
padding: 2px 5px;
|
| 1265 |
+
border-radius: 2px;
|
| 1266 |
+
background: var(--coal-3);
|
| 1267 |
+
}
|
| 1268 |
+
.disc-row.r-breaks .rel { color: var(--rust); background: rgba(200,64,60,0.10); }
|
| 1269 |
+
.disc-row.r-conditional .rel { color: var(--amber); background: rgba(224,161,60,0.10); }
|
| 1270 |
+
.disc-row.r-requires .rel { color: var(--ember-2);background: rgba(255,106,31,0.10); }
|
| 1271 |
+
.disc-row.r-degrades .rel { color: var(--t-inference); background: rgba(225,109,142,0.10); }
|
| 1272 |
+
.disc-row.r-compatible .rel { color: var(--t-architecture); background: rgba(110,199,160,0.10); }
|
| 1273 |
+
|
| 1274 |
+
.disc-row .pair {
|
| 1275 |
+
white-space: nowrap;
|
| 1276 |
+
overflow: hidden; text-overflow: ellipsis;
|
| 1277 |
+
font-family: var(--f-display);
|
| 1278 |
+
font-size: 12px;
|
| 1279 |
+
letter-spacing: 0.01em;
|
| 1280 |
+
}
|
| 1281 |
+
.disc-row .pair .a, .disc-row .pair .b { color: var(--bone-1); }
|
| 1282 |
+
.disc-row .pair .arr { color: var(--brass-2); margin: 0 6px; }
|
| 1283 |
+
|
| 1284 |
+
.disc-row .src {
|
| 1285 |
+
color: var(--bone-4);
|
| 1286 |
+
font-size: 9.5px;
|
| 1287 |
+
white-space: nowrap;
|
| 1288 |
+
overflow: hidden; text-overflow: ellipsis;
|
| 1289 |
+
max-width: 140px;
|
| 1290 |
+
}
|
| 1291 |
+
.disc-row .ts {
|
| 1292 |
+
color: var(--bone-4);
|
| 1293 |
+
font-size: 9px;
|
| 1294 |
+
letter-spacing: 0.08em;
|
| 1295 |
+
font-variant-numeric: tabular-nums;
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
+
/* ── Evidence tooltip (positioned absolutely by JS) ──────────────────────── */
|
| 1299 |
+
.evidence-tip {
|
| 1300 |
+
position: fixed;
|
| 1301 |
+
z-index: 100;
|
| 1302 |
+
pointer-events: none;
|
| 1303 |
+
max-width: 320px;
|
| 1304 |
+
background: rgba(11, 14, 19, 0.96);
|
| 1305 |
+
border: 1px solid var(--steel-1);
|
| 1306 |
+
border-radius: 6px;
|
| 1307 |
+
padding: 12px 14px;
|
| 1308 |
+
box-shadow: 0 12px 36px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,106,31,0.06);
|
| 1309 |
+
font-size: 12px;
|
| 1310 |
+
animation: tip-in 160ms ease;
|
| 1311 |
+
}
|
| 1312 |
+
@keyframes tip-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
| 1313 |
+
.evidence-tip .tip-head {
|
| 1314 |
+
display: flex; align-items: center; gap: 8px;
|
| 1315 |
+
margin-bottom: 8px;
|
| 1316 |
+
padding-bottom: 8px;
|
| 1317 |
+
border-bottom: 1px solid var(--coal-5);
|
| 1318 |
+
}
|
| 1319 |
+
.evidence-tip .tip-name {
|
| 1320 |
+
font-family: var(--f-display);
|
| 1321 |
+
font-size: 15px;
|
| 1322 |
+
color: var(--bone-1);
|
| 1323 |
+
}
|
| 1324 |
+
.evidence-tip .tip-status {
|
| 1325 |
+
margin-left: auto;
|
| 1326 |
+
font-family: var(--f-mono);
|
| 1327 |
+
font-size: 9px;
|
| 1328 |
+
text-transform: uppercase;
|
| 1329 |
+
letter-spacing: 0.16em;
|
| 1330 |
+
padding: 2px 6px;
|
| 1331 |
+
border-radius: 2px;
|
| 1332 |
+
}
|
| 1333 |
+
.tip-status.blocked { color: var(--rust); background: rgba(200,64,60,0.12); }
|
| 1334 |
+
.tip-status.conditional { color: var(--amber); background: rgba(224,161,60,0.12); }
|
| 1335 |
+
.tip-status.selected { color: var(--ember-2); background: rgba(255,106,31,0.12); }
|
| 1336 |
+
.tip-status.available { color: var(--t-architecture); background: rgba(110,199,160,0.12); }
|
| 1337 |
+
|
| 1338 |
+
.evidence-tip .tip-reason {
|
| 1339 |
+
margin-bottom: 8px;
|
| 1340 |
+
}
|
| 1341 |
+
.evidence-tip .tip-reason-label {
|
| 1342 |
+
font-family: var(--f-mono);
|
| 1343 |
+
font-size: 9px;
|
| 1344 |
+
text-transform: uppercase;
|
| 1345 |
+
letter-spacing: 0.16em;
|
| 1346 |
+
color: var(--bone-4);
|
| 1347 |
+
margin-bottom: 3px;
|
| 1348 |
+
}
|
| 1349 |
+
.evidence-tip .tip-reason-fix {
|
| 1350 |
+
font-size: 12px;
|
| 1351 |
+
color: var(--bone-2);
|
| 1352 |
+
line-height: 1.5;
|
| 1353 |
+
}
|
| 1354 |
+
.evidence-tip .quote {
|
| 1355 |
+
font-family: Georgia, serif;
|
| 1356 |
+
font-style: italic;
|
| 1357 |
+
font-size: 12px;
|
| 1358 |
+
line-height: 1.5;
|
| 1359 |
+
color: var(--bone-2);
|
| 1360 |
+
border-left: 2px solid var(--brass-1);
|
| 1361 |
+
padding-left: 10px;
|
| 1362 |
+
margin: 6px 0 8px;
|
| 1363 |
+
}
|
| 1364 |
+
.evidence-tip .tip-foot {
|
| 1365 |
+
display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
| 1366 |
+
font-family: var(--f-mono);
|
| 1367 |
+
font-size: 9.5px;
|
| 1368 |
+
color: var(--bone-4);
|
| 1369 |
+
margin-top: 6px;
|
| 1370 |
+
}
|
| 1371 |
+
.evidence-tip .src-link {
|
| 1372 |
+
color: var(--bone-3);
|
| 1373 |
+
text-decoration: none;
|
| 1374 |
+
border-bottom: 1px dotted var(--bone-4);
|
| 1375 |
+
white-space: nowrap;
|
| 1376 |
+
overflow: hidden; text-overflow: ellipsis;
|
| 1377 |
+
max-width: 200px;
|
| 1378 |
+
}
|
| 1379 |
+
.evidence-tip .tier-badge {
|
| 1380 |
+
font-weight: 600;
|
| 1381 |
+
padding: 2px 5px;
|
| 1382 |
+
border-radius: 2px;
|
| 1383 |
+
color: var(--coal-0);
|
| 1384 |
+
}
|
| 1385 |
+
.tier-badge.t1 { background: var(--brass-1); }
|
| 1386 |
+
.tier-badge.t2 { background: var(--brass-2); }
|
| 1387 |
+
.tier-badge.t3 { background: var(--brass-3); }
|
| 1388 |
+
|
| 1389 |
+
/* ── Scrollbars ─────────────────────────────────────────────────────────── */
|
| 1390 |
+
::-webkit-scrollbar { width: 8px; height: 8px; }
|
| 1391 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 1392 |
+
::-webkit-scrollbar-thumb {
|
| 1393 |
+
background: var(--coal-5);
|
| 1394 |
+
border-radius: 4px;
|
| 1395 |
+
border: 2px solid transparent;
|
| 1396 |
+
background-clip: padding-box;
|
| 1397 |
+
}
|
| 1398 |
+
::-webkit-scrollbar-thumb:hover { background: var(--steel-1); background-clip: padding-box; }
|
| 1399 |
+
|
| 1400 |
+
/* ── Type colors injected via inline style on node group ─────────────────── */
|
| 1401 |
+
/* They use --type-color which is set on the <g class="node"> element. */
|
| 1402 |
+
|
| 1403 |
+
/* ── Responsive guard for narrow viewports ──────────────────────────────── */
|
| 1404 |
+
@media (max-width: 1100px) {
|
| 1405 |
+
.main { grid-template-columns: 260px 1fr 320px; }
|
| 1406 |
+
.ticker { width: min(440px, calc(100% - 600px)); }
|
| 1407 |
+
}
|
frontend/ticker.jsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Live Discoveries ticker. Items animate in from the bottom.
|
| 2 |
+
// Simulates new edges arriving from the Bright Data scraper.
|
| 3 |
+
|
| 4 |
+
window.DiscoveriesTicker = function DiscoveriesTicker() {
|
| 5 |
+
const seed = window.FORGE_DISCOVERIES;
|
| 6 |
+
const futures = window.FORGE_FUTURE_DISCOVERIES;
|
| 7 |
+
const [items, setItems] = React.useState(seed.slice(0, 6).map((d, i) => ({ ...d, _id: i, _t: Date.now() + d.ts_offset * 1000 })));
|
| 8 |
+
const idRef = React.useRef(seed.length);
|
| 9 |
+
const futIdxRef = React.useRef(0);
|
| 10 |
+
|
| 11 |
+
React.useEffect(() => {
|
| 12 |
+
const interval = setInterval(() => {
|
| 13 |
+
const next = futures[futIdxRef.current % futures.length];
|
| 14 |
+
futIdxRef.current += 1;
|
| 15 |
+
const item = { ...next, _id: idRef.current++, _t: Date.now() };
|
| 16 |
+
setItems(prev => [item, ...prev].slice(0, 6));
|
| 17 |
+
}, 5200);
|
| 18 |
+
return () => clearInterval(interval);
|
| 19 |
+
}, []);
|
| 20 |
+
|
| 21 |
+
return (
|
| 22 |
+
<div className="ticker">
|
| 23 |
+
<div className="ticker-head">
|
| 24 |
+
<span className="dot" />
|
| 25 |
+
<span className="label">Live Discoveries</span>
|
| 26 |
+
<span>— scraper · bright data SERP</span>
|
| 27 |
+
<span className="feed">
|
| 28 |
+
<span style={{ color: "var(--bone-3)" }}>{items.length}</span>
|
| 29 |
+
<span>new this minute</span>
|
| 30 |
+
</span>
|
| 31 |
+
</div>
|
| 32 |
+
<div className="ticker-list">
|
| 33 |
+
{items.map((d) => (
|
| 34 |
+
<div key={d._id} className={`disc-row r-${d.relation.toLowerCase()}`}>
|
| 35 |
+
<span className="rel">{d.relation}</span>
|
| 36 |
+
<span className="pair">
|
| 37 |
+
<span className="a">{d.a}</span>
|
| 38 |
+
<span className="arr">→</span>
|
| 39 |
+
<span className="b">{d.b}</span>
|
| 40 |
+
</span>
|
| 41 |
+
<span className="src" title={d.source}>{d.source}</span>
|
| 42 |
+
<span className={`tier-badge t${d.tier}`}>T{d.tier}</span>
|
| 43 |
+
<span className="ts" style={{ gridColumn: "1 / -1", color: "var(--bone-4)", marginTop: -1 }}>
|
| 44 |
+
{relativeTime(d._t)}
|
| 45 |
+
</span>
|
| 46 |
+
</div>
|
| 47 |
+
))}
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
);
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
function relativeTime(t) {
|
| 54 |
+
const dt = Math.floor((Date.now() - t) / 1000);
|
| 55 |
+
if (dt < 5) return "just now";
|
| 56 |
+
if (dt < 60) return `${dt}s ago`;
|
| 57 |
+
if (dt < 3600) return `${Math.floor(dt/60)}m ago`;
|
| 58 |
+
return `${Math.floor(dt/3600)}h ago`;
|
| 59 |
+
}
|
frontend/tooltip.jsx
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Evidence tooltip — appears on hover over a node or edge, anchored to cursor.
|
| 2 |
+
|
| 3 |
+
window.EvidenceTip = function EvidenceTip({ payload }) {
|
| 4 |
+
if (!payload) return null;
|
| 5 |
+
const { node, edge, status, reasons, pos } = payload;
|
| 6 |
+
|
| 7 |
+
const W = 320;
|
| 8 |
+
let left = pos.x + 16;
|
| 9 |
+
let top = pos.y + 14;
|
| 10 |
+
if (typeof window !== "undefined") {
|
| 11 |
+
if (left + W > window.innerWidth - 12) left = pos.x - W - 16;
|
| 12 |
+
if (top + 220 > window.innerHeight - 12) top = pos.y - 220;
|
| 13 |
+
if (top < 12) top = 12;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
return (
|
| 17 |
+
<div className="evidence-tip" style={{ left, top, width: W }}>
|
| 18 |
+
{node && (
|
| 19 |
+
<>
|
| 20 |
+
<div className="tip-head">
|
| 21 |
+
<span className="tip-name">{node.name}</span>
|
| 22 |
+
<span className={`tip-status ${status}`}>{status}</span>
|
| 23 |
+
</div>
|
| 24 |
+
<div style={{ fontSize: 11.5, color: "var(--bone-3)", lineHeight: 1.5, marginBottom: 8 }}>
|
| 25 |
+
{node.description}
|
| 26 |
+
</div>
|
| 27 |
+
{reasons && reasons.length > 0 && (
|
| 28 |
+
<>
|
| 29 |
+
{reasons.slice(0, 2).map((r, i) => {
|
| 30 |
+
const ev = (r.evidence && r.evidence[0]) || null;
|
| 31 |
+
return (
|
| 32 |
+
<div className="tip-reason" key={i}>
|
| 33 |
+
<div className="tip-reason-label" style={{
|
| 34 |
+
color: r.kind === "blocked" ? "var(--rust)"
|
| 35 |
+
: r.kind === "needed-by" ? "var(--amber)"
|
| 36 |
+
: "var(--amber)"
|
| 37 |
+
}}>
|
| 38 |
+
{r.label}
|
| 39 |
+
</div>
|
| 40 |
+
{r.fix && <div className="tip-reason-fix">{r.fix}</div>}
|
| 41 |
+
{ev && ev.quote && <div className="quote">“{ev.quote}”</div>}
|
| 42 |
+
{ev && (
|
| 43 |
+
<div className="tip-foot">
|
| 44 |
+
<a className="src-link" href={ev.url} target="_blank" rel="noopener">{prettyUrl(ev.url)}</a>
|
| 45 |
+
<span className={`tier-badge t${ev.tier || r.tier || 1}`}>T{ev.tier || r.tier || 1}</span>
|
| 46 |
+
</div>
|
| 47 |
+
)}
|
| 48 |
+
</div>
|
| 49 |
+
);
|
| 50 |
+
})}
|
| 51 |
+
</>
|
| 52 |
+
)}
|
| 53 |
+
{(!reasons || reasons.length === 0) && (
|
| 54 |
+
<div className="tip-foot">
|
| 55 |
+
<span style={{ color: "var(--bone-4)" }}>type · {node.type}</span>
|
| 56 |
+
<span className={`tier-badge t${node.tier}`}>T{node.tier}</span>
|
| 57 |
+
</div>
|
| 58 |
+
)}
|
| 59 |
+
</>
|
| 60 |
+
)}
|
| 61 |
+
{edge && (
|
| 62 |
+
<>
|
| 63 |
+
<div className="tip-head">
|
| 64 |
+
<span className="tip-name" style={{ fontSize: 13 }}>
|
| 65 |
+
{displayName(edge.from)} <span style={{ color: "var(--bone-4)", margin: "0 6px" }}>→</span> {displayName(edge.to)}
|
| 66 |
+
</span>
|
| 67 |
+
<span className={`tip-status ${edge.relation === "BREAKS" ? "blocked" : "conditional"}`}>{edge.relation}</span>
|
| 68 |
+
</div>
|
| 69 |
+
{edge.fix && <div className="tip-reason-fix" style={{ marginBottom: 8 }}>{edge.fix}</div>}
|
| 70 |
+
{edge.evidence && edge.evidence[0] && (
|
| 71 |
+
<>
|
| 72 |
+
<div className="quote">“{edge.evidence[0].quote}”</div>
|
| 73 |
+
<div className="tip-foot">
|
| 74 |
+
<a className="src-link" href={edge.evidence[0].url} target="_blank" rel="noopener">{prettyUrl(edge.evidence[0].url)}</a>
|
| 75 |
+
<span className={`tier-badge t${edge.tier}`}>T{edge.tier}</span>
|
| 76 |
+
</div>
|
| 77 |
+
</>
|
| 78 |
+
)}
|
| 79 |
+
</>
|
| 80 |
+
)}
|
| 81 |
+
</div>
|
| 82 |
+
);
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
function displayName(c) {
|
| 86 |
+
const n = (window.FORGE_NODES || []).find(x => x.canonical === c);
|
| 87 |
+
return n ? n.name : c;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function prettyUrl(url) {
|
| 91 |
+
if (!url) return "";
|
| 92 |
+
if (url.startsWith("mempalace://")) return "mempalace · rick";
|
| 93 |
+
try {
|
| 94 |
+
const u = new URL(url);
|
| 95 |
+
return u.hostname.replace(/^www\./, "") + u.pathname;
|
| 96 |
+
} catch { return url; }
|
| 97 |
+
}
|
frontend/tweaks-panel.jsx
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
// tweaks-panel.jsx
|
| 3 |
+
// Reusable Tweaks shell + form-control helpers.
|
| 4 |
+
//
|
| 5 |
+
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
| 6 |
+
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
| 7 |
+
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
| 8 |
+
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
| 9 |
+
//
|
| 10 |
+
// Usage (in an HTML file that loads React + Babel):
|
| 11 |
+
//
|
| 12 |
+
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
| 13 |
+
// "primaryColor": "#D97757",
|
| 14 |
+
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
|
| 15 |
+
// "fontSize": 16,
|
| 16 |
+
// "density": "regular",
|
| 17 |
+
// "dark": false
|
| 18 |
+
// }/*EDITMODE-END*/;
|
| 19 |
+
//
|
| 20 |
+
// function App() {
|
| 21 |
+
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
| 22 |
+
// return (
|
| 23 |
+
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
| 24 |
+
// Hello
|
| 25 |
+
// <TweaksPanel>
|
| 26 |
+
// <TweakSection label="Typography" />
|
| 27 |
+
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
| 28 |
+
// onChange={(v) => setTweak('fontSize', v)} />
|
| 29 |
+
// <TweakRadio label="Density" value={t.density}
|
| 30 |
+
// options={['compact', 'regular', 'comfy']}
|
| 31 |
+
// onChange={(v) => setTweak('density', v)} />
|
| 32 |
+
// <TweakSection label="Theme" />
|
| 33 |
+
// <TweakColor label="Primary" value={t.primaryColor}
|
| 34 |
+
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
|
| 35 |
+
// onChange={(v) => setTweak('primaryColor', v)} />
|
| 36 |
+
// <TweakColor label="Palette" value={t.palette}
|
| 37 |
+
// options={[['#D97757', '#29261b', '#f6f4ef'],
|
| 38 |
+
// ['#475569', '#0f172a', '#f1f5f9']]}
|
| 39 |
+
// onChange={(v) => setTweak('palette', v)} />
|
| 40 |
+
// <TweakToggle label="Dark mode" value={t.dark}
|
| 41 |
+
// onChange={(v) => setTweak('dark', v)} />
|
| 42 |
+
// </TweaksPanel>
|
| 43 |
+
// </div>
|
| 44 |
+
// );
|
| 45 |
+
// }
|
| 46 |
+
//
|
| 47 |
+
// ─────────────────────────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
const __TWEAKS_STYLE = `
|
| 50 |
+
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
| 51 |
+
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
| 52 |
+
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
|
| 53 |
+
background:rgba(250,249,247,.78);color:#29261b;
|
| 54 |
+
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
| 55 |
+
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
| 56 |
+
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
| 57 |
+
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
| 58 |
+
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
| 59 |
+
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
| 60 |
+
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
| 61 |
+
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
| 62 |
+
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
| 63 |
+
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
| 64 |
+
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
| 65 |
+
overflow-y:auto;overflow-x:hidden;min-height:0;
|
| 66 |
+
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
| 67 |
+
.twk-body::-webkit-scrollbar{width:8px}
|
| 68 |
+
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
| 69 |
+
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
| 70 |
+
border:2px solid transparent;background-clip:content-box}
|
| 71 |
+
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
| 72 |
+
border:2px solid transparent;background-clip:content-box}
|
| 73 |
+
.twk-row{display:flex;flex-direction:column;gap:5px}
|
| 74 |
+
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
| 75 |
+
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
| 76 |
+
color:rgba(41,38,27,.72)}
|
| 77 |
+
.twk-lbl>span:first-child{font-weight:500}
|
| 78 |
+
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
| 79 |
+
|
| 80 |
+
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
| 81 |
+
color:rgba(41,38,27,.45);padding:10px 0 0}
|
| 82 |
+
.twk-sect:first-child{padding-top:0}
|
| 83 |
+
|
| 84 |
+
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
|
| 85 |
+
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
| 86 |
+
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
| 87 |
+
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
| 88 |
+
select.twk-field{padding-right:22px;
|
| 89 |
+
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
| 90 |
+
background-repeat:no-repeat;background-position:right 8px center}
|
| 91 |
+
|
| 92 |
+
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
| 93 |
+
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
| 94 |
+
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
| 95 |
+
width:14px;height:14px;border-radius:50%;background:#fff;
|
| 96 |
+
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
| 97 |
+
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
| 98 |
+
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
| 99 |
+
|
| 100 |
+
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
| 101 |
+
background:rgba(0,0,0,.06);user-select:none}
|
| 102 |
+
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
| 103 |
+
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
| 104 |
+
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
| 105 |
+
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
| 106 |
+
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
| 107 |
+
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
| 108 |
+
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
| 109 |
+
overflow-wrap:anywhere}
|
| 110 |
+
|
| 111 |
+
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
| 112 |
+
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
| 113 |
+
.twk-toggle[data-on="1"]{background:#34c759}
|
| 114 |
+
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
| 115 |
+
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
| 116 |
+
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
| 117 |
+
|
| 118 |
+
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
|
| 119 |
+
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
| 120 |
+
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
| 121 |
+
user-select:none;padding-right:8px}
|
| 122 |
+
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
| 123 |
+
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
| 124 |
+
outline:none;color:inherit;-moz-appearance:textfield}
|
| 125 |
+
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
| 126 |
+
-webkit-appearance:none;margin:0}
|
| 127 |
+
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
| 128 |
+
|
| 129 |
+
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
| 130 |
+
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
| 131 |
+
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
| 132 |
+
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
| 133 |
+
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
| 134 |
+
|
| 135 |
+
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
| 136 |
+
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
| 137 |
+
background:transparent;flex-shrink:0}
|
| 138 |
+
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
| 139 |
+
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
| 140 |
+
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
| 141 |
+
|
| 142 |
+
.twk-chips{display:flex;gap:6px}
|
| 143 |
+
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
|
| 144 |
+
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
|
| 145 |
+
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
|
| 146 |
+
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
|
| 147 |
+
.twk-chip:hover{transform:translateY(-1px);
|
| 148 |
+
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
|
| 149 |
+
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
|
| 150 |
+
0 2px 6px rgba(0,0,0,.15)}
|
| 151 |
+
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
|
| 152 |
+
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
|
| 153 |
+
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
|
| 154 |
+
.twk-chip>span>i:first-child{box-shadow:none}
|
| 155 |
+
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
|
| 156 |
+
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
|
| 157 |
+
`;
|
| 158 |
+
|
| 159 |
+
// ── useTweaks ───────────────────────────────────────────────────────────────
|
| 160 |
+
// Single source of truth for tweak values. setTweak persists via the host
|
| 161 |
+
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
| 162 |
+
function useTweaks(defaults) {
|
| 163 |
+
const [values, setValues] = React.useState(defaults);
|
| 164 |
+
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
| 165 |
+
// useState-style call doesn't write a "[object Object]" key into the persisted
|
| 166 |
+
// JSON block.
|
| 167 |
+
const setTweak = React.useCallback((keyOrEdits, val) => {
|
| 168 |
+
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
| 169 |
+
? keyOrEdits : { [keyOrEdits]: val };
|
| 170 |
+
setValues((prev) => ({ ...prev, ...edits }));
|
| 171 |
+
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
| 172 |
+
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
|
| 173 |
+
// can react — the parent message only reaches the host, not peers.
|
| 174 |
+
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
|
| 175 |
+
}, []);
|
| 176 |
+
return [values, setTweak];
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
| 180 |
+
// Floating shell. Registers the protocol listener BEFORE announcing
|
| 181 |
+
// availability — if the announce ran first, the host's activate could land
|
| 182 |
+
// before our handler exists and the toolbar toggle would silently no-op.
|
| 183 |
+
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
| 184 |
+
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
| 185 |
+
// is what actually hides the panel.
|
| 186 |
+
function TweaksPanel({ title = 'Tweaks', children }) {
|
| 187 |
+
const [open, setOpen] = React.useState(false);
|
| 188 |
+
const dragRef = React.useRef(null);
|
| 189 |
+
const offsetRef = React.useRef({ x: 16, y: 16 });
|
| 190 |
+
const PAD = 16;
|
| 191 |
+
|
| 192 |
+
const clampToViewport = React.useCallback(() => {
|
| 193 |
+
const panel = dragRef.current;
|
| 194 |
+
if (!panel) return;
|
| 195 |
+
const w = panel.offsetWidth, h = panel.offsetHeight;
|
| 196 |
+
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
| 197 |
+
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
| 198 |
+
offsetRef.current = {
|
| 199 |
+
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
| 200 |
+
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
| 201 |
+
};
|
| 202 |
+
panel.style.right = offsetRef.current.x + 'px';
|
| 203 |
+
panel.style.bottom = offsetRef.current.y + 'px';
|
| 204 |
+
}, []);
|
| 205 |
+
|
| 206 |
+
React.useEffect(() => {
|
| 207 |
+
if (!open) return;
|
| 208 |
+
clampToViewport();
|
| 209 |
+
if (typeof ResizeObserver === 'undefined') {
|
| 210 |
+
window.addEventListener('resize', clampToViewport);
|
| 211 |
+
return () => window.removeEventListener('resize', clampToViewport);
|
| 212 |
+
}
|
| 213 |
+
const ro = new ResizeObserver(clampToViewport);
|
| 214 |
+
ro.observe(document.documentElement);
|
| 215 |
+
return () => ro.disconnect();
|
| 216 |
+
}, [open, clampToViewport]);
|
| 217 |
+
|
| 218 |
+
React.useEffect(() => {
|
| 219 |
+
const onMsg = (e) => {
|
| 220 |
+
const t = e?.data?.type;
|
| 221 |
+
if (t === '__activate_edit_mode') setOpen(true);
|
| 222 |
+
else if (t === '__deactivate_edit_mode') setOpen(false);
|
| 223 |
+
};
|
| 224 |
+
window.addEventListener('message', onMsg);
|
| 225 |
+
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
| 226 |
+
return () => window.removeEventListener('message', onMsg);
|
| 227 |
+
}, []);
|
| 228 |
+
|
| 229 |
+
const dismiss = () => {
|
| 230 |
+
setOpen(false);
|
| 231 |
+
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
const onDragStart = (e) => {
|
| 235 |
+
const panel = dragRef.current;
|
| 236 |
+
if (!panel) return;
|
| 237 |
+
const r = panel.getBoundingClientRect();
|
| 238 |
+
const sx = e.clientX, sy = e.clientY;
|
| 239 |
+
const startRight = window.innerWidth - r.right;
|
| 240 |
+
const startBottom = window.innerHeight - r.bottom;
|
| 241 |
+
const move = (ev) => {
|
| 242 |
+
offsetRef.current = {
|
| 243 |
+
x: startRight - (ev.clientX - sx),
|
| 244 |
+
y: startBottom - (ev.clientY - sy),
|
| 245 |
+
};
|
| 246 |
+
clampToViewport();
|
| 247 |
+
};
|
| 248 |
+
const up = () => {
|
| 249 |
+
window.removeEventListener('mousemove', move);
|
| 250 |
+
window.removeEventListener('mouseup', up);
|
| 251 |
+
};
|
| 252 |
+
window.addEventListener('mousemove', move);
|
| 253 |
+
window.addEventListener('mouseup', up);
|
| 254 |
+
};
|
| 255 |
+
|
| 256 |
+
if (!open) return null;
|
| 257 |
+
return (
|
| 258 |
+
<>
|
| 259 |
+
<style>{__TWEAKS_STYLE}</style>
|
| 260 |
+
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
|
| 261 |
+
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
| 262 |
+
<div className="twk-hd" onMouseDown={onDragStart}>
|
| 263 |
+
<b>{title}</b>
|
| 264 |
+
<button className="twk-x" aria-label="Close tweaks"
|
| 265 |
+
onMouseDown={(e) => e.stopPropagation()}
|
| 266 |
+
onClick={dismiss}>✕</button>
|
| 267 |
+
</div>
|
| 268 |
+
<div className="twk-body">
|
| 269 |
+
{children}
|
| 270 |
+
</div>
|
| 271 |
+
</div>
|
| 272 |
+
</>
|
| 273 |
+
);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// ── Layout helpers ──────────────────────────────────────────────────────────
|
| 277 |
+
|
| 278 |
+
function TweakSection({ label, children }) {
|
| 279 |
+
return (
|
| 280 |
+
<>
|
| 281 |
+
<div className="twk-sect">{label}</div>
|
| 282 |
+
{children}
|
| 283 |
+
</>
|
| 284 |
+
);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
function TweakRow({ label, value, children, inline = false }) {
|
| 288 |
+
return (
|
| 289 |
+
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
| 290 |
+
<div className="twk-lbl">
|
| 291 |
+
<span>{label}</span>
|
| 292 |
+
{value != null && <span className="twk-val">{value}</span>}
|
| 293 |
+
</div>
|
| 294 |
+
{children}
|
| 295 |
+
</div>
|
| 296 |
+
);
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// ── Controls ────────────────────────────────────────────────────────────────
|
| 300 |
+
|
| 301 |
+
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
| 302 |
+
return (
|
| 303 |
+
<TweakRow label={label} value={`${value}${unit}`}>
|
| 304 |
+
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
| 305 |
+
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
| 306 |
+
</TweakRow>
|
| 307 |
+
);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
function TweakToggle({ label, value, onChange }) {
|
| 311 |
+
return (
|
| 312 |
+
<div className="twk-row twk-row-h">
|
| 313 |
+
<div className="twk-lbl"><span>{label}</span></div>
|
| 314 |
+
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
| 315 |
+
role="switch" aria-checked={!!value}
|
| 316 |
+
onClick={() => onChange(!value)}><i /></button>
|
| 317 |
+
</div>
|
| 318 |
+
);
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
function TweakRadio({ label, value, options, onChange }) {
|
| 322 |
+
const trackRef = React.useRef(null);
|
| 323 |
+
const [dragging, setDragging] = React.useState(false);
|
| 324 |
+
// The active value is read by pointer-move handlers attached for the lifetime
|
| 325 |
+
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
| 326 |
+
const valueRef = React.useRef(value);
|
| 327 |
+
valueRef.current = value;
|
| 328 |
+
|
| 329 |
+
// Segments wrap mid-word once per-segment width runs out. The track is
|
| 330 |
+
// ~248px (280 panel − 28 body pad − 4 seg pad), each button loses 12px
|
| 331 |
+
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
|
| 332 |
+
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
|
| 333 |
+
// back to a dropdown rather than wrap.
|
| 334 |
+
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
|
| 335 |
+
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
|
| 336 |
+
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
|
| 337 |
+
if (!fitsAsSegments) {
|
| 338 |
+
// <select> emits strings — map back to the original option value so the
|
| 339 |
+
// fallback stays type-preserving (numbers, booleans) like the segment path.
|
| 340 |
+
const resolve = (s) => {
|
| 341 |
+
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
|
| 342 |
+
return m === undefined ? s : typeof m === 'object' ? m.value : m;
|
| 343 |
+
};
|
| 344 |
+
return <TweakSelect label={label} value={value} options={options}
|
| 345 |
+
onChange={(s) => onChange(resolve(s))} />;
|
| 346 |
+
}
|
| 347 |
+
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
| 348 |
+
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
| 349 |
+
const n = opts.length;
|
| 350 |
+
|
| 351 |
+
const segAt = (clientX) => {
|
| 352 |
+
const r = trackRef.current.getBoundingClientRect();
|
| 353 |
+
const inner = r.width - 4;
|
| 354 |
+
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
| 355 |
+
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
| 356 |
+
};
|
| 357 |
+
|
| 358 |
+
const onPointerDown = (e) => {
|
| 359 |
+
setDragging(true);
|
| 360 |
+
const v0 = segAt(e.clientX);
|
| 361 |
+
if (v0 !== valueRef.current) onChange(v0);
|
| 362 |
+
const move = (ev) => {
|
| 363 |
+
if (!trackRef.current) return;
|
| 364 |
+
const v = segAt(ev.clientX);
|
| 365 |
+
if (v !== valueRef.current) onChange(v);
|
| 366 |
+
};
|
| 367 |
+
const up = () => {
|
| 368 |
+
setDragging(false);
|
| 369 |
+
window.removeEventListener('pointermove', move);
|
| 370 |
+
window.removeEventListener('pointerup', up);
|
| 371 |
+
};
|
| 372 |
+
window.addEventListener('pointermove', move);
|
| 373 |
+
window.addEventListener('pointerup', up);
|
| 374 |
+
};
|
| 375 |
+
|
| 376 |
+
return (
|
| 377 |
+
<TweakRow label={label}>
|
| 378 |
+
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
| 379 |
+
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
| 380 |
+
<div className="twk-seg-thumb"
|
| 381 |
+
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
| 382 |
+
width: `calc((100% - 4px) / ${n})` }} />
|
| 383 |
+
{opts.map((o) => (
|
| 384 |
+
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
| 385 |
+
{o.label}
|
| 386 |
+
</button>
|
| 387 |
+
))}
|
| 388 |
+
</div>
|
| 389 |
+
</TweakRow>
|
| 390 |
+
);
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
function TweakSelect({ label, value, options, onChange }) {
|
| 394 |
+
return (
|
| 395 |
+
<TweakRow label={label}>
|
| 396 |
+
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
| 397 |
+
{options.map((o) => {
|
| 398 |
+
const v = typeof o === 'object' ? o.value : o;
|
| 399 |
+
const l = typeof o === 'object' ? o.label : o;
|
| 400 |
+
return <option key={v} value={v}>{l}</option>;
|
| 401 |
+
})}
|
| 402 |
+
</select>
|
| 403 |
+
</TweakRow>
|
| 404 |
+
);
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
function TweakText({ label, value, placeholder, onChange }) {
|
| 408 |
+
return (
|
| 409 |
+
<TweakRow label={label}>
|
| 410 |
+
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
| 411 |
+
onChange={(e) => onChange(e.target.value)} />
|
| 412 |
+
</TweakRow>
|
| 413 |
+
);
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
| 417 |
+
const clamp = (n) => {
|
| 418 |
+
if (min != null && n < min) return min;
|
| 419 |
+
if (max != null && n > max) return max;
|
| 420 |
+
return n;
|
| 421 |
+
};
|
| 422 |
+
const startRef = React.useRef({ x: 0, val: 0 });
|
| 423 |
+
const onScrubStart = (e) => {
|
| 424 |
+
e.preventDefault();
|
| 425 |
+
startRef.current = { x: e.clientX, val: value };
|
| 426 |
+
const decimals = (String(step).split('.')[1] || '').length;
|
| 427 |
+
const move = (ev) => {
|
| 428 |
+
const dx = ev.clientX - startRef.current.x;
|
| 429 |
+
const raw = startRef.current.val + dx * step;
|
| 430 |
+
const snapped = Math.round(raw / step) * step;
|
| 431 |
+
onChange(clamp(Number(snapped.toFixed(decimals))));
|
| 432 |
+
};
|
| 433 |
+
const up = () => {
|
| 434 |
+
window.removeEventListener('pointermove', move);
|
| 435 |
+
window.removeEventListener('pointerup', up);
|
| 436 |
+
};
|
| 437 |
+
window.addEventListener('pointermove', move);
|
| 438 |
+
window.addEventListener('pointerup', up);
|
| 439 |
+
};
|
| 440 |
+
return (
|
| 441 |
+
<div className="twk-num">
|
| 442 |
+
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
| 443 |
+
<input type="number" value={value} min={min} max={max} step={step}
|
| 444 |
+
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
| 445 |
+
{unit && <span className="twk-num-unit">{unit}</span>}
|
| 446 |
+
</div>
|
| 447 |
+
);
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
|
| 451 |
+
// read on both #111 and #fafafa without per-option configuration. Hex input
|
| 452 |
+
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
|
| 453 |
+
function __twkIsLight(hex) {
|
| 454 |
+
const h = String(hex).replace('#', '');
|
| 455 |
+
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
|
| 456 |
+
const n = parseInt(x.slice(0, 6), 16);
|
| 457 |
+
if (Number.isNaN(n)) return true;
|
| 458 |
+
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
|
| 459 |
+
return r * 299 + g * 587 + b * 114 > 148000;
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
const __TwkCheck = ({ light }) => (
|
| 463 |
+
<svg viewBox="0 0 14 14" aria-hidden="true">
|
| 464 |
+
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
|
| 465 |
+
strokeLinecap="round" strokeLinejoin="round"
|
| 466 |
+
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
|
| 467 |
+
</svg>
|
| 468 |
+
);
|
| 469 |
+
|
| 470 |
+
// TweakColor — curated color/palette picker. Each option is either a single
|
| 471 |
+
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
|
| 472 |
+
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
|
| 473 |
+
// rest stacked in a sharp column on the right. onChange emits the
|
| 474 |
+
// option in the shape it was passed (string stays string, array stays array).
|
| 475 |
+
// Without options it falls back to the native color input for back-compat.
|
| 476 |
+
function TweakColor({ label, value, options, onChange }) {
|
| 477 |
+
if (!options || !options.length) {
|
| 478 |
+
return (
|
| 479 |
+
<div className="twk-row twk-row-h">
|
| 480 |
+
<div className="twk-lbl"><span>{label}</span></div>
|
| 481 |
+
<input type="color" className="twk-swatch" value={value}
|
| 482 |
+
onChange={(e) => onChange(e.target.value)} />
|
| 483 |
+
</div>
|
| 484 |
+
);
|
| 485 |
+
}
|
| 486 |
+
// Native <input type=color> emits lowercase hex per the HTML spec, so
|
| 487 |
+
// compare case-insensitively. String() guards JSON.stringify(undefined),
|
| 488 |
+
// which returns the primitive undefined (no .toLowerCase).
|
| 489 |
+
const key = (o) => String(JSON.stringify(o)).toLowerCase();
|
| 490 |
+
const cur = key(value);
|
| 491 |
+
return (
|
| 492 |
+
<TweakRow label={label}>
|
| 493 |
+
<div className="twk-chips" role="radiogroup">
|
| 494 |
+
{options.map((o, i) => {
|
| 495 |
+
const colors = Array.isArray(o) ? o : [o];
|
| 496 |
+
const [hero, ...rest] = colors;
|
| 497 |
+
const sup = rest.slice(0, 4);
|
| 498 |
+
const on = key(o) === cur;
|
| 499 |
+
return (
|
| 500 |
+
<button key={i} type="button" className="twk-chip" role="radio"
|
| 501 |
+
aria-checked={on} data-on={on ? '1' : '0'}
|
| 502 |
+
aria-label={colors.join(', ')} title={colors.join(' · ')}
|
| 503 |
+
style={{ background: hero }}
|
| 504 |
+
onClick={() => onChange(o)}>
|
| 505 |
+
{sup.length > 0 && (
|
| 506 |
+
<span>
|
| 507 |
+
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
|
| 508 |
+
</span>
|
| 509 |
+
)}
|
| 510 |
+
{on && <__TwkCheck light={__twkIsLight(hero)} />}
|
| 511 |
+
</button>
|
| 512 |
+
);
|
| 513 |
+
})}
|
| 514 |
+
</div>
|
| 515 |
+
</TweakRow>
|
| 516 |
+
);
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
function TweakButton({ label, onClick, secondary = false }) {
|
| 520 |
+
return (
|
| 521 |
+
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
| 522 |
+
onClick={onClick}>{label}</button>
|
| 523 |
+
);
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
Object.assign(window, {
|
| 527 |
+
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
| 528 |
+
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
| 529 |
+
TweakText, TweakNumber, TweakColor, TweakButton,
|
| 530 |
+
});
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Forge app deps. External (not pip): Bright Data CLI, llama.cpp (llama-server).
|
| 2 |
+
fastapi
|
| 3 |
+
uvicorn[standard]
|