use gradio server

#1
by akhaliq HF Staff - opened
Files changed (2) hide show
  1. app.py +52 -376
  2. index.html +533 -0
app.py CHANGED
@@ -1,5 +1,11 @@
1
  """Pulpie vs Dripper β€” encoder vs decoder content extraction, live.
2
 
 
 
 
 
 
 
3
  Both models receive the SAME simplified HTML (pulpie's MinerU-HTML-parity
4
  simplify). Pulpie classifies every block in one encoder forward pass; Dripper
5
  (MinerU-HTML 0.6B) emits its answer autoregressively, one token at a time.
@@ -7,15 +13,18 @@ simplify). Pulpie classifies every block in one encoder forward pass; Dripper
7
 
8
  from __future__ import annotations
9
 
 
10
  import re
11
  import threading
12
  import time
13
  from pathlib import Path
14
 
15
- import gradio as gr
16
  import spaces
17
  import torch
18
  from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
 
 
 
19
 
20
  from pulpie.chunker import extract_blocks, pack_chunks, tokenize_blocks
21
  from pulpie.markdown import to_markdown
@@ -174,7 +183,7 @@ def labels_to_markdown(map_html: str, labels: dict[str, str]) -> str:
174
  return to_markdown(extract_main_html(map_html, labels))
175
 
176
 
177
- # ── The race ──
178
 
179
  MONO = "font-family: 'IBM Plex Mono', monospace;"
180
 
@@ -188,23 +197,27 @@ def fmt_timer(seconds: float, done: bool, color: str = "#1B1714") -> str:
188
 
189
 
190
  def race(example_name: str, custom_html: str):
191
- """Generator driving both panes. Yields (pulpie_md, pulpie_timer, dripper_md, dripper_timer, verdict)."""
 
192
  if custom_html and custom_html.strip():
193
  html = custom_html
194
  elif example_name in EXAMPLES:
195
  html = (EXAMPLES_DIR / EXAMPLES[example_name]).read_text(errors="replace")
196
  else:
197
- yield "", "", "", "", "Pick an example page or paste HTML."
 
198
  return
199
  if len(html.encode()) > MAX_HTML_BYTES:
200
- yield "", "", "", "", "HTML too large (2 MB cap)."
 
201
  return
202
 
203
  # Shared preprocessing β€” identical input to both models.
204
  try:
205
  simplified, map_html = simplify(html)
206
  except Exception as e:
207
- yield "", "", "", "", f"simplify failed: {e}"
 
208
  return
209
 
210
  # Pulpie. Timed on-GPU inside the @spaces.GPU call so ZeroGPU
@@ -212,17 +225,22 @@ def race(example_name: str, custom_html: str):
212
  pulpie_labels, pulpie_s = pulpie_classify(simplified)
213
  pulpie_md = labels_to_markdown(map_html, pulpie_labels) or "*No main content detected.*"
214
  pulpie_timer = fmt_timer(pulpie_s, done=True, color="#C6531D")
215
- yield pulpie_md, pulpie_timer, "", fmt_timer(0.0, done=False), ""
 
216
 
217
  # Dripper, streaming.
218
  raw, dripper_s = "", 0.0
219
  for acc, gen_s, err in dripper_generate_stream(simplified):
220
  if err:
221
- yield pulpie_md, pulpie_timer, f"*{err}*", fmt_timer(gen_s, done=True), ""
 
 
222
  return
223
  raw, dripper_s = acc, gen_s
224
  preview = f"```\n…{raw[-1500:]}\n```" if raw else "*waiting for first token…*"
225
- yield pulpie_md, pulpie_timer, preview, fmt_timer(dripper_s, done=False), ""
 
 
226
 
227
  dripper_md = labels_to_markdown(map_html, parse_dripper_labels(raw)) or "*No main content detected.*"
228
  speedup = dripper_s / max(pulpie_s, 1e-6)
@@ -233,381 +251,39 @@ def race(example_name: str, custom_html: str):
233
  f"PULPIE {pulpie_s:.2f}s β†’ DRIPPER {dripper_s:.1f}s Β· "
234
  f'<span style="color:#C6531D; font-weight:600;">{speedup_str} ON THIS PAGE</span></div>'
235
  )
236
- yield pulpie_md, pulpie_timer, dripper_md, fmt_timer(dripper_s, done=True), verdict
237
-
238
-
239
- # ── UI ──
240
-
241
- CSS = """
242
- :root, .light, .dark {
243
- --paper: #F2EDE3; --raised: #FBF7F0; --sunken: #ECE5D7;
244
- --ink: #1B1714; --body-ink: #3F382F; --soft: #756A5E;
245
- --orange: #C6531D; --orange-hover: #E0823A; --pine: #2F5A3C;
246
- --hairline: #D7CBB6;
247
- color-scheme: light;
248
- /* Pin the Gradio variables dark mode flips β€” text must stay ink on paper. */
249
- --body-text-color: var(--ink);
250
- --body-text-color-subdued: var(--soft);
251
- --block-label-text-color: var(--soft);
252
- --block-label-background-fill: var(--paper);
253
- --block-background-fill: var(--raised);
254
- --block-title-text-color: var(--ink);
255
- --input-background-fill: var(--raised);
256
- --input-background-fill-focus: var(--raised);
257
- --input-border-color: var(--hairline);
258
- --body-background-fill: var(--paper);
259
- --background-fill-primary: var(--paper);
260
- --background-fill-secondary: var(--raised);
261
- --border-color-primary: var(--hairline);
262
- --block-border-color: var(--hairline);
263
- --table-text-color: var(--ink);
264
- --table-even-background-fill: var(--raised);
265
- --table-odd-background-fill: var(--paper);
266
- --button-primary-background-fill: var(--orange);
267
- --button-primary-background-fill-hover: var(--orange-hover);
268
- --button-primary-border-color: var(--orange);
269
- --button-primary-text-color: #FBF7F0;
270
- --button-secondary-background-fill: var(--paper);
271
- --button-secondary-background-fill-hover: var(--sunken);
272
- --button-secondary-border-color: var(--hairline);
273
- --button-secondary-text-color: var(--ink);
274
- --button-secondary-text-color-hover: var(--orange);
275
- --error-background-fill: #FFF3EC;
276
- --error-text-color: #8A2E16;
277
- --error-border-color: var(--orange);
278
- --error-icon-color: var(--orange);
279
- --error-500: var(--orange);
280
- --error-600: #A84418;
281
- /* Markdown prose has its own variable set β€” headings went white without these. */
282
- --prose-text-color: var(--body-ink);
283
- --prose-header-text-color: var(--ink);
284
- --link-text-color: var(--orange);
285
- --link-text-color-hover: var(--orange-hover);
286
- --link-text-color-visited: var(--orange);
287
- }
288
- /* Direct pins in case any prose rule bypasses the variables. */
289
- .result-pane h1, .result-pane h2, .result-pane h3,
290
- .result-pane h4, .result-pane h5, .result-pane h6,
291
- .prose h1, .prose h2, .prose h3, .prose h4, .prose h5, .prose h6 {
292
- color: var(--ink) !important;
293
- }
294
- .result-pane a, .prose a { color: var(--orange) !important; }
295
- .dark .gradio-container, .dark .gradio-container * { color: var(--ink); }
296
- html, body, gradio-app {
297
- background: var(--paper) !important;
298
- color: var(--ink) !important;
299
- }
300
- /* Paper everywhere β€” the page behind the container, not just the container. */
301
- body, gradio-app, .gradio-container, main, .main, .wrap, .contain {
302
- background: var(--paper) !important;
303
- }
304
- .gradio-container {
305
- font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif !important;
306
- color: var(--ink) !important;
307
- max-width: 1280px !important;
308
- margin: 0 auto !important;
309
- }
310
- .gradio-container * { border-radius: 0 !important; box-shadow: none !important; }
311
 
312
- #eyebrow {
313
- font-family: 'IBM Plex Mono', monospace; font-size: 12px;
314
- letter-spacing: 0.18em; color: var(--orange); text-transform: uppercase;
315
- }
316
- #headline h1 {
317
- font-weight: 700; letter-spacing: -0.035em; font-size: 44px;
318
- color: var(--ink); margin: 6px 0 4px;
319
- }
320
- #lead { font-family: 'Lora', serif; font-size: 19px; color: var(--body-ink); font-style: italic; }
321
- #header-block { border-bottom: 1px solid var(--hairline); padding-bottom: 24px; }
322
-
323
- .pane-label {
324
- font-family: 'IBM Plex Mono', monospace; font-size: 11px;
325
- letter-spacing: 0.12em; color: var(--soft); text-transform: uppercase;
326
- border-bottom: 1px solid var(--hairline); padding-bottom: 8px;
327
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
328
- }
329
- .result-pane {
330
- background: var(--raised) !important; border: 1px solid var(--hairline) !important;
331
- min-height: 220px; max-height: 560px; overflow-y: auto; padding: 18px !important;
332
- }
333
- /* Gradio nests blocks inside blocks β€” kill inner borders/padding so panes read as one surface. */
334
- .result-pane > *, .result-pane .prose, .result-pane .block {
335
- border: none !important; background: transparent !important; padding: 0 !important;
336
- }
337
- .result-pane h1, .result-pane h2, .result-pane h3 { font-weight: 700; letter-spacing: -0.02em; }
338
- .result-pane p { font-family: 'Lora', serif; color: var(--body-ink); }
339
 
340
- .input-eyebrow {
341
- font-family: 'IBM Plex Mono', monospace; font-size: 11px;
342
- letter-spacing: 0.14em; color: var(--soft); text-transform: uppercase;
343
- margin: 8px 0 6px;
344
- }
345
- /* Selector + button: same height, same hairline treatment, flush row.
346
- The dropdown's outer .block is Gradio's white card β€” flatten it entirely
347
- and let the input be the only visible surface. */
348
- #input-row { align-items: center; gap: 12px; }
349
- #input-row > * { margin: 0 !important; }
350
- /* Both children are exactly 48px and centered in the row β€” no stray offsets. */
351
- #input-row > .block, #input-row > button { align-self: center !important; }
352
- #input-row .svelte-1xfsv4t { margin: 0 !important; }
353
- #input-row .block {
354
- background: transparent !important; border: none !important; padding: 0 !important;
355
- overflow: visible !important;
356
- }
357
- #input-row .wrap, #input-row .wrap-inner, #input-row .secondary-wrap {
358
- background: transparent !important; border: none !important;
359
- padding: 0 !important; height: 100%;
360
- }
361
- #input-row input {
362
- background: var(--raised) !important;
363
- border: 1px solid var(--hairline) !important; height: 48px !important;
364
- font-family: 'IBM Plex Mono', monospace !important; font-size: 13px !important;
365
- color: var(--ink) !important; padding: 0 14px !important;
366
- cursor: pointer;
367
- }
368
- #input-row input:hover, #input-row input:focus {
369
- border-color: var(--orange) !important;
370
- }
371
- #input-row .icon-wrap { right: 14px; }
372
- #input-row .icon-wrap svg { fill: var(--soft); }
373
- #input-row button.primary { height: 48px !important; min-height: 48px !important; }
374
- /* Dropdown options list: paper card, hairline, mono β€” not Gradio's rounded white. */
375
- ul.options, .options {
376
- background: var(--raised) !important; border: 1px solid var(--hairline) !important;
377
- font-family: 'IBM Plex Mono', monospace !important; font-size: 13px !important;
378
- color: var(--ink) !important;
379
- }
380
- ul.options li.item, .options .item { padding: 10px 14px !important; }
381
- ul.options li.item.selected, .options .item.selected,
382
- ul.options li.item:hover, .options .item:hover {
383
- background: var(--sunken) !important; color: var(--orange) !important;
384
- }
385
 
386
- button.primary {
387
- background: var(--orange) !important; color: #FBF7F0 !important;
388
- font-family: 'IBM Plex Mono', monospace !important; letter-spacing: 0.1em;
389
- text-transform: uppercase; border: none !important;
390
- }
391
- button.primary, button.primary *, button.primary span { color: #FBF7F0 !important; }
392
- button.primary:hover { background: var(--orange-hover) !important; }
393
- button.secondary {
394
- background: var(--paper) !important; color: var(--ink) !important;
395
- border: 1px solid var(--hairline) !important;
396
- font-family: 'IBM Plex Mono', monospace !important; font-size: 12px !important;
397
- letter-spacing: 0.08em;
398
- }
399
- button.secondary:hover { color: var(--orange) !important; border-color: var(--orange) !important; }
400
 
401
- #bench-well {
402
- background: var(--sunken); border: 1px solid var(--hairline);
403
- padding: 20px 24px; margin-top: 8px;
404
- }
405
- #bench-well table { width: 100%; font-family: 'IBM Plex Mono', monospace; font-size: 13px; }
406
- #footer-mono {
407
- font-family: 'IBM Plex Mono', monospace; font-size: 12px; letter-spacing: 0.08em;
408
- color: var(--soft); border-top: 1px solid var(--hairline); padding-top: 16px;
409
- }
410
- #footer-mono a { color: var(--ink); text-decoration: none; }
411
- #footer-mono a:hover { color: var(--orange); }
412
- textarea { background: var(--raised) !important; border: 1px solid var(--hairline) !important; }
413
- input, textarea {
414
- color: var(--ink) !important;
415
- -webkit-text-fill-color: var(--ink) !important;
416
- }
417
- input::placeholder, textarea::placeholder {
418
- color: var(--soft) !important;
419
- opacity: 0.78 !important;
420
- }
421
- /* Accordion: hairline bar on paper, mono label β€” not a white card.
422
- The white comes from the .block wrapper Gradio puts around the accordion. */
423
- #html-accordion, #html-accordion.block {
424
- background: transparent !important;
425
- border: 1px solid var(--hairline) !important;
426
- }
427
- button.label-wrap { background: transparent !important; border: none !important; }
428
- /* Inner blocks of the accordion (the Textbox card) inherit paper, no border. */
429
- #html-accordion .block {
430
- background: transparent !important; border: none !important; padding: 0 !important;
431
- }
432
- button.label-wrap span {
433
- font-family: 'IBM Plex Mono', monospace !important; font-size: 11px !important;
434
- letter-spacing: 0.14em; text-transform: uppercase; color: var(--soft) !important;
435
- }
436
- button.label-wrap:hover span { color: var(--orange) !important; }
437
- #input-row svg, #html-accordion svg, button.label-wrap svg {
438
- color: var(--soft) !important;
439
- fill: var(--soft) !important;
440
- stroke: var(--soft) !important;
441
- }
442
- ul.options *, .options * { color: var(--ink) !important; }
443
- ul.options li.item.selected, .options .item.selected,
444
- ul.options li.item:hover, .options .item:hover {
445
- color: var(--orange) !important;
446
- }
447
- .result-pane li, .result-pane strong, .result-pane em,
448
- .result-pane blockquote, .result-pane code, .result-pane pre {
449
- color: var(--body-ink) !important;
450
- }
451
- .result-pane pre, .result-pane code {
452
- background: var(--sunken) !important;
453
- border-color: var(--hairline) !important;
454
- }
455
- .error, .validation-error {
456
- background: #FFF3EC !important;
457
- color: #8A2E16 !important;
458
- border-color: var(--orange) !important;
459
- }
460
- .toast-body {
461
- background: var(--raised) !important;
462
- color: var(--ink) !important;
463
- border: 1px solid var(--hairline) !important;
464
- }
465
- .toast-body.error {
466
- --toast-color: var(--orange) !important;
467
- border-color: var(--orange) !important;
468
- }
469
- .toast-title, .toast-count, .toast-message-text, .toast-close,
470
- .toast-body .toast-message-item, .toast-body * {
471
- color: var(--ink) !important;
472
- }
473
- .toast-icon, .toast-icon *, .toast-body.error .toast-icon,
474
- .toast-body.error .toast-icon * {
475
- color: var(--orange) !important;
476
- fill: var(--orange) !important;
477
- stroke: var(--orange) !important;
478
- }
479
- .toast-message-item {
480
- background: var(--paper) !important;
481
- }
482
- .toast-separator {
483
- background: var(--hairline) !important;
484
- }
485
- .timer {
486
- background: var(--orange) !important;
487
- }
488
- .icon-button-wrapper, button.icon-button, .icon-button {
489
- background: var(--raised) !important;
490
- color: var(--soft) !important;
491
- border-color: var(--hairline) !important;
492
- }
493
- button.icon-button:hover, .icon-button:hover {
494
- background: var(--sunken) !important;
495
- color: var(--orange) !important;
496
- border-color: var(--orange) !important;
497
- }
498
- .icon-button svg, button.icon-button svg,
499
- .icon-button-wrapper svg {
500
- color: currentColor !important;
501
- fill: currentColor !important;
502
- stroke: currentColor !important;
503
- }
504
- #vg-tooltip-element, [role="tooltip"] {
505
- background: var(--raised) !important;
506
- color: var(--ink) !important;
507
- border: 1px solid var(--hairline) !important;
508
- box-shadow: none !important;
509
- }
510
- #vg-tooltip-element *, [role="tooltip"] * {
511
- color: var(--ink) !important;
512
- }
513
- """
514
 
515
- # The design is paper-light only: strip Gradio's .dark class before paint and
516
- # whenever the runtime re-adds it (system dark-mode users otherwise get
517
- # near-white text on paper).
518
- FORCE_LIGHT_JS = """
519
- () => {
520
- const root = document.documentElement;
521
- const strip = () => { root.classList.remove('dark'); root.classList.add('light'); };
522
- strip();
523
- new MutationObserver(strip).observe(root, {attributes: true, attributeFilter: ['class']});
524
- }
525
- """
526
 
527
- # CSS goes in <head> (not launch(css=...)) so the first paint already has the
528
- # layout β€” otherwise the page renders at Gradio's default width, then snaps to
529
- # 1280px when the runtime-injected stylesheet lands.
530
- HEAD = f"""
531
- <link rel="preconnect" href="https://fonts.googleapis.com">
532
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
533
- <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Lora:ital@0;1&display=swap" rel="stylesheet">
534
- <style>{CSS}</style>
535
- """
536
 
537
- with gr.Blocks(title="pulpie β€” encoder vs decoder") as demo:
538
- with gr.Column(elem_id="header-block"):
539
- gr.HTML('<div id="eyebrow">FEYN Β· CONTENT EXTRACTION Β· ENCODER VS DECODER</div>')
540
- gr.HTML('<div id="headline"><h1>pulpie</h1></div>')
541
- gr.HTML(
542
- '<div id="lead">Extract main content from HTML. '
543
- "One forward pass, not one token at a time.</div>"
544
- )
545
-
546
- gr.HTML('<div class="input-eyebrow">EXAMPLE PAGE</div>')
547
- with gr.Row(elem_id="input-row"):
548
- example_dd = gr.Dropdown(
549
- choices=list(EXAMPLES.keys()),
550
- value="paulgraham.com",
551
- label=None,
552
- show_label=False,
553
- container=False,
554
- scale=2,
555
- )
556
- run_btn = gr.Button("EXTRACT β†’", variant="primary", scale=1)
557
- with gr.Accordion("OR PASTE RAW HTML", open=False, elem_id="html-accordion"):
558
- custom_html = gr.Textbox(
559
- lines=6,
560
- show_label=False,
561
- container=False,
562
- placeholder="<html>…</html> (2 MB max; JS-rendered SPAs will come up empty β€” nothing static to extract)",
563
- )
564
-
565
- with gr.Row():
566
- with gr.Column():
567
- gr.HTML('<div class="pane-label">PULPIE ORANGE-SMALL Β· 210M Β· ENCODER</div>')
568
- pulpie_timer = gr.HTML(fmt_timer(0.0, done=True))
569
- pulpie_out = gr.Markdown(
570
- "*One encoder forward pass over every block.*", elem_classes=["result-pane"]
571
- )
572
- with gr.Column():
573
- gr.HTML('<div class="pane-label">DRIPPER Β· 0.6B Β· DECODER</div>')
574
- dripper_timer = gr.HTML(fmt_timer(0.0, done=True))
575
- dripper_out = gr.Markdown(
576
- "*The same labels, one token at a time.*", elem_classes=["result-pane"]
577
- )
578
-
579
- verdict = gr.HTML()
580
-
581
- gr.HTML(
582
- """<div id="bench-well">
583
- <div style="font-family:'IBM Plex Mono',monospace; font-size:12px; letter-spacing:0.14em;
584
- color:#756A5E; margin-bottom:12px;">WEBMAINBENCH Β· 6,647 PAGES Β· ROUGE-5 F1</div>
585
- <table>
586
- <tr><td>pulpie orange-small</td><td>210M</td><td style="color:#C6531D; font-weight:600;">0.862</td><td>13.7 pg/s on L4</td></tr>
587
- <tr><td>dripper (MinerU-HTML)</td><td>0.6B</td><td>0.864</td><td>0.68 pg/s on L4</td></tr>
588
- <tr><td>trafilatura</td><td>β€”</td><td>0.619</td><td>β€”</td></tr>
589
- </table>
590
- <div style="font-family:'Lora',serif; font-style:italic; color:#3F382F; margin-top:12px;">
591
- Same quality. ~$7,900 to clean 1B pages, versus ~$159,000.</div>
592
- </div>"""
593
- )
594
 
595
- gr.HTML(
596
- """<div id="footer-mono">
597
- pip install pulpie Β·
598
- <a href="https://github.com/feyninc/pulpie">GITHUB</a> Β·
599
- <a href="https://huggingface.co/feyninc/pulpie-orange-small">MODEL</a> Β·
600
- <a href="https://github.com/feyninc/pulpie/blob/main/BENCHMARKS.md">BENCHMARKS</a> Β·
601
- <a href="https://usefeyn.com/blog/pulpie-pareto-optimal-models-for-cleaning-the-web/">BLOG</a> Β·
602
- BUILT BY <a href="https://usefeyn.com">FEYN</a>
603
- </div>"""
604
- )
605
 
606
- run_btn.click(
607
- race,
608
- inputs=[example_dd, custom_html],
609
- outputs=[pulpie_out, pulpie_timer, dripper_out, dripper_timer, verdict],
610
- )
611
 
612
  if __name__ == "__main__":
613
- demo.launch(head=HEAD, js=FORCE_LIGHT_JS)
 
1
  """Pulpie vs Dripper β€” encoder vs decoder content extraction, live.
2
 
3
+ gradio.Server backend: a FastAPI app with Gradio's queue engine layered on
4
+ (via @app.api()). The UI is a custom vanilla HTML/CSS/JS frontend served at
5
+ `/`; the Gradio JS client calls the queued `/race` endpoint, so requests go
6
+ through concurrency control and (on ZeroGPU Spaces) GPU allocation β€” not raw
7
+ fetches that would collide on the GPU.
8
+
9
  Both models receive the SAME simplified HTML (pulpie's MinerU-HTML-parity
10
  simplify). Pulpie classifies every block in one encoder forward pass; Dripper
11
  (MinerU-HTML 0.6B) emits its answer autoregressively, one token at a time.
 
13
 
14
  from __future__ import annotations
15
 
16
+ import os
17
  import re
18
  import threading
19
  import time
20
  from pathlib import Path
21
 
 
22
  import spaces
23
  import torch
24
  from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
25
+ from fastapi.responses import HTMLResponse
26
+ from gradio import Server
27
+ from gradio.data_classes import FileData
28
 
29
  from pulpie.chunker import extract_blocks, pack_chunks, tokenize_blocks
30
  from pulpie.markdown import to_markdown
 
183
  return to_markdown(extract_main_html(map_html, labels))
184
 
185
 
186
+ # ── The race (queued endpoint, streamed as SSE via the Gradio client) ──
187
 
188
  MONO = "font-family: 'IBM Plex Mono', monospace;"
189
 
 
197
 
198
 
199
  def race(example_name: str, custom_html: str):
200
+ """Generator driving both panes. Yields dict records consumed by the SSE
201
+ frontend: {pulpie_md, pulpie_timer, dripper_md, dripper_timer, verdict}."""
202
  if custom_html and custom_html.strip():
203
  html = custom_html
204
  elif example_name in EXAMPLES:
205
  html = (EXAMPLES_DIR / EXAMPLES[example_name]).read_text(errors="replace")
206
  else:
207
+ yield {"pulpie_md": "", "pulpie_timer": "", "dripper_md": "",
208
+ "dripper_timer": "", "verdict": "Pick an example page or paste HTML."}
209
  return
210
  if len(html.encode()) > MAX_HTML_BYTES:
211
+ yield {"pulpie_md": "", "pulpie_timer": "", "dripper_md": "",
212
+ "dripper_timer": "", "verdict": "HTML too large (2 MB cap)."}
213
  return
214
 
215
  # Shared preprocessing β€” identical input to both models.
216
  try:
217
  simplified, map_html = simplify(html)
218
  except Exception as e:
219
+ yield {"pulpie_md": "", "pulpie_timer": "", "dripper_md": "",
220
+ "dripper_timer": "", "verdict": f"simplify failed: {e}"}
221
  return
222
 
223
  # Pulpie. Timed on-GPU inside the @spaces.GPU call so ZeroGPU
 
225
  pulpie_labels, pulpie_s = pulpie_classify(simplified)
226
  pulpie_md = labels_to_markdown(map_html, pulpie_labels) or "*No main content detected.*"
227
  pulpie_timer = fmt_timer(pulpie_s, done=True, color="#C6531D")
228
+ yield {"pulpie_md": pulpie_md, "pulpie_timer": pulpie_timer,
229
+ "dripper_md": "", "dripper_timer": fmt_timer(0.0, done=False), "verdict": ""}
230
 
231
  # Dripper, streaming.
232
  raw, dripper_s = "", 0.0
233
  for acc, gen_s, err in dripper_generate_stream(simplified):
234
  if err:
235
+ yield {"pulpie_md": pulpie_md, "pulpie_timer": pulpie_timer,
236
+ "dripper_md": f"*{err}*", "dripper_timer": fmt_timer(gen_s, done=True),
237
+ "verdict": ""}
238
  return
239
  raw, dripper_s = acc, gen_s
240
  preview = f"```\n…{raw[-1500:]}\n```" if raw else "*waiting for first token…*"
241
+ yield {"pulpie_md": pulpie_md, "pulpie_timer": pulpie_timer,
242
+ "dripper_md": preview, "dripper_timer": fmt_timer(dripper_s, done=False),
243
+ "verdict": ""}
244
 
245
  dripper_md = labels_to_markdown(map_html, parse_dripper_labels(raw)) or "*No main content detected.*"
246
  speedup = dripper_s / max(pulpie_s, 1e-6)
 
251
  f"PULPIE {pulpie_s:.2f}s β†’ DRIPPER {dripper_s:.1f}s Β· "
252
  f'<span style="color:#C6531D; font-weight:600;">{speedup_str} ON THIS PAGE</span></div>'
253
  )
254
+ yield {"pulpie_md": pulpie_md, "pulpie_timer": pulpie_timer,
255
+ "dripper_md": dripper_md, "dripper_timer": fmt_timer(dripper_s, done=True),
256
+ "verdict": verdict}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
+ # ── gradio.Server app ──
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
+ app = Server()
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
 
264
+ @app.api(name="race")
265
+ def race_endpoint(example_name: str, custom_html: str) -> dict:
266
+ """Queued, GPU-managed streaming endpoint. Each yielded record is delivered
267
+ to the Gradio client (JS or Python) as a separate SSE event, so the frontend
268
+ sees pulpie finish, then dripper's tokens arrive live, then the verdict.
269
+ The final record carries every field, so a non-streaming predict() call
270
+ still converges to the complete result.
 
 
 
 
271
 
272
+ The `-> dict` return annotation is load-bearing: gradio.Server derives the
273
+ endpoint's output components from the return type, and a generator without
274
+ one registers zero outputs β€” the queue runs (the logs show each yield) but
275
+ the client receives nothing to render. Annotating with the *yielded* type
276
+ (dict, not Generator[dict, ...]) declares one dict output while the queue
277
+ still iterates the generator and streams each dict as a data event."""
278
+ yield from race(example_name or "", custom_html or "")
 
 
279
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
280
 
281
+ @app.get("/")
282
+ async def homepage():
283
+ html_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
284
+ with open(html_path, "r", encoding="utf-8") as f:
285
+ return HTMLResponse(content=f.read())
 
 
 
 
 
286
 
 
 
 
 
 
287
 
288
  if __name__ == "__main__":
289
+ app.launch(show_error=True)
index.html ADDED
@@ -0,0 +1,533 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#F2EDE3" />
7
+ <title>pulpie β€” encoder vs decoder</title>
8
+ <meta name="color-scheme" content="light" />
9
+ <link rel="preconnect" href="https://fonts.googleapis.com">
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Lora:ital@0;1&display=swap" rel="stylesheet">
12
+ <style>
13
+ :root{
14
+ --paper:#F2EDE3; --raised:#FBF7F0; --sunken:#ECE5D7;
15
+ --ink:#1B1714; --body-ink:#3F382F; --soft:#756A5E;
16
+ --orange:#C6531D; --orange-hover:#E0823A; --pine:#2F5A3C; --hairline:#D7CBB6;
17
+ --green:#2F5A3C; --err:#8A2E16; --err-bg:#FFF3EC;
18
+ --r:14px; --r-sm:10px; --r-lg:20px;
19
+ --sh1:0 1px 2px rgba(27,23,20,.05), 0 1px 3px rgba(27,23,20,.04);
20
+ --sh2:0 10px 32px -12px rgba(27,23,20,.18), 0 2px 8px -3px rgba(27,23,20,.07);
21
+ --ring:0 0 0 3px rgba(198,83,29,.22);
22
+ --safe-t:env(safe-area-inset-top,0px);
23
+ color-scheme:light;
24
+ }
25
+ *{box-sizing:border-box;margin:0;padding:0}
26
+ html,body{background:var(--paper);color:var(--ink)}
27
+ body{font-family:'Inter','Helvetica Neue',Arial,sans-serif;min-height:100vh;min-height:100dvh;display:flex;flex-direction:column;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;-webkit-tap-highlight-color:transparent}
28
+ .mono{font-family:'IBM Plex Mono',monospace}
29
+ .wrap{width:100%;max-width:920px;margin:0 auto;padding:max(30px,var(--safe-t)) 20px 28px;flex:1}
30
+ ::selection{background:rgba(198,83,29,.18)}
31
+ *{scrollbar-width:thin;scrollbar-color:var(--hairline) transparent}
32
+ ::-webkit-scrollbar{width:9px;height:9px}
33
+ ::-webkit-scrollbar-thumb{background:var(--hairline);border-radius:20px;border:2px solid var(--raised)}
34
+ ::-webkit-scrollbar-thumb:hover{background:var(--soft)}
35
+ ::-webkit-scrollbar-track{background:transparent}
36
+ a,button,select,textarea{touch-action:manipulation}
37
+
38
+ /* ── Header ── */
39
+ #header-block{margin-bottom:20px}
40
+ .topbar{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:18px}
41
+ #eyebrow{font-family:'IBM Plex Mono',monospace;font-size:11px;letter-spacing:.18em;color:var(--orange);text-transform:uppercase;font-weight:500;line-height:1.3}
42
+ .pill{display:inline-flex;align-items:center;gap:7px;font-family:'IBM Plex Mono',monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);background:var(--raised);border:1px solid var(--hairline);padding:6px 11px;border-radius:100px;box-shadow:var(--sh1);transition:all .2s;white-space:nowrap}
43
+ .pill .dot{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 0 3px rgba(47,90,60,.15)}
44
+ .pill.connecting .dot{background:var(--orange);box-shadow:0 0 0 3px rgba(198,83,29,.15);animation:pulse 1.1s infinite}
45
+ .pill.error{color:var(--err);border-color:var(--orange)}
46
+ .pill.error .dot{background:var(--err);box-shadow:0 0 0 3px rgba(138,46,22,.15)}
47
+ @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.4;transform:scale(.85)}}
48
+ #headline h1{font-weight:700;letter-spacing:-.035em;font-size:50px;color:var(--ink);line-height:1.02;background:linear-gradient(120deg,var(--ink) 30%,var(--orange) 145%);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent}
49
+ #lead{font-family:'Lora',serif;font-size:18px;color:var(--body-ink);font-style:italic;max-width:620px;line-height:1.4;margin-top:6px}
50
+
51
+ /* ── Unified race card ── */
52
+ .race-card{background:var(--raised);border:1px solid var(--hairline);border-radius:var(--r-lg);box-shadow:var(--sh2);overflow:hidden;margin-top:20px}
53
+ .card-section{padding:18px}
54
+ .card-divider{height:1px;background:var(--hairline)}
55
+
56
+ /* input section */
57
+ .input-eyebrow{font-family:'IBM Plex Mono',monospace;font-size:10px;letter-spacing:.16em;color:var(--soft);text-transform:uppercase;margin-bottom:11px;display:flex;align-items:center;gap:8px}
58
+ .input-eyebrow::after{content:"";flex:1;height:1px;background:var(--hairline)}
59
+ #input-row{display:flex;align-items:stretch;gap:12px}
60
+ #example-select{flex:1;height:52px;background:var(--paper);border:1px solid var(--hairline);color:var(--ink);font-family:'IBM Plex Mono',monospace;font-size:13px;padding:0 42px 0 16px;cursor:pointer;appearance:none;border-radius:var(--r-sm);transition:all .15s;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23C6531D' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 16px center}
61
+ #example-select:hover{border-color:var(--orange);box-shadow:var(--sh1)}
62
+ #example-select:focus-visible{outline:none;box-shadow:var(--ring);border-color:var(--orange)}
63
+ #run-btn{height:52px;min-width:150px;background:linear-gradient(135deg,var(--orange) 0%,#D9642A 100%);color:#FBF7F0;border:none;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-size:13px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;border-radius:var(--r-sm);display:inline-flex;align-items:center;justify-content:center;gap:10px;transition:all .18s;box-shadow:0 4px 14px -4px rgba(198,83,29,.5)}
64
+ #run-btn:hover:not(:disabled){filter:brightness(1.06);transform:translateY(-1px);box-shadow:0 7px 22px -5px rgba(198,83,29,.55)}
65
+ #run-btn:active:not(:disabled){transform:translateY(0)}
66
+ #run-btn:focus-visible{outline:none;box-shadow:var(--ring)}
67
+ #run-btn:disabled{opacity:.6;cursor:progress;transform:none}
68
+ #run-btn .bolt{width:15px;height:15px}
69
+ .spinner{width:15px;height:15px;border:2px solid rgba(251,247,240,.35);border-top-color:#FBF7F0;border-radius:50%;animation:spin .7s linear infinite;display:none}
70
+ #run-btn.running .spinner{display:inline-block}
71
+ #run-btn.running .bolt{display:none}
72
+ @keyframes spin{to{transform:rotate(360deg)}}
73
+
74
+ #html-accordion{margin-top:12px}
75
+ .acc-header{display:flex;align-items:center;gap:9px;padding:7px 2px;cursor:pointer;user-select:none}
76
+ .acc-header .chev{font-family:'IBM Plex Mono',monospace;font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);display:flex;align-items:center;gap:7px;transition:color .15s}
77
+ .acc-header .chev .arw{display:inline-block;transition:transform .22s;color:var(--orange)}
78
+ .acc-header:hover .chev{color:var(--ink)}
79
+ #html-accordion.open .chev .arw{transform:rotate(90deg)}
80
+ .acc-body{max-height:0;overflow:hidden;transition:max-height .3s ease}
81
+ #html-accordion.open .acc-body{max-height:300px}
82
+ .acc-body-inner{padding-top:8px}
83
+ #custom-html{width:100%;min-height:120px;background:var(--paper);border:1px solid var(--hairline);color:var(--ink);padding:13px 14px;font-family:'IBM Plex Mono',monospace;font-size:12px;line-height:1.55;resize:vertical;border-radius:var(--r-sm);transition:all .15s}
84
+ #custom-html:focus-visible{outline:none;border-color:var(--orange);box-shadow:var(--ring)}
85
+ .acc-hint{font-family:'Lora',serif;font-style:italic;color:var(--soft);font-size:12px;margin-top:8px}
86
+
87
+ /* comparison strip */
88
+ .cmp-strip{display:grid;grid-template-columns:1fr auto 1fr;gap:14px;align-items:stretch;padding:16px 18px;background:linear-gradient(180deg,var(--raised),rgba(236,229,215,.4))}
89
+ .chip{background:var(--paper);border:1px solid var(--hairline);border-radius:var(--r-sm);padding:11px 13px;display:flex;flex-direction:column;gap:8px;transition:all .25s;min-width:0}
90
+ .chip.live{border-color:var(--orange);box-shadow:0 0 0 3px rgba(198,83,29,.09)}
91
+ .chip.dripper.live{border-color:var(--pine);box-shadow:0 0 0 3px rgba(47,90,60,.09)}
92
+ .chip-top{display:flex;align-items:center;justify-content:space-between;gap:8px}
93
+ .chip-name{font-family:'IBM Plex Mono',monospace;font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--soft);display:flex;align-items:center;gap:7px;min-width:0}
94
+ .chip-name b{color:var(--ink);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
95
+ .chip .led{width:8px;height:8px;border-radius:50%;flex:none;opacity:.4;transition:opacity .25s}
96
+ .chip.pulpie .led{background:var(--orange)}
97
+ .chip.dripper .led{background:var(--pine)}
98
+ .chip.live .led{opacity:1;box-shadow:0 0 0 4px rgba(198,83,29,.12)}
99
+ .chip.dripper.live .led{box-shadow:0 0 0 4px rgba(47,90,60,.12)}
100
+ .timer{font-family:'IBM Plex Mono',monospace;font-size:13px;letter-spacing:.02em;color:var(--ink);font-variant-numeric:tabular-nums;white-space:nowrap}
101
+ .timer .running{color:var(--green);font-weight:500}
102
+ .chip.pulpie .timer{color:var(--orange)}
103
+ .bar{height:4px;background:var(--sunken);border-radius:4px;position:relative;overflow:hidden}
104
+ .bar .fill{position:absolute;inset:0 100% 0 0;background:linear-gradient(90deg,var(--orange),var(--orange-hover));border-radius:4px;transition:right .35s cubic-bezier(.4,0,.2,1)}
105
+ .chip.dripper .bar .fill{background:linear-gradient(90deg,var(--pine),#4A7E55)}
106
+ .bar .fill.indeterminate{animation:indet 1.3s ease-in-out infinite}
107
+ @keyframes indet{0%{left:-30%;right:100%}50%{right:40%}100%{left:100%;right:-30%}}
108
+
109
+ .cmp-center{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:0 4px;min-width:92px}
110
+ .sp-num{font-family:'IBM Plex Mono',monospace;font-weight:700;font-size:32px;line-height:1;letter-spacing:-.03em;color:var(--orange);transition:all .3s}
111
+ .sp-lbl{font-family:'IBM Plex Mono',monospace;font-size:9px;letter-spacing:.14em;text-transform:uppercase;color:var(--soft);margin-top:7px;line-height:1.3}
112
+ .cmp-center.idle .sp-num{color:var(--hairline);font-size:22px}
113
+ .cmp-center.idle .sp-lbl{opacity:0}
114
+
115
+ /* toolbar (toggle + actions) */
116
+ .read-toolbar{display:flex;align-items:center;gap:10px;padding:11px 18px;border-top:1px solid var(--hairline);border-bottom:1px solid var(--hairline);flex-wrap:wrap;background:rgba(236,229,215,.32)}
117
+ .toggle-seg{display:flex;gap:3px;background:var(--sunken);border:1px solid var(--hairline);padding:3px;border-radius:100px}
118
+ .toggle-seg button{border:none;background:transparent;color:var(--soft);font-family:'IBM Plex Mono',monospace;font-size:10.5px;letter-spacing:.08em;text-transform:uppercase;padding:8px 14px;cursor:pointer;border-radius:100px;transition:all .18s;font-weight:500;display:flex;align-items:center;gap:7px}
119
+ .toggle-seg button .led{width:7px;height:7px;border-radius:50%;opacity:.5}
120
+ .toggle-seg button[data-model="pulpie"] .led{background:var(--orange)}
121
+ .toggle-seg button[data-model="dripper"] .led{background:var(--pine)}
122
+ .toggle-seg button.active{background:var(--raised);color:var(--ink);box-shadow:var(--sh1)}
123
+ .toggle-seg button.active .led{opacity:1}
124
+ .toolbar-spacer{flex:1}
125
+ .mini-btn{background:var(--paper);color:var(--ink);border:1px solid var(--hairline);font-family:'IBM Plex Mono',monospace;font-size:10.5px;letter-spacing:.08em;padding:8px 12px;cursor:pointer;transition:all .15s;text-transform:uppercase;border-radius:7px;display:inline-flex;align-items:center;gap:6px;min-height:34px}
126
+ .mini-btn:hover{color:var(--orange);border-color:var(--orange);background:var(--raised)}
127
+ .mini-btn:focus-visible{outline:none;box-shadow:var(--ring)}
128
+ .mini-btn:disabled{opacity:.4;cursor:not-allowed}
129
+
130
+ /* single reading pane */
131
+ .read-pane{min-height:280px;max-height:560px;overflow-y:auto;padding:18px 20px;-webkit-overflow-scrolling:touch}
132
+ .read-pane .placeholder{color:var(--soft);font-style:italic;font-family:'Lora',serif;font-size:14.5px;line-height:1.5;display:flex;align-items:center;justify-content:center;min-height:220px;text-align:center;opacity:.85;padding:0 6px}
133
+ .sk-line{height:10px;background:linear-gradient(90deg,var(--sunken),var(--hairline),var(--sunken));background-size:200% 100%;animation:shimmer 1.4s infinite;border-radius:6px;margin:14px 0}
134
+ .sk-line:nth-child(1){width:55%}.sk-line:nth-child(2){width:88%}.sk-line:nth-child(3){width:72%}.sk-line:nth-child(4){width:90%}.sk-line:nth-child(5){width:40%}
135
+ .sk-line:nth-child(2){animation-delay:.1s}.sk-line:nth-child(3){animation-delay:.2s}.sk-line:nth-child(4){animation-delay:.3s}.sk-line:nth-child(5){animation-delay:.4s}
136
+ @keyframes shimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
137
+
138
+ /* status line (errors / bad input) */
139
+ #status-line{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--err);padding:0 20px 14px;display:none}
140
+ #status-line.show{display:block}
141
+
142
+ /* markdown */
143
+ .md h1,.md h2,.md h3,.md h4,.md h5,.md h6{color:var(--ink);font-weight:700;letter-spacing:-.02em;margin:16px 0 7px;line-height:1.22}
144
+ .md h1{font-size:25px}.md h2{font-size:20px}.md h3{font-size:18px}.md h4{font-size:15.5px}
145
+ .md h1:first-child,.md h2:first-child{margin-top:0}
146
+ .md p{font-family:'Lora',serif;color:var(--body-ink);margin:9px 0;line-height:1.62;font-size:15px}
147
+ .md ul,.md ol{margin:9px 0 9px 22px;font-family:'Lora',serif;color:var(--body-ink);font-size:15px}
148
+ .md li{margin:4px 0;line-height:1.55}
149
+ .md a{color:var(--orange);text-decoration:underline;text-underline-offset:2px;text-decoration-color:var(--hairline)}
150
+ .md a:hover{color:var(--orange-hover);text-decoration-color:var(--orange)}
151
+ .md code{background:var(--sunken);border:1px solid var(--hairline);padding:1px 6px;font-family:'IBM Plex Mono',monospace;font-size:.86em;color:var(--ink);border-radius:5px}
152
+ .md pre{background:var(--sunken);border:1px solid var(--hairline);padding:13px;overflow-x:auto;margin:11px 0;border-radius:var(--r-sm);font-size:12px;line-height:1.5}
153
+ .md pre code{background:none;border:none;padding:0;color:var(--ink)}
154
+ .md blockquote{border-left:3px solid var(--orange);padding:2px 0 2px 14px;margin:11px 0;color:var(--body-ink);font-style:italic;font-family:'Lora',serif}
155
+ .md img{max-width:100%;height:auto;border-radius:var(--r-sm)}
156
+ .md table{border-collapse:collapse;margin:11px 0;font-family:'IBM Plex Mono',monospace;font-size:12.5px;width:100%;display:block;overflow-x:auto}
157
+ .md th,.md td{border:1px solid var(--hairline);padding:7px 10px;text-align:left;white-space:nowrap}
158
+ .md th{background:var(--sunken)}
159
+ .md hr{border:none;border-top:1px solid var(--hairline);margin:15px 0}
160
+
161
+ /* flash */
162
+ .flash{background:var(--err-bg);color:var(--err);border:1px solid var(--orange);padding:12px 16px;font-family:'IBM Plex Mono',monospace;font-size:12.5px;margin-top:14px;border-radius:var(--r-sm);display:none;align-items:center;gap:10px}
163
+ .flash.show{display:flex}
164
+ .flash .x{margin-left:auto;cursor:pointer;opacity:.6;padding:2px 6px}
165
+ .flash .x:hover{opacity:1}
166
+
167
+ /* ── Bench ── */
168
+ #bench-well{background:var(--raised);border:1px solid var(--hairline);border-radius:var(--r);padding:20px;margin-top:18px;box-shadow:var(--sh1)}
169
+ #bench-well .label{font-family:'IBM Plex Mono',monospace;font-size:10.5px;letter-spacing:.14em;color:var(--soft);margin-bottom:14px;text-transform:uppercase;display:flex;align-items:center;gap:8px}
170
+ #bench-well .label::after{content:"";flex:1;height:1px;background:var(--hairline)}
171
+ .bench-row{display:grid;grid-template-columns:170px 1fr 80px;align-items:center;gap:14px;padding:8px 0}
172
+ .bench-row .who{font-family:'Inter',sans-serif;font-size:13.5px;color:var(--ink);font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
173
+ .bench-row .who .params{color:var(--soft);font-family:'IBM Plex Mono',monospace;font-size:11px;margin-left:6px;font-weight:400}
174
+ .bench-track{height:10px;background:var(--sunken);border-radius:6px;overflow:hidden}
175
+ .bench-fill{height:100%;border-radius:6px;background:var(--soft);width:0;transition:width .9s cubic-bezier(.4,0,.2,1)}
176
+ .bench-row.win .bench-fill{background:linear-gradient(90deg,var(--orange),var(--orange-hover))}
177
+ .bench-row.dim .bench-fill{opacity:.5}
178
+ .bench-row .score{font-family:'IBM Plex Mono',monospace;font-size:13px;text-align:right;font-variant-numeric:tabular-nums;color:var(--ink)}
179
+ .bench-row.win .score{color:var(--orange);font-weight:600}
180
+ #bench-well .note{font-family:'Lora',serif;font-style:italic;color:var(--body-ink);margin-top:12px;font-size:13.5px;line-height:1.5}
181
+
182
+ /* footer */
183
+ #footer-mono{font-family:'IBM Plex Mono',monospace;font-size:11.5px;letter-spacing:.06em;color:var(--soft);border-top:1px solid var(--hairline);padding-top:16px;margin-top:24px;padding-bottom:8px;display:flex;flex-wrap:wrap;gap:3px 0;align-items:center}
184
+ #footer-mono a{color:var(--ink);text-decoration:none;padding:3px;transition:color .15s}
185
+ #footer-mono a:hover{color:var(--orange)}
186
+ #footer-mono .sep{opacity:.4;margin:0 5px}
187
+
188
+ /* ── Mobile ── */
189
+ @media (max-width:600px){
190
+ .wrap{padding:max(20px,var(--safe-t)) 14px 22px}
191
+ #headline h1{font-size:38px}
192
+ #lead{font-size:16px}
193
+ .race-card{border-radius:var(--r)}
194
+ .card-section{padding:15px}
195
+ #input-row{flex-direction:column}
196
+ #example-select,#run-btn{flex:1;width:100%;min-width:0}
197
+ /* comparison strip stacks: pulpie / speedup / dripper */
198
+ .cmp-strip{grid-template-columns:1fr;gap:10px;padding:14px}
199
+ .cmp-center{flex-direction:row;gap:12px;min-width:0;padding:2px 0;justify-content:flex-start}
200
+ .cmp-center .sp-num{font-size:24px}
201
+ .cmp-center .sp-lbl{margin-top:0}
202
+ .chip{flex-direction:row;align-items:center;gap:14px;flex-wrap:wrap}
203
+ .chip-top{flex:1;min-width:120px}
204
+ .chip .bar{flex:1;min-width:80px}
205
+ .read-toolbar{padding:10px 14px}
206
+ .read-pane{padding:16px 16px;min-height:240px}
207
+ .bench-row{grid-template-columns:1fr 60px;gap:6px 12px}
208
+ .bench-row .who{grid-column:1/3}
209
+ .bench-row .bench-track{grid-column:1}
210
+ .bench-row .score{grid-column:2}
211
+ }
212
+ @media (max-width:420px){
213
+ #eyebrow{font-size:9.5px}
214
+ .pill{font-size:9px;padding:5px 9px}
215
+ #headline h1{font-size:32px}
216
+ .sp-num{font-size:22px}
217
+ }
218
+
219
+ @media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important}}
220
+ </style>
221
+ </head>
222
+ <body>
223
+ <div class="wrap">
224
+ <!-- Header -->
225
+ <div id="header-block">
226
+ <div class="topbar">
227
+ <div id="eyebrow">FEYN Β· CONTENT EXTRACTION Β· ENCODER VS DECODER</div>
228
+ <div class="pill connecting" id="conn-pill"><span class="dot"></span><span id="conn-text">CONNECTING</span></div>
229
+ </div>
230
+ <div id="headline"><h1>pulpie</h1></div>
231
+ <div id="lead">Extract main content from HTML. One forward pass, not one token at a time.</div>
232
+ </div>
233
+
234
+ <!-- Unified race card -->
235
+ <div class="race-card">
236
+ <!-- input -->
237
+ <div class="card-section">
238
+ <div class="input-eyebrow">EXAMPLE PAGE</div>
239
+ <div id="input-row">
240
+ <select id="example-select" aria-label="Example page">
241
+ <option value="paulgraham.com">paulgraham.com</option>
242
+ <option value="nodejs.dev">nodejs.dev</option>
243
+ <option value="protobuf.dev">protobuf.dev</option>
244
+ <option value="istio.io">istio.io</option>
245
+ <option value="tutorialspoint.com">tutorialspoint.com</option>
246
+ <option value="builtin.com">builtin.com</option>
247
+ <option value="bbc.com">bbc.com</option>
248
+ <option value="theguardian.com">theguardian.com</option>
249
+ <option value="wikipedia.org">wikipedia.org</option>
250
+ <option value="stackoverflow.com">stackoverflow.com</option>
251
+ </select>
252
+ <button id="run-btn">
253
+ <svg class="bolt" viewBox="0 0 24 24" fill="currentColor"><path d="M13 2L4.5 13.5H11l-1 8.5L19.5 10H13z"/></svg>
254
+ <span class="spinner"></span>
255
+ <span class="btn-label">EXTRACT β†’</span>
256
+ </button>
257
+ </div>
258
+ <div id="html-accordion">
259
+ <div class="acc-header" id="acc-toggle" role="button" tabindex="0" aria-expanded="false">
260
+ <span class="chev"><span class="arw">β–Έ</span>OR PASTE RAW HTML</span>
261
+ </div>
262
+ <div class="acc-body"><div class="acc-body-inner">
263
+ <textarea id="custom-html" placeholder="<html>…</html> (2 MB max; JS-rendered SPAs will come up empty β€” nothing static to extract)"></textarea>
264
+ <div class="acc-hint">If set, this overrides the example dropdown. ⌘/Ctrl+Enter to run.</div>
265
+ </div></div>
266
+ </div>
267
+ </div>
268
+
269
+ <div class="card-divider"></div>
270
+
271
+ <!-- comparison strip (both models, always visible) -->
272
+ <div class="cmp-strip">
273
+ <div class="chip pulpie" id="pulpie-chip">
274
+ <div class="chip-top">
275
+ <div class="chip-name"><span class="led"></span>PULPIE <b>210M Β· ENCODER</b></div>
276
+ <div class="timer" id="pulpie-timer"></div>
277
+ </div>
278
+ <div class="bar"><div class="fill" id="pulpie-bar"></div></div>
279
+ </div>
280
+ <div class="cmp-center idle" id="cmp-center">
281
+ <div class="sp-num" id="sp-num">β€”</div>
282
+ <div class="sp-lbl" id="sp-lbl">Γ— FASTER Β· ON THIS PAGE</div>
283
+ </div>
284
+ <div class="chip dripper" id="dripper-chip">
285
+ <div class="chip-top">
286
+ <div class="chip-name"><span class="led"></span>DRIPPER <b>0.6B Β· DECODER</b></div>
287
+ <div class="timer" id="dripper-timer"></div>
288
+ </div>
289
+ <div class="bar"><div class="fill" id="dripper-bar"></div></div>
290
+ </div>
291
+ </div>
292
+
293
+ <!-- toolbar: toggle + actions -->
294
+ <div class="read-toolbar">
295
+ <div class="toggle-seg" role="tablist" aria-label="Which model to read">
296
+ <button data-model="pulpie" class="active" role="tab" aria-selected="true"><span class="led"></span>PULPIE</button>
297
+ <button data-model="dripper" role="tab" aria-selected="false"><span class="led"></span>DRIPPER</button>
298
+ </div>
299
+ <div class="toolbar-spacer"></div>
300
+ <button class="mini-btn" id="copy-btn">⧉ COPY MD</button>
301
+ <button class="mini-btn" id="diff-btn">β‰  DIFF</button>
302
+ </div>
303
+
304
+ <!-- single reading pane -->
305
+ <div class="read-pane" id="read-pane">
306
+ <div class="placeholder">One encoder forward pass over every block.</div>
307
+ </div>
308
+ <div id="status-line"></div>
309
+ </div>
310
+
311
+ <div class="flash" id="flash"><span id="flash-text"></span><span class="x" id="flash-x">βœ•</span></div>
312
+
313
+ <!-- Bench -->
314
+ <div id="bench-well">
315
+ <div class="label">WEBMAINBENCH Β· 6,647 PAGES Β· ROUGE-5 F1</div>
316
+ <div class="bench-row win"><div class="who">pulpie orange-small<span class="params">210M</span></div><div class="bench-track"><div class="bench-fill" data-pct="86.2"></div></div><div class="score">0.862</div></div>
317
+ <div class="bench-row"><div class="who">dripper (MinerU-HTML)<span class="params">0.6B</span></div><div class="bench-track"><div class="bench-fill" data-pct="86.4"></div></div><div class="score">0.864</div></div>
318
+ <div class="bench-row dim"><div class="who">trafilatura<span class="params">β€”</span></div><div class="bench-track"><div class="bench-fill" data-pct="61.9"></div></div><div class="score">0.619</div></div>
319
+ <div class="note">Same quality. ~$7,900 to clean 1B pages, versus ~$159,000. <span style="color:var(--soft);font-family:'IBM Plex Mono',monospace;font-style:normal;font-size:11px;letter-spacing:.05em">β€” 13.7 pg/s on L4 vs 0.68 pg/s</span></div>
320
+ </div>
321
+
322
+ <!-- Footer -->
323
+ <div id="footer-mono">
324
+ <span class="sep">pip install pulpie</span><span class="sep">Β·</span>
325
+ <a href="https://github.com/feyninc/pulpie">GITHUB</a><span class="sep">Β·</span>
326
+ <a href="https://huggingface.co/feyninc/pulpie-orange-small">MODEL</a><span class="sep">Β·</span>
327
+ <a href="https://github.com/feyninc/pulpie/blob/main/BENCHMARKS.md">BENCHMARKS</a><span class="sep">Β·</span>
328
+ <a href="https://usefeyn.com/blog/pulpie-pareto-optimal-models-for-cleaning-the-web/">BLOG</a><span class="sep">Β·</span>
329
+ BUILT BY <a href="https://usefeyn.com">FEYN</a>
330
+ </div>
331
+ </div>
332
+
333
+ <script type="module">
334
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
335
+ import { marked } from "https://cdn.jsdelivr.net/npm/marked@12/lib/marked.esm.js";
336
+ import DOMPurify from "https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.es.mjs";
337
+ marked.setOptions({ gfm:true, breaks:false, headerIds:false, mangle:false });
338
+
339
+ const $ = (id) => document.getElementById(id);
340
+ const runBtn=$("run-btn"), btnLabel=runBtn.querySelector(".btn-label");
341
+ const exampleSelect=$("example-select"), customHtml=$("custom-html");
342
+ const readPane=$("read-pane");
343
+ const pulpieChip=$("pulpie-chip"), dripperChip=$("dripper-chip");
344
+ const pulpieTimer=$("pulpie-timer"), dripperTimer=$("dripper-timer");
345
+ const pulpieBar=$("pulpie-bar"), dripperBar=$("dripper-bar");
346
+ const cmpCenter=$("cmp-center"), spNum=$("sp-num"), spLbl=$("sp-lbl");
347
+ const statusLine=$("status-line");
348
+ const flashEl=$("flash"), flashText=$("flash-text");
349
+ const connPill=$("conn-pill"), connText=$("conn-text");
350
+ const copyBtn=$("copy-btn"), diffBtn=$("diff-btn");
351
+
352
+ const PLACEHOLDERS = {
353
+ pulpie:'<div class="placeholder">One encoder forward pass over every block.</div>',
354
+ dripper:'<div class="placeholder">The same labels, one token at a time.</div>',
355
+ };
356
+ const SKEL = '<div class="sk-line"></div><div class="sk-line"></div><div class="sk-line"></div><div class="sk-line"></div><div class="sk-line"></div>';
357
+ let lastMd = { pulpie:"", dripper:"" };
358
+ let running = false, currentModel = "pulpie";
359
+
360
+ function renderMarkdown(md){ if(!md) return ""; return DOMPurify.sanitize(marked.parse(md)); }
361
+ function renderRead(){
362
+ const md = lastMd[currentModel];
363
+ if(md) readPane.innerHTML = renderMarkdown(md);
364
+ else if(running) readPane.innerHTML = SKEL;
365
+ else readPane.innerHTML = PLACEHOLDERS[currentModel];
366
+ }
367
+ function setRunning(on){
368
+ running = on;
369
+ runBtn.classList.toggle("running", on); runBtn.disabled=on;
370
+ btnLabel.textContent = on ? "EXTRACTING…" : "EXTRACT β†’";
371
+ if(on || lastMd[currentModel]) renderRead();
372
+ }
373
+ function showError(msg){ flashText.textContent=msg; flashEl.classList.add("show"); }
374
+ function clearError(){ flashEl.classList.remove("show"); }
375
+ function setStatus(html){ if(!html){ statusLine.classList.remove("show"); statusLine.innerHTML=""; return; } statusLine.innerHTML=html; statusLine.classList.add("show"); }
376
+ function setBar(bar, pct, indeterminate){
377
+ bar.classList.toggle("indeterminate", !!indeterminate);
378
+ if(indeterminate){ bar.style.right=""; }
379
+ else { bar.style.right = (100-Math.max(0,Math.min(100,pct)))+"%"; }
380
+ }
381
+ function setSpeedupIdle(){ cmpCenter.classList.add("idle"); spNum.textContent="β€”"; spLbl.textContent="Γ— FASTER Β· ON THIS PAGE"; }
382
+ function setSpeedup(num){
383
+ cmpCenter.classList.remove("idle");
384
+ spNum.textContent = num;
385
+ spLbl.textContent = "Γ— FASTER Β· ON THIS PAGE";
386
+ }
387
+ // Pull the speedup token ("12x" / "9.4x") out of the backend's verdict HTML:
388
+ // <div ...>PULPIE 0.42s β†’ DRIPPER 2.8s Β· <span ...>12x ON THIS PAGE</span></div>
389
+ function applyVerdict(v){
390
+ if(!v){ return; }
391
+ const m = v.match(/<span[^>]*>\s*([\d.]+x)\s+ON THIS PAGE\s*<\/span>/);
392
+ if(m){ setSpeedup(m[1]); setStatus(""); }
393
+ else { setSpeedupIdle(); setStatus(v); } // plain-text verdict (bad input / error)
394
+ }
395
+
396
+ function resetPanes(){
397
+ lastMd={pulpie:"",dripper:""};
398
+ pulpieChip.classList.remove("live"); dripperChip.classList.remove("live");
399
+ pulpieTimer.innerHTML=""; dripperTimer.innerHTML="";
400
+ setBar(pulpieBar,0); setBar(dripperBar,0);
401
+ setSpeedupIdle(); setStatus(""); clearError();
402
+ renderRead();
403
+ }
404
+
405
+ function normalizeRecord(d){
406
+ if(!d) return null;
407
+ if(typeof d==="object" && !Array.isArray(d) && "pulpie_md" in d) return d;
408
+ if(Array.isArray(d)){ for(const it of d){ if(it && typeof it==="object" && "pulpie_md" in it) return it; } }
409
+ return null;
410
+ }
411
+
412
+ function applyRecord(rec){
413
+ if(!rec) return;
414
+ if(rec.pulpie_md!==undefined && rec.pulpie_md!==""){
415
+ lastMd.pulpie=rec.pulpie_md;
416
+ pulpieChip.classList.add("live");
417
+ setBar(pulpieBar,100);
418
+ setTimeout(()=>pulpieChip.classList.remove("live"),900);
419
+ if(currentModel==="pulpie") renderRead();
420
+ }
421
+ if(rec.dripper_md!==undefined && rec.dripper_md!==""){
422
+ lastMd.dripper=rec.dripper_md;
423
+ dripperChip.classList.add("live");
424
+ if(currentModel==="dripper") renderRead();
425
+ }
426
+ if(rec.pulpie_timer!==undefined) pulpieTimer.innerHTML=rec.pulpie_timer;
427
+ if(rec.dripper_timer!==undefined) dripperTimer.innerHTML=rec.dripper_timer;
428
+ // dripper progress: indeterminate while streaming, full when the verdict lands
429
+ if(rec.dripper_md && !(rec.verdict)){ setBar(dripperBar,0,true); }
430
+ if(rec.verdict!==undefined && rec.verdict!==""){
431
+ applyVerdict(rec.verdict);
432
+ dripperChip.classList.remove("live");
433
+ setBar(dripperBar,100);
434
+ }
435
+ }
436
+
437
+ // ── Connect ──
438
+ let client=null;
439
+ try {
440
+ client = await Client.connect(window.location.origin, { events:["data","status"] });
441
+ connPill.classList.remove("connecting"); connText.textContent="READY";
442
+ } catch(e){
443
+ connPill.classList.remove("connecting"); connPill.classList.add("error"); connText.textContent="OFFLINE";
444
+ showError("Could not connect to backend: " + (e?.message||e));
445
+ }
446
+
447
+ // ── Run ──
448
+ async function run(){
449
+ if(!client){ showError("Backend not connected. Reload the page."); return; }
450
+ resetPanes(); setRunning(true);
451
+ const params = { example_name: exampleSelect.value, custom_html: customHtml.value.trim() };
452
+ try {
453
+ const submission = client.submit("/race", params);
454
+ let sawData=false;
455
+ for await (const msg of submission){
456
+ if(msg.type==="status"){
457
+ if(msg.stage==="pending" && msg.position!=null){
458
+ setStatus('<span style="color:var(--soft)">QUEUED Β· POSITION '+msg.position+'</span>');
459
+ } else if(msg.stage==="generating"){ if(!sawData) setStatus(""); }
460
+ continue;
461
+ }
462
+ if(msg.type==="data"){ sawData=true; applyRecord(normalizeRecord(msg.data)); }
463
+ }
464
+ if(!sawData) showError("Backend returned no data. Check the /race endpoint and Space logs.");
465
+ } catch(e){ showError("Race failed: " + (e?.message||e)); }
466
+ finally { setRunning(false); }
467
+ }
468
+ runBtn.addEventListener("click", run);
469
+ exampleSelect.addEventListener("keydown", e=>{ if(e.key==="Enter"){ e.preventDefault(); run(); } });
470
+ customHtml.addEventListener("keydown", e=>{ if(e.key==="Enter" && (e.metaKey||e.ctrlKey)){ e.preventDefault(); run(); } });
471
+
472
+ // ── Accordion ──
473
+ const acc=$("html-accordion"), accToggle=$("acc-toggle");
474
+ accToggle.addEventListener("click", ()=>{ const o=acc.classList.toggle("open"); accToggle.setAttribute("aria-expanded", o); });
475
+ accToggle.addEventListener("keydown", e=>{ if(e.key==="Enter"||e.key===" "){ e.preventDefault(); accToggle.click(); } });
476
+
477
+ // ── Model toggle ──
478
+ document.querySelectorAll(".toggle-seg button").forEach(b=>{
479
+ b.addEventListener("click", ()=>{
480
+ currentModel = b.getAttribute("data-model");
481
+ document.querySelectorAll(".toggle-seg button").forEach(x=>{ const on=x===b; x.classList.toggle("active",on); x.setAttribute("aria-selected",on); });
482
+ renderRead();
483
+ });
484
+ });
485
+
486
+ // ── Flash dismiss ──
487
+ $("flash-x").addEventListener("click", clearError);
488
+
489
+ // ── Copy ──
490
+ copyBtn.addEventListener("click", async ()=>{
491
+ const text = lastMd[currentModel];
492
+ if(!text) return;
493
+ try { await navigator.clipboard.writeText(text); copyBtn.textContent="βœ“ COPIED"; }
494
+ catch { copyBtn.textContent="COPY FAILED"; }
495
+ setTimeout(()=>(copyBtn.textContent="⧉ COPY MD"), 1400);
496
+ });
497
+
498
+ // ── Diff (against the other model, shown in the single reading pane) ──
499
+ let diffOpen=false, diffForModel=null;
500
+ diffBtn.addEventListener("click", ()=>{
501
+ if(diffOpen && diffForModel===currentModel){ renderRead(); diffOpen=false; diffBtn.textContent="β‰  DIFF"; return; }
502
+ const other = currentModel==="pulpie" ? "dripper" : "pulpie";
503
+ if(!lastMd[currentModel] || !lastMd[other]) return;
504
+ const a=lastMd.pulpie, b=lastMd.dripper, same=a.trim()===b.trim();
505
+ if(same){
506
+ readPane.innerHTML = renderMarkdown(lastMd[currentModel]) +
507
+ '<div style="font-family:\'IBM Plex Mono\',monospace;font-size:11px;color:var(--green);margin-top:14px;border-top:1px solid var(--hairline);padding-top:10px;">βœ“ IDENTICAL OUTPUTS β€” PULPIE &amp; DRIPPER MATCH</div>';
508
+ diffBtn.textContent="IDENTICAL";
509
+ } else {
510
+ readPane.innerHTML = '<pre style="white-space:pre-wrap;font-family:\'IBM Plex Mono\',monospace;font-size:12px;line-height:1.5;background:var(--sunken);border:1px solid var(--hairline);padding:12px;border-radius:8px;">'+escapeHtml(lineDiff(a,b))+'</pre>';
511
+ diffBtn.textContent="↩ BACK";
512
+ }
513
+ diffOpen=true; diffForModel=currentModel;
514
+ });
515
+
516
+ function lineDiff(a,b){
517
+ const la=a.split("\n"), lb=b.split("\n"), n=la.length, m=lb.length;
518
+ const dp=Array.from({length:n+1}, ()=>new Array(m+1).fill(0));
519
+ for(let i=n-1;i>=0;i--) for(let j=m-1;j>=0;j--) dp[i][j]=la[i]===lb[j]?dp[i+1][j+1]+1:Math.max(dp[i+1][j],dp[i][j+1]);
520
+ let i=0,j=0,out=[];
521
+ while(i<n&&j<m){ if(la[i]===lb[j]){ out.push(" "+la[i]); i++; j++; } else if(dp[i+1][j]>=dp[i][j+1]){ out.push("- "+la[i]); i++; } else { out.push("+ "+lb[j]); j++; } }
522
+ while(i<n) out.push("- "+la[i++]); while(j<m) out.push("+ "+lb[j++]);
523
+ return out.slice(-400).join("\n");
524
+ }
525
+ function escapeHtml(s){ return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
526
+
527
+ // ── Animate benchmark bars on first paint ──
528
+ requestAnimationFrame(()=>{ setTimeout(()=>{
529
+ requestAnimationFrame(()=>{ document.querySelectorAll(".bench-fill").forEach(f=>{ f.style.width=(f.getAttribute("data-pct")||0)+"%"; }); });
530
+ }, 120); });
531
+ </script>
532
+ </body>
533
+ </html>