juiceb0xc0de commited on
Commit
00cabba
·
1 Parent(s): 4bc9610

Forge: initial deploy — API + static UI over prebuilt forge.db (seeded at build)

Browse files
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: green
5
- colorTo: green
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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]