graziul commited on
Commit
029d553
·
verified ·
1 Parent(s): 79be5a9

feat: collapsible expansion, canonical formalism pages, citation enrichment

Browse files
Files changed (1) hide show
  1. space_app.py +996 -0
space_app.py ADDED
@@ -0,0 +1,996 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ HF Space entry point — FastAPI app serving the Différance Engine.
3
+
4
+ Copy of site/app.py but with paths adjusted for the Space root layout.
5
+ The Space root is under-erasure/ with:
6
+ - pipeline/ Python pipeline code
7
+ - data/ SQLite database + benchmarks
8
+ - site/dist/ Astro-built static files
9
+ - site/src/ Astro source (not needed at runtime)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ # Ensure pipeline is importable from Space root
20
+ _space_root = Path(__file__).resolve().parent
21
+ if str(_space_root) not in sys.path:
22
+ sys.path.insert(0, str(_space_root))
23
+
24
+ from fastapi import FastAPI, HTTPException, Query
25
+ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
26
+ from fastapi.staticfiles import StaticFiles
27
+
28
+ app = FastAPI(title="Différance Engine", version="0.2.0")
29
+
30
+ DIST_DIR = _space_root / "site" / "dist"
31
+
32
+ # Serve Astro static assets (CSS, JS, fonts)
33
+ if (DIST_DIR / "_astro").exists():
34
+ app.mount("/_astro", StaticFiles(directory=str(DIST_DIR / "_astro")), name="astro_assets")
35
+
36
+
37
+ @app.get("/")
38
+ async def index():
39
+ """Dynamic index — queries live DB for stats and papers at render time."""
40
+ try:
41
+ from pipeline.db import get_db
42
+ db = get_db()
43
+ db.connect()
44
+ stats = db.stats()
45
+ papers = db.get_displayable_papers(limit=30)
46
+ db.close()
47
+ return HTMLResponse(_render_index_html(stats, papers))
48
+ except Exception:
49
+ return HTMLResponse(_fallback_html())
50
+
51
+
52
+ @app.get("/discourse")
53
+ async def discourse_page():
54
+ """Translation trace: papers sorted by reduction rate, descending and ascending.
55
+
56
+ NOT a quality leaderboard. Translation efficiency against a declared taxonomy
57
+ is descriptive, not evaluative. High rate = vocabulary overlap. Low rate =
58
+ where to look for genuine novelty OR taxonomy gaps OR bullshit. Reader decides.
59
+ """
60
+ try:
61
+ from pipeline.db import get_db
62
+ db = get_db(); db.connect()
63
+ papers = db.get_displayable_papers(limit=50)
64
+ # Enrich with per-paper reduction rate
65
+ enriched = []
66
+ for p in papers:
67
+ reds = db.get_reductions_for_paper(p["arxiv_id"])
68
+ total = len(reds)
69
+ mapped = sum(1 for r in reds if r["result_type"] in ("identity", "compositional"))
70
+ rate = mapped / total if total > 0 else 0
71
+ unknown = total - mapped
72
+ p["_reduction_rate"] = rate
73
+ p["_mapped"] = mapped
74
+ p["_unknown"] = unknown
75
+ p["_total"] = total
76
+ enriched.append(p)
77
+ db.close()
78
+ # Sort descending (most translated) and ascending (least translated)
79
+ desc = sorted(enriched, key=lambda p: p["_reduction_rate"], reverse=True)
80
+ asc = sorted(enriched, key=lambda p: p["_reduction_rate"])
81
+ return HTMLResponse(_render_discourse_html(desc, asc))
82
+ except Exception as e:
83
+ return HTMLResponse(_fallback_html(str(e)))
84
+
85
+
86
+ @app.get("/lookup")
87
+ async def lookup_page():
88
+ lookup_path = DIST_DIR / "lookup" / "index.html"
89
+ if lookup_path.exists():
90
+ return HTMLResponse(lookup_path.read_text())
91
+ return HTMLResponse(_fallback_html())
92
+
93
+
94
+ @app.get("/paper/{arxiv_id}")
95
+ async def paper_page(arxiv_id: str):
96
+ paper_path = DIST_DIR / "paper" / arxiv_id / "index.html"
97
+ if paper_path.exists():
98
+ return HTMLResponse(paper_path.read_text())
99
+ try:
100
+ from pipeline.db import get_db
101
+ db = get_db()
102
+ db.connect()
103
+ paper = db.find_paper(arxiv_id)
104
+ if paper:
105
+ canonical_id = paper["arxiv_id"]
106
+ reductions = db.get_reductions_for_paper(canonical_id)
107
+ db.close()
108
+ return HTMLResponse(_render_paper_html(paper, reductions))
109
+ db.close()
110
+ except Exception:
111
+ pass
112
+ return HTMLResponse(_fallback_html(f"Paper {arxiv_id} not found."), status_code=404)
113
+
114
+
115
+ # --- API ---
116
+
117
+ @app.get("/api/stats")
118
+ async def api_stats():
119
+ try:
120
+ from pipeline.db import get_db
121
+ db = get_db()
122
+ db.connect()
123
+ stats = db.stats()
124
+ db.close()
125
+ return stats
126
+ except Exception as e:
127
+ return {"error": str(e)}
128
+
129
+
130
+ @app.get("/api/paper/{arxiv_id}")
131
+ async def api_paper(arxiv_id: str):
132
+ try:
133
+ from pipeline.db import get_db
134
+ db = get_db()
135
+ db.connect()
136
+ paper = db.find_paper(arxiv_id)
137
+ if not paper:
138
+ db.close()
139
+ raise HTTPException(status_code=404)
140
+ canonical_id = paper["arxiv_id"]
141
+ reductions = db.get_reductions_for_paper(canonical_id)
142
+ db.close()
143
+ return {"paper": paper, "reductions": reductions}
144
+ except HTTPException:
145
+ raise
146
+ except Exception as e:
147
+ raise HTTPException(status_code=500, detail=str(e))
148
+
149
+
150
+ @app.get("/api/lookup")
151
+ async def api_lookup(q: str = Query(...)):
152
+ try:
153
+ from pipeline.db import get_db
154
+ db = get_db()
155
+ db.connect()
156
+ paper = db.find_paper(q.strip())
157
+ db.close()
158
+ if paper:
159
+ return RedirectResponse(f"/paper/{paper['arxiv_id']}")
160
+ raise HTTPException(status_code=404, detail=f"Not found: {q}")
161
+ except HTTPException:
162
+ raise
163
+
164
+
165
+ @app.get("/api/index/analogs")
166
+ async def api_index_analogs():
167
+ """All canonical analogs cited, with paper counts (cross-reference index)."""
168
+ try:
169
+ from pipeline.db import get_db
170
+ db = get_db(); db.connect()
171
+ analogs = db.get_canonical_analogs()
172
+ db.close()
173
+ return analogs
174
+ except Exception as e:
175
+ return {"error": str(e)}
176
+
177
+
178
+ @app.get("/api/index/moves")
179
+ async def api_index_moves():
180
+ """Deconstructive move counts across all concepts."""
181
+ try:
182
+ from pipeline.db import get_db
183
+ db = get_db(); db.connect()
184
+ moves = db.get_move_counts()
185
+ db.close()
186
+ return moves
187
+ except Exception as e:
188
+ return {"error": str(e)}
189
+
190
+
191
+ @app.get("/api/crossref/{arxiv_id}")
192
+ async def api_crossref(arxiv_id: str):
193
+ """Papers that share canonical analogs with this paper."""
194
+ try:
195
+ from pipeline.db import get_db
196
+ db = get_db(); db.connect()
197
+ paper = db.find_paper(arxiv_id)
198
+ if not paper:
199
+ db.close()
200
+ raise HTTPException(status_code=404)
201
+ reductions = db.get_reductions_for_paper(paper["arxiv_id"])
202
+ # Collect unique canonical analogs for this paper
203
+ analogs = list({r["canonical_analog"] for r in reductions if r.get("canonical_analog")})
204
+ # Find related papers for each analog
205
+ related: dict[str, list] = {}
206
+ for analog in analogs:
207
+ papers = db.get_papers_by_analog(analog, limit=10)
208
+ # Exclude self
209
+ papers = [p for p in papers if p["arxiv_id"] != paper["arxiv_id"]]
210
+ if papers:
211
+ related[analog] = papers
212
+ db.close()
213
+ return {
214
+ "paper_id": paper["arxiv_id"],
215
+ "paper_title": paper["title"],
216
+ "canonical_analogs": analogs,
217
+ "related_papers": related,
218
+ }
219
+ except HTTPException:
220
+ raise
221
+ except Exception as e:
222
+ raise HTTPException(status_code=500, detail=str(e))
223
+
224
+
225
+ @app.get("/canonical/{formalism_id}")
226
+ async def canonical_page(formalism_id: str):
227
+ """Page for a canonical formalism, with its orienting paper deconstructed."""
228
+ try:
229
+ from pipeline.match import load_engine
230
+ from pipeline.db import get_db
231
+ engine = load_engine()
232
+ fm = engine._by_id.get(formalism_id)
233
+ if not fm:
234
+ raise HTTPException(status_code=404, detail=f"Unknown formalism: {formalism_id}")
235
+
236
+ db = get_db(); db.connect()
237
+ # Papers that cite this formalism as a canonical analog
238
+ citing_papers = db.get_papers_by_analog(fm.name, limit=30)
239
+ # Check if canonical paper is already deconstructed
240
+ canonical_arxiv = getattr(fm, 'canonical_arxiv_id', None)
241
+ # Also load from raw YAML for canonical_arxiv_id
242
+ import yaml
243
+ from pathlib import Path
244
+ kb_path = Path(__file__).resolve().parent / "pipeline" / "kb" / "formalisms.yaml"
245
+ with open(kb_path) as f:
246
+ raw = yaml.safe_load(f)
247
+ for entry in raw.get("formalisms", []):
248
+ if entry.get("id") == formalism_id:
249
+ canonical_arxiv = entry.get("canonical_arxiv_id")
250
+ break
251
+
252
+ canonical_paper = None
253
+ canonical_reductions = []
254
+ if canonical_arxiv:
255
+ paper = db.find_paper(canonical_arxiv)
256
+ if paper and paper.get("status") == "matched":
257
+ canonical_paper = paper
258
+ canonical_reductions = db.get_reductions_for_paper(paper["arxiv_id"])
259
+
260
+ db.close()
261
+ return HTMLResponse(_render_canonical_html(
262
+ fm, citing_papers, canonical_arxiv,
263
+ canonical_paper, canonical_reductions
264
+ ))
265
+ except HTTPException:
266
+ raise
267
+ except Exception as e:
268
+ return HTMLResponse(_fallback_html(str(e)))
269
+
270
+
271
+ @app.get("/api/canonical/{formalism_id}")
272
+ async def api_canonical(formalism_id: str):
273
+ """JSON API for a canonical formalism."""
274
+ try:
275
+ from pipeline.match import load_engine
276
+ from pipeline.db import get_db
277
+ engine = load_engine()
278
+ fm = engine._by_id.get(formalism_id)
279
+ if not fm:
280
+ raise HTTPException(status_code=404)
281
+ # Get canonical_arxiv_id
282
+ import yaml
283
+ from pathlib import Path
284
+ kb_path = Path(__file__).resolve().parent / "pipeline" / "kb" / "formalisms.yaml"
285
+ with open(kb_path) as f:
286
+ raw = yaml.safe_load(f)
287
+ canonical_arxiv = None
288
+ for entry in raw.get("formalisms", []):
289
+ if entry.get("id") == formalism_id:
290
+ canonical_arxiv = entry.get("canonical_arxiv_id")
291
+ break
292
+ db = get_db(); db.connect()
293
+ citing = db.get_papers_by_analog(fm.name, limit=30)
294
+ db.close()
295
+ return {
296
+ "id": fm.id, "name": fm.name, "year": fm.year,
297
+ "origin": fm.origin,
298
+ "signature": {
299
+ "operation": fm.signature.operation,
300
+ "domain": fm.signature.domain,
301
+ "codomain": fm.signature.codomain,
302
+ "objective_family": fm.signature.objective_family,
303
+ },
304
+ "meso_type": fm.meso_type, "macro_type": fm.macro_type,
305
+ "canonical_reference": fm.canonical_reference,
306
+ "canonical_arxiv_id": canonical_arxiv,
307
+ "citing_papers_count": len(citing),
308
+ "citing_papers": citing[:10],
309
+ }
310
+ except HTTPException:
311
+ raise
312
+ except Exception as e:
313
+ raise HTTPException(status_code=500, detail=str(e))
314
+
315
+
316
+ @app.get("/api/cite/{arxiv_id}")
317
+ async def api_cite(arxiv_id: str):
318
+ """Look up citation count from Semantic Scholar."""
319
+ import urllib.request, json as _json
320
+ url = f"https://api.semanticscholar.org/graph/v1/paper/ArXiv:{arxiv_id}?fields=citationCount,influentialCitationCount,title,year"
321
+ try:
322
+ req = urllib.request.Request(url, headers={"User-Agent": "DifferanceEngine/1.0"})
323
+ with urllib.request.urlopen(req, timeout=10) as resp:
324
+ data = _json.loads(resp.read())
325
+ return {
326
+ "arxiv_id": arxiv_id,
327
+ "title": data.get("title", ""),
328
+ "year": data.get("year"),
329
+ "citation_count": data.get("citationCount", 0),
330
+ "influential_citation_count": data.get("influentialCitationCount", 0),
331
+ }
332
+ except Exception as e:
333
+ return {"arxiv_id": arxiv_id, "error": str(e), "citation_count": 0}
334
+
335
+
336
+ @app.get("/api/models")
337
+ async def api_models():
338
+ try:
339
+ from pipeline.extract import (
340
+ select_extraction_model,
341
+ _benchmark_cache,
342
+ EXTRACTION_MODEL_CANDIDATES,
343
+ )
344
+ api_key = os.environ.get("HF_API_KEY", "")
345
+ selection = select_extraction_model(api_key)
346
+ benchmarks = {}
347
+ for mid, bench in _benchmark_cache.items():
348
+ benchmarks[mid] = {
349
+ "passed": bench.passed,
350
+ "correctness_score": bench.correctness_score,
351
+ "completeness_score": bench.completeness_score,
352
+ "latency_sec": bench.latency_sec,
353
+ "cost_est": bench.cost_est,
354
+ "error": bench.error,
355
+ }
356
+ return {
357
+ "selected_model": selection.model_id,
358
+ "selected_reason": selection.reason,
359
+ "selected_at": selection.selected_at,
360
+ "candidates": [
361
+ {"id": c["id"], "cost_per_1k_tokens": c["cost_per_1k_tokens"]}
362
+ for c in EXTRACTION_MODEL_CANDIDATES
363
+ ],
364
+ "benchmarks": benchmarks,
365
+ }
366
+ except Exception as e:
367
+ return {"error": str(e)}
368
+
369
+
370
+ @app.post("/api/trigger")
371
+ async def api_trigger(arxiv_id: str | None = None, retroactive: bool = False):
372
+ api_key = os.environ.get("HF_API_KEY", "")
373
+ if not api_key:
374
+ raise HTTPException(status_code=401, detail="HF_API_KEY not configured")
375
+
376
+ try:
377
+ from pipeline.db import get_db
378
+ from pipeline.ingest import ingest_single
379
+ from pipeline.match import load_engine
380
+ from pipeline.extract import extract_paper as _extract
381
+
382
+ db = get_db()
383
+ engine = load_engine()
384
+
385
+ if arxiv_id:
386
+ paper = ingest_single(arxiv_id.strip(), db=db)
387
+ if not paper:
388
+ raise HTTPException(status_code=404, detail=f"Could not fetch {arxiv_id}")
389
+ # Use the canonical arXiv ID from the DB/API (may include version suffix)
390
+ canonical_id = paper["arxiv_id"]
391
+ import traceback
392
+ try:
393
+ extraction = _extract(title=paper["title"], abstract=paper["abstract"], api_key=api_key)
394
+ except Exception as exc:
395
+ return {"status": "extraction_error", "error": str(exc), "traceback": traceback.format_exc()}
396
+ if extraction:
397
+ db.delete_concepts_for_paper(canonical_id)
398
+ db.delete_reductions_for_paper(canonical_id)
399
+ for concept in extraction.get("concepts", []):
400
+ cid = db.insert_concept(canonical_id, concept)
401
+ match = engine.match_concept(concept)
402
+ db.insert_reduction(cid, canonical_id, engine._result_to_dict(match))
403
+ db.update_status(canonical_id, "matched")
404
+ return {"status": "matched", "concepts": len(extraction.get("concepts", []))}
405
+ return {"status": "extraction_failed", "debug": "extract_paper returned None — check Space logs for details"}
406
+
407
+ if retroactive:
408
+ papers = db.get_papers_with_unknown(limit=30)
409
+ for p in papers:
410
+ pid = p["arxiv_id"]
411
+ db.delete_reductions_for_paper(pid)
412
+ return {"status": "retroactive_queued", "papers": len(papers)}
413
+
414
+ return {"status": "no_action", "message": "Specify arxiv_id or retroactive=true"}
415
+
416
+ except HTTPException:
417
+ raise
418
+ except Exception as e:
419
+ raise HTTPException(status_code=500, detail=str(e))
420
+
421
+
422
+ # --- Helpers ---
423
+
424
+ def _render_discourse_html(desc: list[dict], asc: list[dict]) -> str:
425
+ """Render the discourse page: translation trace, not quality leaderboard."""
426
+
427
+ def _paper_row(p: dict) -> str:
428
+ rate = p["_reduction_rate"]
429
+ pct = int(rate * 100)
430
+ bar_color = (
431
+ "#4ecdc4" if pct >= 67 else "#ffe66d" if pct >= 34 else "#ff6b35"
432
+ )
433
+ title = (p.get("title") or "Untitled")[:80]
434
+ aid = p.get("arxiv_id", "?")
435
+ return f"""<tr>
436
+ <td style="text-align:right;padding-right:1rem;color:#888;font-size:0.8rem">{pct}%</td>
437
+ <td style="width:120px"><div style="height:6px;background:#2a2a2a;border-radius:3px;overflow:hidden"><div style="height:100%;width:{pct}%;background:{bar_color};border-radius:3px"></div></div></td>
438
+ <td><a style="color:var(--fg);text-decoration:none" href="/paper/{aid}">{title}</a></td>
439
+ <td style="color:#888;font-size:0.75rem;text-align:right">{p['_mapped']}/{p['_total']} mapped</td>
440
+ </tr>"""
441
+
442
+ desc_rows = "\n".join(_paper_row(p) for p in desc if p["_total"] > 0)
443
+ asc_rows = "\n".join(_paper_row(p) for p in asc if p["_total"] > 0)
444
+
445
+ return f"""<!doctype html>
446
+ <html lang="en">
447
+ <head>
448
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
449
+ <title>Translation Trace — Différance Engine</title>
450
+ <style>
451
+ :root {{
452
+ --bg: #0d0d0d; --fg: #e0e0e0; --muted: #888; --accent: #ff6b35;
453
+ --math: #4ecdc4; --border: #2a2a2a; --card-bg: #141414;
454
+ }}
455
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
456
+ body {{
457
+ font-family: "IBM Plex Mono","SF Mono","Cascadia Code",monospace;
458
+ background: var(--bg); color: var(--fg); line-height: 1.6;
459
+ max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem;
460
+ }}
461
+ h1 {{ font-size: 1.5rem; font-weight: 700; }}
462
+ h1 span {{ color: var(--accent); }}
463
+ h2 {{ font-size: 1rem; margin: 1.5rem 0 0.75rem; color: var(--muted); }}
464
+ .back {{ color: var(--math); font-size: 0.85rem; text-decoration: none; }}
465
+ .back:hover {{ text-decoration: underline; }}
466
+ table {{ width: 100%; border-collapse: collapse; }}
467
+ td {{ padding: 0.5rem 0; border-bottom: 1px solid #1a1a1a; }}
468
+ tr:hover td {{ background: #111; }}
469
+ .disclaimer {{
470
+ background: #141414; border: 1px solid #2a2a2a; border-radius: 6px;
471
+ padding: 1rem; margin: 1.5rem 0; font-size: 0.85rem; color: var(--muted);
472
+ }}
473
+ .disclaimer strong {{ color: var(--fg); }}
474
+ .section-label {{
475
+ display: inline-block; padding: 0.15rem 0.6rem; border-radius: 3px;
476
+ font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
477
+ margin-bottom: 0.75rem;
478
+ }}
479
+ .section-desc {{ color: var(--math); }}
480
+ .section-asc {{ color: var(--accent); }}
481
+ </style>
482
+ </head>
483
+ <body>
484
+ <p><a class="back" href="/">← The Différance Engine</a></p>
485
+ <h1>Translation <span>Trace</span></h1>
486
+
487
+ <div class="disclaimer">
488
+ <strong>This is not a quality leaderboard.</strong> It describes translation
489
+ efficiency — what fraction of a paper's extracted concepts successfully
490
+ mapped to a declared formal vocabulary (taxonomy v1.0). A high rate means
491
+ the paper's vocabulary overlapped heavily with ours. A low rate means it
492
+ didn't. The low-rate papers are where you should look — for genuine novelty,
493
+ for taxonomy gaps, or for bullshit. The tool doesn't adjudicate. It shows
494
+ the trace and lets you decide.
495
+ </div>
496
+
497
+ <h2><span class="section-label section-desc">↓ Descending</span> Most Translated</h2>
498
+ <p style="color:var(--muted);font-size:0.85rem;margin-bottom:1rem">
499
+ These papers' vocabulary aligned heavily with our formal taxonomy.
500
+ An invitation to understand the mathematical primitives they rest on —
501
+ and to branch out from them.
502
+ </p>
503
+ <table>{desc_rows}</table>
504
+
505
+ <h2><span class="section-label section-asc">↑ Ascending</span> Least Translated</h2>
506
+ <p style="color:var(--muted);font-size:0.85rem;margin-bottom:1rem">
507
+ These papers resisted translation. The gaps might be genuine novelty,
508
+ taxonomy inadequacy, or underspecified language. The unknowns are the
509
+ interesting part — not an error condition, but an invitation to look closer.
510
+ </p>
511
+ <table>{asc_rows}</table>
512
+
513
+ <p style="margin-top:2rem;font-size:0.75rem;color:var(--muted);text-align:center">
514
+ Translation trace against the Différance Engine taxonomy v1.0.0
515
+ (<a style="color:var(--accent)" href="/api/index/analogs">canonical analogs</a> ·
516
+ <a style="color:var(--accent)" href="/api/index/moves">deconstructive moves</a>)
517
+ </p>
518
+ </body>
519
+ </html>"""
520
+
521
+
522
+ def _render_index_html(stats: dict, papers: list[dict]) -> str:
523
+ """Render the main feed page with live DB data.
524
+
525
+ Uses the same CSS as the Astro build so styling stays consistent.
526
+ """
527
+ total = stats.get("total_papers", 0)
528
+ matched = stats.get("by_status", {}).get("matched", 0)
529
+ reduction_rate = stats.get("reduction_rate", 0)
530
+ total_reductions = stats.get("total_reductions", 0)
531
+ reduction_counts = stats.get("reductions_by_type", {})
532
+
533
+ paper_cards = ""
534
+ if papers:
535
+ for paper in papers:
536
+ aid = paper.get("arxiv_id", "?")
537
+ title = paper.get("title", "Untitled")
538
+ updated = (paper.get("updated") or "")[:10]
539
+ categories = paper.get("categories", [])
540
+ if isinstance(categories, str):
541
+ try:
542
+ import json; categories = json.loads(categories)
543
+ except Exception:
544
+ categories = []
545
+ cat_tags = "".join(
546
+ f'<span class="badge" style="background:var(--border);color:var(--muted)">{c}</span>'
547
+ for c in categories[:2]
548
+ )
549
+
550
+ # Fetch reductions for this paper
551
+ reductions_html = ""
552
+ try:
553
+ from pipeline.db import get_db
554
+ db = get_db()
555
+ db.connect()
556
+ reds = db.get_reductions_for_paper(aid)
557
+ db.close()
558
+ if reds:
559
+ red_items = ""
560
+ for r in reds:
561
+ rtype = r.get("result_type", "unknown")
562
+ display = r.get("display", "")
563
+ delta = r.get("genuine_delta", "")
564
+ # Parse display: ~~Term~~ ≡/≈/→ Reduction
565
+ red_items += (
566
+ f'<div class="reduction-item">'
567
+ f'<span class="badge badge-{rtype}">{rtype}</span> '
568
+ f'{_format_reduction_html(display, delta)}'
569
+ f'</div>'
570
+ )
571
+ reductions_html = f'<div class="reduction-list">{red_items}</div>'
572
+ except Exception:
573
+ pass
574
+
575
+ paper_cards += f"""
576
+ <article class="paper-card">
577
+ <div class="paper-meta">
578
+ <span>{updated}</span>
579
+ <a href="https://arxiv.org/abs/{aid}" target="_blank" rel="noopener">{aid}</a>
580
+ {cat_tags}
581
+ </div>
582
+ <h2 class="paper-title">
583
+ <a href="/paper/{aid}">{title}</a>
584
+ </h2>
585
+ {reductions_html}
586
+ </article>"""
587
+
588
+ paper_list_html = (
589
+ f'<div class="paper-list">{paper_cards}</div>' if papers
590
+ else f"""<div class="empty-state">
591
+ <h2>No papers deconstructed yet</h2>
592
+ <p>Trigger a pipeline run to populate the feed:</p>
593
+ <p style="margin-top:1rem">
594
+ <code>POST /api/trigger?arxiv_id=&lt;id&gt;</code>
595
+ </p>
596
+ <p style="margin-top:0.5rem;font-size:0.85rem">
597
+ Set <code>HF_API_KEY</code> as a Space secret for LLM extraction.
598
+ </p>
599
+ </div>"""
600
+ )
601
+
602
+ return f"""<!doctype html>
603
+ <html lang="en">
604
+ <head>
605
+ <meta charset="utf-8" />
606
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
607
+ <title>The Différance Engine — Under Erasure</title>
608
+ <meta name="description" content="Productive deconstruction — what creative destruction looks like under erasure." />
609
+ <style>
610
+ :root {{
611
+ --bg: #0d0d0d; --fg: #e0e0e0; --muted: #888; --accent: #ff6b35;
612
+ --strike: #c44; --math: #4ecdc4; --border: #2a2a2a; --card-bg: #141414;
613
+ --identity: #4ecdc4; --compositional: #ffe66d; --analogy: #a29bfe;
614
+ --unknown: #636e72; --confused: #e17055;
615
+ }}
616
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
617
+ body {{
618
+ font-family: "IBM Plex Mono", "SF Mono", "Cascadia Code", monospace;
619
+ background: var(--bg); color: var(--fg); line-height: 1.6;
620
+ max-width: 900px; margin: 0 auto; padding: 2rem 1.5rem;
621
+ }}
622
+ header {{ border-bottom: 2px solid var(--accent); padding-bottom: 1.5rem; margin-bottom: 2rem; }}
623
+ h1 {{ font-size: 2rem; font-weight: 700; letter-spacing: -0.02em; }}
624
+ h1 span {{ color: var(--accent); }}
625
+ .tagline {{ color: var(--muted); font-style: italic; margin-top: 0.25rem; font-size: 0.95rem; }}
626
+ .stats-bar {{ display: flex; gap: 2rem; margin-top: 1rem; font-size: 0.85rem; color: var(--muted); flex-wrap: wrap; }}
627
+ .stats-bar strong {{ color: var(--fg); }}
628
+ .nav {{ display: flex; gap: 1.5rem; margin-top: 1rem; font-size: 0.9rem; }}
629
+ .nav a {{ color: var(--accent); text-decoration: none; }}
630
+ .nav a:hover {{ text-decoration: underline; }}
631
+ .paper-list {{ display: flex; flex-direction: column; gap: 1.5rem; }}
632
+ .paper-card {{
633
+ background: var(--card-bg); border: 1px solid var(--border);
634
+ border-radius: 6px; padding: 1.25rem; transition: border-color 0.2s;
635
+ }}
636
+ .paper-card:hover {{ border-color: var(--accent); }}
637
+ .paper-meta {{ font-size: 0.8rem; color: var(--muted); margin-bottom: 0.5rem; display: flex; gap: 1rem; flex-wrap: wrap; }}
638
+ .paper-meta a {{ color: var(--accent); text-decoration: none; }}
639
+ .paper-title {{ font-size: 1.1rem; font-weight: 600; margin-bottom: 0.75rem; }}
640
+ .paper-title a {{ color: var(--fg); text-decoration: none; }}
641
+ .paper-title a:hover {{ color: var(--accent); }}
642
+ .reduction-list {{ display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.75rem; }}
643
+ .reduction-item {{ font-size: 0.9rem; }}
644
+ .reduction-item .ai-term {{ text-decoration: line-through; color: var(--strike); margin-right: 0.5rem; }}
645
+ .reduction-item .math-term {{ color: var(--math); font-weight: 600; }}
646
+ .reduction-item .connector {{ color: var(--muted); }}
647
+ .reduction-item .delta {{ color: var(--accent); font-size: 0.8rem; }}
648
+ .badge {{
649
+ display: inline-block; padding: 0.1rem 0.5rem; border-radius: 3px;
650
+ font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em;
651
+ }}
652
+ .badge-identity {{ background: #1a3a3a; color: var(--identity); }}
653
+ .badge-compositional {{ background: #3a3a1a; color: var(--compositional); }}
654
+ .badge-analogy {{ background: #2a2a3a; color: var(--analogy); }}
655
+ .badge-unknown {{ background: #1a1a1a; color: var(--unknown); }}
656
+ .badge-confused {{ background: #3a1a1a; color: var(--confused); }}
657
+ .expand-toggle {{ display: inline-block; margin-left: 0.3rem; }}
658
+ .expand-toggle:hover {{ color: #fff !important; }}
659
+ .expansion-chain {{ display: block; padding: 0.25rem 0 0.25rem 0; word-break: break-all; }}
660
+ footer {{ margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border); font-size: 0.8rem; color: var(--muted); text-align: center; }}
661
+ footer a {{ color: var(--accent); }}
662
+ .empty-state {{ text-align: center; padding: 4rem 1rem; color: var(--muted); }}
663
+ .empty-state h2 {{ font-size: 1.5rem; margin-bottom: 0.5rem; color: var(--fg); }}
664
+ .empty-state code {{ background: var(--card-bg); padding: 0.2rem 0.5rem; border-radius: 3px; font-size: 0.85rem; }}
665
+ </style>
666
+ </head>
667
+ <body>
668
+ <header>
669
+ <h1>The Différ<span>a</span>nce Engine</h1>
670
+ <p class="tagline">Productive deconstruction — what creative destruction looks like under erasure.</p>
671
+ <div class="stats-bar">
672
+ <span><strong>{total}</strong> papers ingested</span>
673
+ <span><strong>{matched}</strong> deconstructed</span>
674
+ <span><strong>{int(reduction_rate * 100)}%</strong> reduction rate</span>
675
+ <span><strong>{total_reductions}</strong> reductions</span>
676
+ </div>
677
+ <nav class="nav">
678
+ <a href="/">Feed</a>
679
+ <a href="/lookup">Lookup</a>
680
+ <a href="/discourse">Translation Trace</a>
681
+ <a href="https://arxiv.org/list/cs.LG/recent" target="_blank" rel="noopener">arXiv cs.LG →</a>
682
+ </nav>
683
+ </header>
684
+ <main>
685
+ {paper_list_html}
686
+ </main>
687
+ <footer>
688
+ <p>The Différance Engine — <a href="https://under-erasure.graziul.io">under-erasure.graziul.io</a></p>
689
+ <p style="margin-top:0.25rem">
690
+ Powered by a formalism KB of 59 canonical mathematical operations.
691
+ All reductions are <em>sous rature</em>: the AI term is crossed out;
692
+ the math beneath is what the paper actually computes.
693
+ </p>
694
+ </footer>
695
+ </body>
696
+ </html>"""
697
+
698
+
699
+ def _format_reduction_html(display: str, delta: str) -> str:
700
+ """Parse a sous-rature display string like '~~GLIGEN~~ ≡ Diffusion Process ∘ diffuse'
701
+ into formatted HTML spans. Handles expandable canonical decomposition chains."""
702
+ import re
703
+ import html as _html
704
+
705
+ # Check for embedded expansion data (NUL-delimited sentinels from match.py)
706
+ expand_html = ""
707
+ if "\x00EXPAND\x00" in display:
708
+ parts = display.split("\x00EXPAND\x00", 1)
709
+ display = parts[0]
710
+ expanded = parts[1].split("\x00/EXPAND\x00", 1)[0] if "\x00/EXPAND\x00" in parts[1] else ""
711
+ if expanded:
712
+ expanded = _html.escape(expanded)
713
+ uid = abs(hash(expanded)) % 100000
714
+ expand_html = (
715
+ f' <span class="expand-toggle" onclick="'
716
+ f'var e=document.getElementById(\'exp-{uid}\');'
717
+ f'var t=this;'
718
+ f'if(e.style.display==\'none\'){{e.style.display=\'block\';t.textContent=\'[−]\'}}'
719
+ f'else{{e.style.display=\'none\';t.textContent=\'[+]\'}}'
720
+ f'" style="cursor:pointer;color:#ffe66d;font-size:0.8rem;user-select:none">[+]</span>'
721
+ f'<span id="exp-{uid}" class="expansion-chain" style="display:none;font-size:0.8rem;color:#ffe66d;margin-left:1.5rem">'
722
+ f'└ {expanded}</span>'
723
+ )
724
+
725
+ # Match ~~struck term~~ then connector (=/~/->) then math term
726
+ m = re.match(r"~~(.+?)~~\s*(≡|≈|→)\s*(.+?)(?:\s*\(Δ:\s*(.+?)\))?$", display)
727
+ if m:
728
+ struck = m.group(1)
729
+ connector = m.group(2)
730
+ math_term = m.group(3)
731
+ inner_delta = m.group(4) or delta
732
+ delta_html = f' <span class="delta">(Δ: {inner_delta})</span>' if inner_delta else ""
733
+ # Link recognized formalism names to canonical pages
734
+ math_term = _link_formalism_names(math_term)
735
+ return (
736
+ f'<span class="ai-term">{struck}</span>'
737
+ f'<span class="connector">{connector}</span> '
738
+ f'<span class="math-term">{math_term}</span>'
739
+ f'{delta_html}'
740
+ f'{expand_html}'
741
+ )
742
+ # Fallback: just escape and display (still check for expansion)
743
+ return _html.escape(display) + expand_html
744
+
745
+
746
+ # Cache for formalism name -> ID mapping (populated lazily)
747
+ _formalism_name_to_id: dict[str, str] | None = None
748
+
749
+
750
+ def _get_formalism_name_map() -> dict[str, str]:
751
+ """Return a mapping from lowercased formalism names to their KB IDs.
752
+ Used for linking formalism names in reductions to canonical pages."""
753
+ global _formalism_name_to_id
754
+ if _formalism_name_to_id is not None:
755
+ return _formalism_name_to_id
756
+ try:
757
+ import yaml
758
+ from pathlib import Path
759
+ kb_path = Path(__file__).resolve().parent / "pipeline" / "kb" / "formalisms.yaml"
760
+ with open(kb_path) as f:
761
+ data = yaml.safe_load(f)
762
+ _formalism_name_to_id = {}
763
+ for entry in data.get("formalisms", []):
764
+ fid = entry.get("id", "")
765
+ name = (entry.get("name", "") or "").lower()
766
+ if name and fid:
767
+ _formalism_name_to_id[name] = fid
768
+ return _formalism_name_to_id
769
+ except Exception:
770
+ return {}
771
+
772
+
773
+ def _link_formalism_names(text: str) -> str:
774
+ """Wrap known formalism names in links to their canonical pages.
775
+ Returns HTML with <a> tags for recognized formalisms."""
776
+ import re as _re
777
+ fm_map = _get_formalism_name_map()
778
+ if not fm_map:
779
+ return text
780
+
781
+ # Sort by length descending to match longest names first
782
+ for name in sorted(fm_map.keys(), key=len, reverse=True):
783
+ fid = fm_map[name]
784
+ # Case-insensitive replacement, but only whole-word-ish
785
+ pattern = _re.compile(_re.escape(name), _re.IGNORECASE)
786
+ text = pattern.sub(
787
+ f'<a style="color:#4ecdc4;text-decoration:none" '
788
+ f'href="/canonical/{fid}" title="Canonical formalism page">{name}</a>',
789
+ text
790
+ )
791
+ return text
792
+
793
+
794
+ def _fallback_html(msg: str = "") -> str:
795
+ message = msg or (
796
+ "The Différance Engine is live but no papers have been deconstructed yet. "
797
+ "Set <code>HF_API_KEY</code> as a Space secret and trigger a pipeline run."
798
+ )
799
+ return f"""<!doctype html>
800
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
801
+ <title>The Différance Engine</title>
802
+ <style>
803
+ body {{ font-family:"IBM Plex Mono",monospace; background:#0d0d0d; color:#e0e0e0;
804
+ max-width:700px; margin:4rem auto; padding:2rem; text-align:center; }}
805
+ h1 {{ color:#ff6b35; }} a {{ color:#4ecdc4; }}
806
+ code {{ background:#141414; padding:0.2rem 0.5rem; border-radius:3px; }}
807
+ </style></head><body>
808
+ <h1>The Différance Engine</h1>
809
+ <p>Productive deconstruction — what creative destruction looks like under erasure.</p>
810
+ <p style="margin-top:2rem;color:#888">{message}</p>
811
+ <p style="margin-top:2rem">
812
+ <a href="/api/stats">Stats</a> · <a href="/api/models">Models</a> · <a href="/lookup">Lookup</a>
813
+ </p>
814
+ </body></html>"""
815
+
816
+
817
+ def _render_paper_html(paper: dict, reductions: list[dict]) -> str:
818
+ title = paper.get("title", "Untitled")
819
+ arxiv_id = paper.get("arxiv_id", "?")
820
+ abstract = paper.get("abstract", "")
821
+ # --- Reductions ---
822
+ reds = ""
823
+ for r in reductions:
824
+ disp = r.get("display", f"~~{r.get('concept_name','')}~~ → {r.get('reduction','')}")
825
+ delta = r.get("genuine_delta", "")
826
+ rtype = r.get("result_type", "unknown")
827
+ analog = r.get("canonical_analog", "")
828
+ reds += f"""<div style="background:#141414;border:1px solid #2a2a2a;border-radius:6px;padding:1rem;margin:0.75rem 0">
829
+ <p style="font-size:1.1rem;font-weight:600">{_format_reduction_html(disp, delta)}</p>
830
+ <p style="font-size:0.8rem;color:#888">
831
+ <span style="display:inline-block;padding:0.1rem 0.5rem;border-radius:3px;font-size:0.7rem;font-weight:600;text-transform:uppercase;background:{'#1a3a3a' if rtype=='identity' else '#3a3a1a' if rtype=='compositional' else '#2a2a3a' if rtype=='analogy' else '#1a1a1a'};color:{'#4ecdc4' if rtype=='identity' else '#ffe66d' if rtype=='compositional' else '#a29bfe' if rtype=='analogy' else '#636e72'}">{rtype}</span>
832
+ micro: {r.get('micro','?')} | meso: {r.get('meso','?')} | macro: {r.get('macro','?')}
833
+ </p>
834
+ </div>"""
835
+
836
+ # --- Cross-references: canonical analogs shared with other papers ---
837
+ from pipeline.db import get_db
838
+ xref_html = ""
839
+ try:
840
+ db = get_db(); db.connect()
841
+ analogs_seen = set()
842
+ for r in reductions:
843
+ analog = r.get("canonical_analog", "")
844
+ if analog and analog not in analogs_seen:
845
+ analogs_seen.add(analog)
846
+ related = db.get_papers_by_analog(analog, limit=8)
847
+ related = [p for p in related if p["arxiv_id"] != arxiv_id]
848
+ if related:
849
+ short_analog = analog[:80] + ("…" if len(analog) > 80 else "")
850
+ items = "".join(
851
+ f'<li style="margin:0.2rem 0;font-size:0.85rem"><a style="color:#4ecdc4" href="/paper/{p["arxiv_id"]}">{p["title"]}</a> <span style="color:#888;font-size:0.75rem">({p["arxiv_id"]})</span></li>'
852
+ for p in related[:5]
853
+ )
854
+ xref_html += f"""<div style="background:#141414;border:1px solid #2a2a2a;border-radius:6px;padding:0.75rem;margin:0.5rem 0">
855
+ <p style="font-size:0.8rem;color:#888">Also reduces to <span style="color:#ffe66d">{short_analog}</span>:</p>
856
+ <ul style="list-style:none;padding:0">{items}</ul>
857
+ </div>"""
858
+ db.close()
859
+ except Exception:
860
+ pass
861
+
862
+ return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
863
+ <meta name="viewport" content="width=device-width,initial-scale=1">
864
+ <title>{title} — Différance Engine</title>
865
+ <style>
866
+ body {{ font-family:"IBM Plex Mono",monospace; background:#0d0d0d; color:#e0e0e0;
867
+ max-width:900px; margin:2rem auto; padding:1.5rem; line-height:1.6; }}
868
+ h1 {{ font-size:1.3rem; }} h1 a {{ color:#ff6b35; text-decoration:none; }}
869
+ .abstract {{ background:#141414; border:1px solid #2a2a2a; border-radius:6px; padding:1rem; margin:1rem 0; font-size:0.9rem; }}
870
+ .back {{ color:#4ecdc4; font-size:0.85rem; }}
871
+ .ai-term {{ text-decoration:line-through; color:#c44; }}
872
+ .math-term {{ color:#4ecdc4; font-weight:600; }}
873
+ .connector {{ color:#888; }}
874
+ .delta {{ color:#ff6b35; font-size:0.85rem; }}
875
+ .expand-toggle {{ display:inline-block; margin-left:0.3rem; }}
876
+ .expand-toggle:hover {{ color:#fff !important; }}
877
+ .expansion-chain {{ display:block; padding:0.25rem 0; word-break:break-all; }}
878
+ </style></head><body>
879
+ <p><a class="back" href="/">← The Différance Engine</a></p>
880
+ <h1><a href="https://arxiv.org/abs/{arxiv_id}">{title}</a></h1>
881
+ <p style="color:#888;font-size:0.8rem">{arxiv_id} · {paper.get('updated','?')[:10]}</p>
882
+ <details class="abstract"><summary>Abstract</summary>{abstract}</details>
883
+ <h2 style="font-size:1rem;margin-top:1.5rem;color:#888">Reductions</h2>
884
+ {reds}
885
+ {f'<h2 style="font-size:1rem;margin-top:1.5rem;color:#888">Cross-References</h2>{xref_html}' if xref_html else ''}
886
+ </body></html>"""
887
+
888
+
889
+ def _render_canonical_html(fm, citing_papers, canonical_arxiv, canonical_paper, canonical_reductions) -> str:
890
+ """Render a page for a canonical formalism, optionally with its orienting paper."""
891
+ import html as _html
892
+
893
+ sig = fm.signature
894
+ sig_str = f"{sig.operation or '?'}({sig.domain or '?'} -> {sig.codomain or '?'})"
895
+ if sig.objective_family:
896
+ sig_str += f" objective={sig.objective_family}"
897
+
898
+ # Canonical paper section
899
+ canonical_html = ""
900
+ if canonical_arxiv:
901
+ if canonical_paper:
902
+ cp_title = _html.escape(canonical_paper.get("title", "Untitled"))
903
+ cp_id = canonical_paper["arxiv_id"]
904
+ reds_html = ""
905
+ for r in canonical_reductions:
906
+ disp = r.get("display", "")
907
+ delta = r.get("genuine_delta", "")
908
+ rtype = r.get("result_type", "unknown")
909
+ reds_html += (
910
+ f'<div style="background:#111;border:1px solid #2a2a2a;'
911
+ f'border-radius:4px;padding:0.75rem;margin:0.5rem 0;font-size:0.9rem">'
912
+ f'{_format_reduction_html(disp, delta)}'
913
+ f'<span style="display:inline-block;margin-left:0.5rem;padding:0.1rem 0.4rem;'
914
+ f'border-radius:2px;font-size:0.65rem;background:#1a3a1a;color:#ffe66d;'
915
+ f'vertical-align:middle">{rtype}</span></div>'
916
+ )
917
+ canonical_html = f"""
918
+ <div style="background:#141414;border:1px solid #ffe66d;border-radius:6px;padding:1rem;margin:1rem 0">
919
+ <p style="color:#ffe66d;font-weight:600;margin-bottom:0.5rem">Canonical Paper (Orienting Document)</p>
920
+ <p><a style="color:#4ecdc4;font-size:1.1rem" href="/paper/{cp_id}">{cp_title}</a></p>
921
+ <p style="color:#888;font-size:0.8rem">{cp_id}{' — ' + str(canonical_paper.get('citation_count','')) + ' citations' if canonical_paper.get('citation_count') else ''}</p>
922
+ <details style="margin-top:0.5rem"><summary style="color:#888;font-size:0.85rem;cursor:pointer">Deconstruction ({len(canonical_reductions)} concepts)</summary>
923
+ {reds_html}
924
+ </details>
925
+ </div>"""
926
+ else:
927
+ canonical_html = f"""
928
+ <div style="background:#141414;border:1px dashed #ffe66d;border-radius:6px;padding:1rem;margin:1rem 0">
929
+ <p style="color:#ffe66d;font-weight:600;margin-bottom:0.5rem">Canonical Paper: <span style="font-family:monospace">{canonical_arxiv}</span></p>
930
+ <p style="color:#888;font-size:0.85rem">Not yet ingested. Trigger deconstruction to see the raw formalism beneath the vocabulary.</p>
931
+ <form method="POST" action="/api/trigger?arxiv_id={canonical_arxiv}" style="margin-top:0.75rem" onsubmit="var b=this.querySelector('button');b.disabled=true;b.textContent='Deconstructing...';return true">
932
+ <button style="background:#ffe66d;color:#0d0d0d;border:none;padding:0.4rem 1rem;border-radius:4px;cursor:pointer;font-family:inherit;font-weight:600">Deconstruct Canonical Paper</button>
933
+ </form>
934
+ </div>"""
935
+
936
+ # Citing papers table
937
+ citing_rows = ""
938
+ if citing_papers:
939
+ for p in citing_papers[:20]:
940
+ aid = p.get("arxiv_id", "?")
941
+ title = (p.get("title") or "Untitled")[:80]
942
+ citing_rows += (
943
+ f'<tr><td><a style="color:var(--fg);text-decoration:none" '
944
+ f'href="/paper/{aid}">{_html.escape(title)}</a></td>'
945
+ f'<td style="color:#888;font-size:0.75rem;text-align:right">{aid}</td></tr>'
946
+ )
947
+ citing_section = (
948
+ f'<h2 style="font-size:1rem;margin-top:1.5rem;color:#888">'
949
+ f'Papers Reducing to This Formalism ({len(citing_papers)})</h2>'
950
+ f'<table style="width:100%;border-collapse:collapse">{citing_rows}</table>'
951
+ ) if citing_papers else ""
952
+
953
+ return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
954
+ <meta name="viewport" content="width=device-width,initial-scale=1">
955
+ <title>{fm.name} — Canonical Formalism — Différance Engine</title>
956
+ <style>
957
+ body {{ font-family:"IBM Plex Mono",monospace; background:#0d0d0d; color:#e0e0e0;
958
+ max-width:900px; margin:2rem auto; padding:1.5rem; line-height:1.6; }}
959
+ h1 {{ font-size:1.3rem; }} .back {{ color:#4ecdc4; font-size:0.85rem; text-decoration:none; }}
960
+ .back:hover {{ text-decoration:underline; }}
961
+ .prop-table {{ width:100%; border-collapse:collapse; font-size:0.9rem; }}
962
+ .prop-table td {{ padding:0.35rem 0.5rem; border-bottom:1px solid #1a1a1a; }}
963
+ .prop-table td:first-child {{ color:#888; width:160px; }}
964
+ .prop-table td:last-child {{ color:#e0e0e0; }}
965
+ .ai-term {{ text-decoration:line-through; color:#c44; }}
966
+ .math-term {{ color:#4ecdc4; font-weight:600; }}
967
+ .connector {{ color:#888; }}
968
+ .delta {{ color:#ff6b35; font-size:0.85rem; }}
969
+ .expand-toggle {{ display:inline-block; margin-left:0.3rem; }}
970
+ .expand-toggle:hover {{ color:#fff !important; }}
971
+ .expansion-chain {{ display:block; padding:0.25rem 0; word-break:break-all; }}
972
+ table {{ width:100%; border-collapse:collapse; }}
973
+ td {{ padding:0.35rem 0; border-bottom:1px solid #1a1a1a; }}
974
+ tr:hover td {{ background:#111; }}
975
+ </style></head><body>
976
+ <p><a class="back" href="/">← The Différance Engine</a></p>
977
+ <h1 style="color:#ffe66d">{_html.escape(fm.name)}</h1>
978
+ <p style="color:#888;font-size:0.85rem">
979
+ {fm.year or '?'} · {_html.escape(fm.origin or '')} · {fm.status}
980
+ </p>
981
+ <table class="prop-table" style="margin-top:1rem">
982
+ <tr><td>Operation</td><td>{sig.operation or '?'}</td></tr>
983
+ <tr><td>Domain</td><td>{sig.domain or '?'}</td></tr>
984
+ <tr><td>Codomain</td><td>{sig.codomain or '?'}</td></tr>
985
+ <tr><td>Objective</td><td>{sig.objective_family or '?'}</td></tr>
986
+ <tr><td>Meso-type</td><td>{fm.meso_type or 'none'}</td></tr>
987
+ <tr><td>Macro-type</td><td>{fm.macro_type or 'none'}</td></tr>
988
+ <tr><td>Reference</td><td style="font-size:0.85rem">{_html.escape(fm.canonical_reference or '')}</td></tr>
989
+ </table>
990
+ {canonical_html}
991
+ {citing_section}
992
+ <p style="margin-top:2rem;font-size:0.75rem;color:#888;text-align:center">
993
+ Canonical formalism in the Différance Engine taxonomy. ·
994
+ <a style="color:#ff6b35" href="/api/canonical/{fm.id}">JSON</a>
995
+ </p>
996
+ </body></html>"""