From 8b75e0ea302c95e44da04f0026d727f21137baa2 Mon Sep 17 00:00:00 2001 From: Sebastian Werner Date: Mon, 6 Jul 2026 20:20:11 +0200 Subject: [PATCH 1/2] feat(site): polish homepage and add realistic 10k-message benchmark MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes from the homepage review: - Hero: add the large Palamedes logo medallion above the headline. - Section 04 (Scope): rewrite from architecture jargon (transform, ferrocat, "locale negotiation", "compile-time authoring") into plain developer language. The scope diagram now contrasts what Palamedes owns (write messages, extract & update, catalog, runtime lookup — same on every framework) against what your framework owns (routing, locale detection, rendering, hosting — different per stack), with the payoff line: switch frameworks and only the bottom row changes. - CodeShowcase: fix broken tab highlight. Base UI (RC) emits aria-selected, not data-selected, so the data-[selected] utilities never matched. Switch to aria-selected + a bronze top rule so the active tab is unmistakable. - TerminalCascade: stop the size jump (report bars are now always reserved and fade in) and give the three panels equal width with a clean gap (was a negative-margin overlap plus a right indent that made the lower two narrower). - Nav: widen the header link padding for proper rhythm. - Add WorkflowFlow (Write -> Extract -> Translate -> Render) above the code showcase to explain the pipeline visually. - LocaleBookingCards: render the three locales across the full width instead of a narrow left column. - BenchmarkChart: drop the in-bar ratio annotation, round the median timings to whole ms, align the axis labels under the bars, and round the axis maximum up to a clean value with headroom (0-1200 ms) so the longest bar never ends hard on the frame. Show one representative corpus chart on home and proof — the three-size comparison didn't add insight. Benchmark + proof: - Add a realistic profile (400 files, 10,000 messages) to the e2e workflow harness, include it in the default run, and re-run: Palamedes 12.66x faster than Lingui on the 10k corpus. Home, ProofStrip, prose, compare, proof, and the linked methodology docs all quote the fresh numbers; drift guard extended to cover the realistic section. - Matrix: subdomain demos are live (verified reachable, all 15 return 200 and render their locale) so promote them from provisioning to live links; TLD stays provisioning (domains still unresolved). Update the stale reachability note in demo-deployments.md. Co-Authored-By: Claude Opus 4.8 --- benchmarks/e2e-workflow/results/latest.json | 494 +++++++++++++----- benchmarks/e2e-workflow/results/latest.md | 41 +- benchmarks/e2e-workflow/src/corpus.mjs | 7 + benchmarks/e2e-workflow/src/run.mjs | 2 +- docs/benchmark-e2e-workflow.md | 36 +- docs/demo-deployments.md | 7 +- docs/proof-and-benchmarks.md | 11 +- scripts/verify-site-bench-data.mjs | 17 + site/app/app.css | 2 +- site/app/components/home/CodeShowcase.tsx | 8 +- .../components/home/LocaleBookingCards.tsx | 2 +- site/app/components/home/ProofStrip.tsx | 4 +- site/app/components/home/StatementBand.tsx | 29 +- site/app/components/home/TerminalCascade.tsx | 13 +- site/app/components/home/WorkflowFlow.tsx | 63 +++ site/app/components/proof/BenchmarkChart.tsx | 59 ++- site/app/data/bench.ts | 30 +- site/app/data/compare.ts | 6 +- site/app/data/matrix.ts | 12 +- site/app/routes/home.tsx | 29 +- site/app/routes/proof.tsx | 5 +- 21 files changed, 644 insertions(+), 233 deletions(-) create mode 100644 site/app/components/home/WorkflowFlow.tsx diff --git a/benchmarks/e2e-workflow/results/latest.json b/benchmarks/e2e-workflow/results/latest.json index 7ebacd77..f6a40213 100644 --- a/benchmarks/e2e-workflow/results/latest.json +++ b/benchmarks/e2e-workflow/results/latest.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "benchmark": "palamedes-e2e-extract-update-workflow", - "generatedAt": "2026-07-06T09:09:36.849Z", + "generatedAt": "2026-07-06T18:05:29.525Z", "machineLocal": true, "seed": 20260703, "warmup": 3, @@ -11,12 +11,12 @@ "nodeVersion": "v24.18.0", "platform": "darwin", "arch": "arm64", - "generatedAt": "2026-07-06T09:09:36.849Z" + "generatedAt": "2026-07-06T18:05:29.525Z" }, "versions": { "benchmarkPackage": "@palamedes/benchmark-e2e-workflow", "palamedes": { - "cli": "pmds (Palamedes) v1.2.0" + "cli": "pmds (Palamedes) v1.3.0" }, "lingui": { "cli": "6.4.0" @@ -70,7 +70,7 @@ "results": [ { "tool": "palamedes", - "version": "pmds (Palamedes) v1.2.0", + "version": "pmds (Palamedes) v1.3.0", "profile": "small", "fileCount": 80, "sourceMessageCount": 640, @@ -81,23 +81,23 @@ "sourceBytes": 22960, "warmup": 3, "runs": 7, - "medianMs": 33.583292, + "medianMs": 33.406833, "rawSamplesMs": [ - 31.939375, - 32.455208, - 33.127292, - 33.583292, - 34.592125, - 34.679875, - 34.839459 + 32.609792, + 32.987958, + 33.38825, + 33.406833, + 34.685042, + 34.806417, + 39.011041 ], "stdoutBytes": 178, "stderrBytes": 0, "palamedesTiming": { - "totalMs": 26, + "totalMs": 27, "globMs": 0, "extractMs": 3, - "writeMs": 22, + "writeMs": 23, "totalMessages": 640, "totalFiles": 80 } @@ -115,17 +115,17 @@ "sourceBytes": 22960, "warmup": 3, "runs": 7, - "medianMs": 705.124708, + "medianMs": 738.179333, "rawSamplesMs": [ - 685.258209, - 693.789292, - 702.004625, - 705.124708, - 707.62275, - 713.791166, - 714.190875 + 725.374458, + 727.513333, + 734.014084, + 738.179333, + 743.532083, + 748.106459, + 774.963583 ], - "stdoutBytes": 787, + "stdoutBytes": 755, "stderrBytes": 18 }, { @@ -141,17 +141,17 @@ "sourceBytes": 22960, "warmup": 3, "runs": 7, - "medianMs": 526.454792, + "medianMs": 531.385875, "rawSamplesMs": [ - 513.875041, - 515.759834, - 521.805042, - 526.454792, - 533.265417, - 533.463791, - 590.449 + 509.646458, + 512.649541, + 524.157792, + 531.385875, + 533.002542, + 533.166208, + 534.876959 ], - "stdoutBytes": 12753, + "stdoutBytes": 11883, "stderrBytes": 0 } ] @@ -194,7 +194,7 @@ "results": [ { "tool": "palamedes", - "version": "pmds (Palamedes) v1.2.0", + "version": "pmds (Palamedes) v1.3.0", "profile": "medium", "fileCount": 240, "sourceMessageCount": 1920, @@ -205,23 +205,23 @@ "sourceBytes": 68881, "warmup": 3, "runs": 7, - "medianMs": 47.7695, + "medianMs": 46.266458, "rawSamplesMs": [ - 46.399417, - 46.970375, - 47.256083, - 47.7695, - 48.136709, - 49.510833, - 50.739209 + 42.961583, + 45.010791, + 45.765375, + 46.266458, + 46.463, + 46.749792, + 48.281084 ], "stdoutBytes": 183, "stderrBytes": 0, "palamedesTiming": { - "totalMs": 41, + "totalMs": 40, "globMs": 1, - "extractMs": 10, - "writeMs": 29, + "extractMs": 9, + "writeMs": 28, "totalMessages": 1920, "totalFiles": 240 } @@ -239,17 +239,17 @@ "sourceBytes": 68881, "warmup": 3, "runs": 7, - "medianMs": 812.833709, + "medianMs": 800.749625, "rawSamplesMs": [ - 791.693209, - 806.037625, - 808.452458, - 812.833709, - 815.027666, - 822.437125, - 849.054625 + 786.811375, + 791.563834, + 793.403833, + 800.749625, + 804.889333, + 814.356584, + 847.850666 ], - "stdoutBytes": 787, + "stdoutBytes": 755, "stderrBytes": 18 }, { @@ -265,17 +265,141 @@ "sourceBytes": 68881, "warmup": 3, "runs": 7, - "medianMs": 587.520583, + "medianMs": 582.999875, "rawSamplesMs": [ - 570.131, - 573.34225, - 574.8245, - 587.520583, - 587.70775, - 589.265459, - 597.196958 + 567.697625, + 568.527167, + 576.003959, + 582.999875, + 583.251416, + 585.267625, + 617.984541 ], - "stdoutBytes": 37529, + "stdoutBytes": 35059, + "stderrBytes": 0 + } + ] + }, + { + "profile": "realistic", + "corpus": { + "fileCount": 400, + "messagesPerFile": 25, + "sourceMessageCount": 10000, + "baselineMessageCount": 9750, + "changedCount": 750, + "newCount": 1000, + "removedCount": 750, + "sourceBytes": 358740 + }, + "validation": { + "expectedActiveMessages": 10000, + "tools": { + "palamedes": { + "activeMessagesByLocale": { + "en": 10000, + "de": 10000 + } + }, + "lingui": { + "activeMessagesByLocale": { + "en": 10000, + "de": 10000 + } + }, + "i18next": { + "activeMessagesByLocale": { + "en": 10000, + "de": 10000 + } + } + } + }, + "results": [ + { + "tool": "palamedes", + "version": "pmds (Palamedes) v1.3.0", + "profile": "realistic", + "fileCount": 400, + "sourceMessageCount": 10000, + "baselineMessageCount": 9750, + "changedCount": 750, + "newCount": 1000, + "removedCount": 750, + "sourceBytes": 358740, + "warmup": 3, + "runs": 7, + "medianMs": 83.753833, + "rawSamplesMs": [ + 80.418125, + 82.786833, + 83.686625, + 83.753833, + 84.204, + 84.99, + 90.306208 + ], + "stdoutBytes": 185, + "stderrBytes": 0, + "palamedesTiming": { + "totalMs": 77, + "globMs": 2, + "extractMs": 24, + "writeMs": 48, + "totalMessages": 10000, + "totalFiles": 400 + } + }, + { + "tool": "lingui", + "version": "6.4.0", + "profile": "realistic", + "fileCount": 400, + "sourceMessageCount": 10000, + "baselineMessageCount": 9750, + "changedCount": 750, + "newCount": 1000, + "removedCount": 750, + "sourceBytes": 358740, + "warmup": 3, + "runs": 7, + "medianMs": 1060.236458, + "rawSamplesMs": [ + 1040.99375, + 1051.32075, + 1058.282625, + 1060.236458, + 1071.148167, + 1096.196791, + 1109.829625 + ], + "stdoutBytes": 755, + "stderrBytes": 18 + }, + { + "tool": "i18next", + "version": "9.4.0", + "profile": "realistic", + "fileCount": 400, + "sourceMessageCount": 10000, + "baselineMessageCount": 9750, + "changedCount": 750, + "newCount": 1000, + "removedCount": 750, + "sourceBytes": 358740, + "warmup": 3, + "runs": 7, + "medianMs": 787.146625, + "rawSamplesMs": [ + 779.014458, + 783.769625, + 786.497375, + 787.146625, + 792.85375, + 801.310625, + 812.540208 + ], + "stdoutBytes": 59359, "stderrBytes": 0 } ] @@ -284,7 +408,7 @@ "results": [ { "tool": "palamedes", - "version": "pmds (Palamedes) v1.2.0", + "version": "pmds (Palamedes) v1.3.0", "profile": "small", "fileCount": 80, "sourceMessageCount": 640, @@ -295,23 +419,23 @@ "sourceBytes": 22960, "warmup": 3, "runs": 7, - "medianMs": 33.583292, + "medianMs": 33.406833, "rawSamplesMs": [ - 31.939375, - 32.455208, - 33.127292, - 33.583292, - 34.592125, - 34.679875, - 34.839459 + 32.609792, + 32.987958, + 33.38825, + 33.406833, + 34.685042, + 34.806417, + 39.011041 ], "stdoutBytes": 178, "stderrBytes": 0, "palamedesTiming": { - "totalMs": 26, + "totalMs": 27, "globMs": 0, "extractMs": 3, - "writeMs": 22, + "writeMs": 23, "totalMessages": 640, "totalFiles": 80 } @@ -329,17 +453,17 @@ "sourceBytes": 22960, "warmup": 3, "runs": 7, - "medianMs": 705.124708, + "medianMs": 738.179333, "rawSamplesMs": [ - 685.258209, - 693.789292, - 702.004625, - 705.124708, - 707.62275, - 713.791166, - 714.190875 + 725.374458, + 727.513333, + 734.014084, + 738.179333, + 743.532083, + 748.106459, + 774.963583 ], - "stdoutBytes": 787, + "stdoutBytes": 755, "stderrBytes": 18 }, { @@ -355,22 +479,22 @@ "sourceBytes": 22960, "warmup": 3, "runs": 7, - "medianMs": 526.454792, + "medianMs": 531.385875, "rawSamplesMs": [ - 513.875041, - 515.759834, - 521.805042, - 526.454792, - 533.265417, - 533.463791, - 590.449 + 509.646458, + 512.649541, + 524.157792, + 531.385875, + 533.002542, + 533.166208, + 534.876959 ], - "stdoutBytes": 12753, + "stdoutBytes": 11883, "stderrBytes": 0 }, { "tool": "palamedes", - "version": "pmds (Palamedes) v1.2.0", + "version": "pmds (Palamedes) v1.3.0", "profile": "medium", "fileCount": 240, "sourceMessageCount": 1920, @@ -381,23 +505,23 @@ "sourceBytes": 68881, "warmup": 3, "runs": 7, - "medianMs": 47.7695, + "medianMs": 46.266458, "rawSamplesMs": [ - 46.399417, - 46.970375, - 47.256083, - 47.7695, - 48.136709, - 49.510833, - 50.739209 + 42.961583, + 45.010791, + 45.765375, + 46.266458, + 46.463, + 46.749792, + 48.281084 ], "stdoutBytes": 183, "stderrBytes": 0, "palamedesTiming": { - "totalMs": 41, + "totalMs": 40, "globMs": 1, - "extractMs": 10, - "writeMs": 29, + "extractMs": 9, + "writeMs": 28, "totalMessages": 1920, "totalFiles": 240 } @@ -415,17 +539,17 @@ "sourceBytes": 68881, "warmup": 3, "runs": 7, - "medianMs": 812.833709, + "medianMs": 800.749625, "rawSamplesMs": [ - 791.693209, - 806.037625, - 808.452458, - 812.833709, - 815.027666, - 822.437125, - 849.054625 + 786.811375, + 791.563834, + 793.403833, + 800.749625, + 804.889333, + 814.356584, + 847.850666 ], - "stdoutBytes": 787, + "stdoutBytes": 755, "stderrBytes": 18 }, { @@ -441,17 +565,103 @@ "sourceBytes": 68881, "warmup": 3, "runs": 7, - "medianMs": 587.520583, + "medianMs": 582.999875, + "rawSamplesMs": [ + 567.697625, + 568.527167, + 576.003959, + 582.999875, + 583.251416, + 585.267625, + 617.984541 + ], + "stdoutBytes": 35059, + "stderrBytes": 0 + }, + { + "tool": "palamedes", + "version": "pmds (Palamedes) v1.3.0", + "profile": "realistic", + "fileCount": 400, + "sourceMessageCount": 10000, + "baselineMessageCount": 9750, + "changedCount": 750, + "newCount": 1000, + "removedCount": 750, + "sourceBytes": 358740, + "warmup": 3, + "runs": 7, + "medianMs": 83.753833, + "rawSamplesMs": [ + 80.418125, + 82.786833, + 83.686625, + 83.753833, + 84.204, + 84.99, + 90.306208 + ], + "stdoutBytes": 185, + "stderrBytes": 0, + "palamedesTiming": { + "totalMs": 77, + "globMs": 2, + "extractMs": 24, + "writeMs": 48, + "totalMessages": 10000, + "totalFiles": 400 + } + }, + { + "tool": "lingui", + "version": "6.4.0", + "profile": "realistic", + "fileCount": 400, + "sourceMessageCount": 10000, + "baselineMessageCount": 9750, + "changedCount": 750, + "newCount": 1000, + "removedCount": 750, + "sourceBytes": 358740, + "warmup": 3, + "runs": 7, + "medianMs": 1060.236458, + "rawSamplesMs": [ + 1040.99375, + 1051.32075, + 1058.282625, + 1060.236458, + 1071.148167, + 1096.196791, + 1109.829625 + ], + "stdoutBytes": 755, + "stderrBytes": 18 + }, + { + "tool": "i18next", + "version": "9.4.0", + "profile": "realistic", + "fileCount": 400, + "sourceMessageCount": 10000, + "baselineMessageCount": 9750, + "changedCount": 750, + "newCount": 1000, + "removedCount": 750, + "sourceBytes": 358740, + "warmup": 3, + "runs": 7, + "medianMs": 787.146625, "rawSamplesMs": [ - 570.131, - 573.34225, - 574.8245, - 587.520583, - 587.70775, - 589.265459, - 597.196958 + 779.014458, + 783.769625, + 786.497375, + 787.146625, + 792.85375, + 801.310625, + 812.540208 ], - "stdoutBytes": 37529, + "stdoutBytes": 59359, "stderrBytes": 0 } ], @@ -461,36 +671,54 @@ "baselineTool": "palamedes", "comparedTool": "lingui", "fasterTool": "palamedes", - "palamedesMedianMs": 33.583292, - "comparedMedianMs": 705.124708, - "speedupFactor": 20.996295062437596 + "palamedesMedianMs": 33.406833, + "comparedMedianMs": 738.179333, + "speedupFactor": 22.096657082100542 }, { "profile": "small", "baselineTool": "palamedes", "comparedTool": "i18next", "fasterTool": "palamedes", - "palamedesMedianMs": 33.583292, - "comparedMedianMs": 526.454792, - "speedupFactor": 15.676092504570427 + "palamedesMedianMs": 33.406833, + "comparedMedianMs": 531.385875, + "speedupFactor": 15.906502570896201 }, { "profile": "medium", "baselineTool": "palamedes", "comparedTool": "lingui", "fasterTool": "palamedes", - "palamedesMedianMs": 47.7695, - "comparedMedianMs": 812.833709, - "speedupFactor": 17.01574663749882 + "palamedesMedianMs": 46.266458, + "comparedMedianMs": 800.749625, + "speedupFactor": 17.30734660950272 }, { "profile": "medium", "baselineTool": "palamedes", "comparedTool": "i18next", "fasterTool": "palamedes", - "palamedesMedianMs": 47.7695, - "comparedMedianMs": 587.520583, - "speedupFactor": 12.299073320842798 + "palamedesMedianMs": 46.266458, + "comparedMedianMs": 582.999875, + "speedupFactor": 12.60091868281769 + }, + { + "profile": "realistic", + "baselineTool": "palamedes", + "comparedTool": "lingui", + "fasterTool": "palamedes", + "palamedesMedianMs": 83.753833, + "comparedMedianMs": 1060.236458, + "speedupFactor": 12.658960432294485 + }, + { + "profile": "realistic", + "baselineTool": "palamedes", + "comparedTool": "i18next", + "fasterTool": "palamedes", + "palamedesMedianMs": 83.753833, + "comparedMedianMs": 787.146625, + "speedupFactor": 9.398335536476283 } ] } \ No newline at end of file diff --git a/benchmarks/e2e-workflow/results/latest.md b/benchmarks/e2e-workflow/results/latest.md index 7ceba98d..88d28d34 100644 --- a/benchmarks/e2e-workflow/results/latest.md +++ b/benchmarks/e2e-workflow/results/latest.md @@ -1,6 +1,6 @@ # End-to-End Extract and Catalog Update Benchmark -Generated: 2026-07-06T09:09:36.849Z +Generated: 2026-07-06T18:05:29.525Z Node: v24.18.0 Platform: darwin/arm64 Seed: 20260703 @@ -10,7 +10,7 @@ Machine-local: yes ## Versions -- Palamedes CLI: pmds (Palamedes) v1.2.0 +- Palamedes CLI: pmds (Palamedes) v1.3.0 - Lingui CLI: 6.4.0 - i18next-parser CLI: 9.4.0 @@ -29,14 +29,14 @@ Machine-local: yes | Tool | Median | Samples | | --- | ---: | --- | -| Palamedes | 33.58 ms | 31.94 ms, 32.46 ms, 33.13 ms, 33.58 ms, 34.59 ms, 34.68 ms, 34.84 ms | -| Lingui | 705.12 ms | 685.26 ms, 693.79 ms, 702.00 ms, 705.12 ms, 707.62 ms, 713.79 ms, 714.19 ms | -| i18next-parser | 526.45 ms | 513.88 ms, 515.76 ms, 521.81 ms, 526.45 ms, 533.27 ms, 533.46 ms, 590.45 ms | +| Palamedes | 33.41 ms | 32.61 ms, 32.99 ms, 33.39 ms, 33.41 ms, 34.69 ms, 34.81 ms, 39.01 ms | +| Lingui | 738.18 ms | 725.37 ms, 727.51 ms, 734.01 ms, 738.18 ms, 743.53 ms, 748.11 ms, 774.96 ms | +| i18next-parser | 531.39 ms | 509.65 ms, 512.65 ms, 524.16 ms, 531.39 ms, 533.00 ms, 533.17 ms, 534.88 ms | | Comparison | Faster | Speedup | | --- | --- | ---: | -| Palamedes vs Lingui | Palamedes | 21.00x | -| Palamedes vs i18next-parser | Palamedes | 15.68x | +| Palamedes vs Lingui | Palamedes | 22.10x | +| Palamedes vs i18next-parser | Palamedes | 15.91x | ## Medium @@ -46,14 +46,31 @@ Machine-local: yes | Tool | Median | Samples | | --- | ---: | --- | -| Palamedes | 47.77 ms | 46.40 ms, 46.97 ms, 47.26 ms, 47.77 ms, 48.14 ms, 49.51 ms, 50.74 ms | -| Lingui | 812.83 ms | 791.69 ms, 806.04 ms, 808.45 ms, 812.83 ms, 815.03 ms, 822.44 ms, 849.05 ms | -| i18next-parser | 587.52 ms | 570.13 ms, 573.34 ms, 574.82 ms, 587.52 ms, 587.71 ms, 589.27 ms, 597.20 ms | +| Palamedes | 46.27 ms | 42.96 ms, 45.01 ms, 45.77 ms, 46.27 ms, 46.46 ms, 46.75 ms, 48.28 ms | +| Lingui | 800.75 ms | 786.81 ms, 791.56 ms, 793.40 ms, 800.75 ms, 804.89 ms, 814.36 ms, 847.85 ms | +| i18next-parser | 583.00 ms | 567.70 ms, 568.53 ms, 576.00 ms, 583.00 ms, 583.25 ms, 585.27 ms, 617.98 ms | | Comparison | Faster | Speedup | | --- | --- | ---: | -| Palamedes vs Lingui | Palamedes | 17.02x | -| Palamedes vs i18next-parser | Palamedes | 12.30x | +| Palamedes vs Lingui | Palamedes | 17.31x | +| Palamedes vs i18next-parser | Palamedes | 12.60x | + +## Realistic + +- Corpus: 400 files, 10000 current messages, 9750 baseline messages +- Inventory mix: 750 changed, 1000 new, 750 removed +- Semantic validation: 10000 active messages per locale and tool + +| Tool | Median | Samples | +| --- | ---: | --- | +| Palamedes | 83.75 ms | 80.42 ms, 82.79 ms, 83.69 ms, 83.75 ms, 84.20 ms, 84.99 ms, 90.31 ms | +| Lingui | 1060.24 ms | 1040.99 ms, 1051.32 ms, 1058.28 ms, 1060.24 ms, 1071.15 ms, 1096.20 ms, 1109.83 ms | +| i18next-parser | 787.15 ms | 779.01 ms, 783.77 ms, 786.50 ms, 787.15 ms, 792.85 ms, 801.31 ms, 812.54 ms | + +| Comparison | Faster | Speedup | +| --- | --- | ---: | +| Palamedes vs Lingui | Palamedes | 12.66x | +| Palamedes vs i18next-parser | Palamedes | 9.40x | ## Notes diff --git a/benchmarks/e2e-workflow/src/corpus.mjs b/benchmarks/e2e-workflow/src/corpus.mjs index 786d773a..b7a0dd76 100644 --- a/benchmarks/e2e-workflow/src/corpus.mjs +++ b/benchmarks/e2e-workflow/src/corpus.mjs @@ -18,6 +18,13 @@ export const PROFILE_DEFINITIONS = { newCount: 192, removedCount: 144, }, + realistic: { + fileCount: 400, + messagesPerFile: 25, + changedCount: 750, + newCount: 1000, + removedCount: 750, + }, large: { fileCount: 720, messagesPerFile: 8, diff --git a/benchmarks/e2e-workflow/src/run.mjs b/benchmarks/e2e-workflow/src/run.mjs index c5dce890..5e4d4407 100644 --- a/benchmarks/e2e-workflow/src/run.mjs +++ b/benchmarks/e2e-workflow/src/run.mjs @@ -119,7 +119,7 @@ function parseArgs(argv) { function readProfiles(argv) { const index = argv.indexOf("--profile") if (index === -1) { - return ["small", "medium"] + return ["small", "medium", "realistic"] } const value = argv[index + 1] diff --git a/docs/benchmark-e2e-workflow.md b/docs/benchmark-e2e-workflow.md index de2b9b30..660d2130 100644 --- a/docs/benchmark-e2e-workflow.md +++ b/docs/benchmark-e2e-workflow.md @@ -106,11 +106,11 @@ Median results: | Tool | Median | | -------------- | ----------: | -| Palamedes | `33.58 ms` | -| Lingui | `705.12 ms` | -| i18next-parser | `526.45 ms` | +| Palamedes | `33.41 ms` | +| Lingui | `738.18 ms` | +| i18next-parser | `531.39 ms` | -On this run, Palamedes measured `21.00x` faster than Lingui and `15.68x` +On this run, Palamedes measured `22.10x` faster than Lingui and `15.91x` faster than i18next-parser. ### Medium @@ -126,11 +126,31 @@ Median results: | Tool | Median | | -------------- | ----------: | -| Palamedes | `47.77 ms` | -| Lingui | `812.83 ms` | -| i18next-parser | `587.52 ms` | +| Palamedes | `46.27 ms` | +| Lingui | `800.75 ms` | +| i18next-parser | `583.00 ms` | -On this run, Palamedes measured `17.02x` faster than Lingui and `12.30x` +On this run, Palamedes measured `17.31x` faster than Lingui and `12.60x` +faster than i18next-parser. + +### Realistic + +Corpus: + +- `400` files +- `10,000` current messages +- `9,750` baseline messages +- `750` changed, `1,000` new, `750` removed + +Median results: + +| Tool | Median | +| -------------- | -----------: | +| Palamedes | `83.75 ms` | +| Lingui | `1060.24 ms` | +| i18next-parser | `787.15 ms` | + +On this run, Palamedes measured `12.66x` faster than Lingui and `9.40x` faster than i18next-parser. ## Reading The Numbers diff --git a/docs/demo-deployments.md b/docs/demo-deployments.md index 891e5c77..8bb652b4 100644 --- a/docs/demo-deployments.md +++ b/docs/demo-deployments.md @@ -110,9 +110,10 @@ must send `Vary: Host`); otherwise a response rendered for one locale host could served for another. This is the same constraint the per-host routing already implies, but it must hold for caching layers too. -Until these records and proxy routes exist, the five subdomain rows above are not -yet reachable; the canonical verification path remains `pnpm verify:examples`, -which exercises the subdomain strategy locally via `*.lvh.me` hosts. +These records and proxy routes are now in place, so the five subdomain rows above +are publicly reachable (each locale host returns 200 and renders its locale). The +canonical verification path remains `pnpm verify:examples`, which exercises the +subdomain strategy locally via `*.lvh.me` hosts. ## TLD Locale Hosting (DNS And Reverse Proxy) diff --git a/docs/proof-and-benchmarks.md b/docs/proof-and-benchmarks.md index c3cc8bea..1fc68001 100644 --- a/docs/proof-and-benchmarks.md +++ b/docs/proof-and-benchmarks.md @@ -236,16 +236,17 @@ Environment: - Node `v24.18.0` - macOS `darwin/arm64` -- Palamedes CLI `1.2.0` +- Palamedes CLI `1.3.0` - Lingui CLI `6.4.0` - i18next-parser CLI `9.4.0` Median results from that run: -| Profile | Palamedes | Lingui | i18next-parser | -| ------- | ---------: | ----------: | -------------: | -| Small | `33.58 ms` | `705.12 ms` | `526.45 ms` | -| Medium | `47.77 ms` | `812.83 ms` | `587.52 ms` | +| Profile | Palamedes | Lingui | i18next-parser | +| --------- | ---------: | -----------: | -------------: | +| Small | `33.41 ms` | `738.18 ms` | `531.39 ms` | +| Medium | `46.27 ms` | `800.75 ms` | `583.00 ms` | +| Realistic | `83.75 ms` | `1060.24 ms` | `787.15 ms` | The harness validates that all three tools write the same active source-message set before publishing timings. It renders the same logical message inventory diff --git a/scripts/verify-site-bench-data.mjs b/scripts/verify-site-bench-data.mjs index 86bcb95e..9021d9f2 100644 --- a/scripts/verify-site-bench-data.mjs +++ b/scripts/verify-site-bench-data.mjs @@ -158,8 +158,10 @@ function expect(label, condition) { const small = parseSection("Small") const medium = parseSection("Medium") +const realistic = parseSection("Realistic") const benchSmall = parseBenchSection("SMALL") const benchMedium = parseBenchSection("MEDIUM") +const benchRealistic = parseBenchSection("REALISTIC") const checks = [ ["small Palamedes median", small.medians.Palamedes, benchSmall.medians.Palamedes], @@ -172,6 +174,13 @@ const checks = [ medium.medians["i18next-parser"], benchMedium.medians["i18next-parser"], ], + ["realistic Palamedes median", realistic.medians.Palamedes, benchRealistic.medians.Palamedes], + ["realistic Lingui median", realistic.medians.Lingui, benchRealistic.medians.Lingui], + [ + "realistic i18next median", + realistic.medians["i18next-parser"], + benchRealistic.medians["i18next-parser"], + ], ] for (const [label, reported, hardcoded] of checks) { @@ -194,5 +203,13 @@ expect( `medium speedup vs i18next (${medium.speedups["i18next-parser"]}x)`, medium.speedups["i18next-parser"] === benchMedium.speedups["i18next-parser"] ) +expect( + `realistic speedup vs Lingui (${realistic.speedups.Lingui}x)`, + realistic.speedups.Lingui === benchRealistic.speedups.Lingui +) +expect( + `realistic speedup vs i18next (${realistic.speedups["i18next-parser"]}x)`, + realistic.speedups["i18next-parser"] === benchRealistic.speedups["i18next-parser"] +) console.log("verify-site-bench-data: bench.ts matches latest.md") diff --git a/site/app/app.css b/site/app/app.css index 7825ad83..f1eb2cce 100644 --- a/site/app/app.css +++ b/site/app/app.css @@ -207,7 +207,7 @@ header a[href="/"] span { * appended by the component). */ .pmds-nav-link { - padding: 0.5rem 0.15rem; + padding: 0.5rem 0.7rem; border-bottom: 2px solid transparent; font-family: var(--font-mono); font-size: 11px; diff --git a/site/app/components/home/CodeShowcase.tsx b/site/app/components/home/CodeShowcase.tsx index 6040c3a7..ebe0619e 100644 --- a/site/app/components/home/CodeShowcase.tsx +++ b/site/app/components/home/CodeShowcase.tsx @@ -82,12 +82,12 @@ export function CodeShowcase() { return (
- + {TABS.map((tab) => ( {tab.label} @@ -113,9 +113,7 @@ export function CodeShowcase() {

The same component, in every locale, in every framework.

-
- -
+
) diff --git a/site/app/components/home/LocaleBookingCards.tsx b/site/app/components/home/LocaleBookingCards.tsx index 69fc3440..154546fa 100644 --- a/site/app/components/home/LocaleBookingCards.tsx +++ b/site/app/components/home/LocaleBookingCards.tsx @@ -19,7 +19,7 @@ export function LocaleBookingCards() { return (
-
+
{LOCALE_CARDS.map((card, index) => { const active = !cycling || index === activeIndex return ( diff --git a/site/app/components/home/ProofStrip.tsx b/site/app/components/home/ProofStrip.tsx index 2e47e934..3697dee7 100644 --- a/site/app/components/home/ProofStrip.tsx +++ b/site/app/components/home/ProofStrip.tsx @@ -15,8 +15,8 @@ const STATS: Stat[] = [ { value: "20", label: "browser-verified example apps", href: "/frameworks" }, { value: "5 × 4", label: "frameworks × locale strategies", href: "/frameworks" }, { - value: "21.0×", - label: "faster than Lingui — checked extract/update benchmark, machine-local run", + value: "12.66×", + label: "faster than Lingui — 10k-message extract/update benchmark, machine-local run", href: "/proof", }, { value: "16", label: "ADRs documenting every tradeoff", href: decisionHref() }, diff --git a/site/app/components/home/StatementBand.tsx b/site/app/components/home/StatementBand.tsx index 2479edaa..7355dd88 100644 --- a/site/app/components/home/StatementBand.tsx +++ b/site/app/components/home/StatementBand.tsx @@ -2,7 +2,10 @@ import type { ReactNode } from "react" /* * The dark positioning band — Home's single deliberate style break. The - * ScopeDiagram renders what Palamedes owns vs. what the adapter/host owns. + * ScopeDiagram renders, in developer-facing terms, the part Palamedes owns + * (stable across frameworks) vs. the part your framework owns (differs per + * stack) — and the payoff line: switching frameworks only touches the bottom + * row. Labels are intentionally plain, not the internal architecture names. */ export function StatementBand({ num, @@ -23,42 +26,50 @@ export function StatementBand({ ) } -const OWNED = ["transform", "extract", "catalog", "runtime"] -const HOST = ["routing", "locale negotiation", "rendering", "deployment"] +const OWNED = ["write messages", "extract & update", "catalog (.po)", "runtime lookup"] +const HOST = ["routing & URLs", "locale detection", "rendering", "hosting"] function ScopeDiagram() { return (
-

Palamedes owns

+

+ Palamedes owns it — same on every framework +

{OWNED.map((label) => ( {label} ))}

- └── ferrocat + rust core ──┘ + └─── one Rust core, everywhere ───┘

-

Adapter / host owns

+

+ Your framework owns it — different on every stack +

{HOST.map((label) => ( {label} ))}
+

+ Switch frameworks, and only the bottom row changes.{" "} + The top row is the same code you already wrote. +

) } diff --git a/site/app/components/home/TerminalCascade.tsx b/site/app/components/home/TerminalCascade.tsx index a166185d..19a50141 100644 --- a/site/app/components/home/TerminalCascade.tsx +++ b/site/app/components/home/TerminalCascade.tsx @@ -16,7 +16,7 @@ const LINE_INTERVAL_MS = 220 /* * The V7 "CLI and repo ownership" hero visual inside the Swiss system: - * three overlapping ink panels replaying pmds extract → audit → report. + * three equal-width ink panels replaying pmds extract → audit → report. * Prerender/no-JS/reduced-motion state: all lines visible, no cursor. */ export function TerminalCascade() { @@ -62,8 +62,7 @@ export function TerminalCascade() { return (
0 ? "-mt-3 ml-6 max-tight:ml-3" : ""}`} - style={{ position: "relative", zIndex: panelIndex }} + className={`border border-hair bg-ink ${panelIndex > 0 ? "mt-4" : ""}`} >
@@ -92,8 +91,12 @@ export function TerminalCascade() {
) })} - {isLast && visibleLines >= totalLines ? ( -
+ {isLast ? ( +
= totalLines ? 1 : 0 }} + aria-hidden + > {REPORT_BARS.map((bar) => (
{bar.locale} diff --git a/site/app/components/home/WorkflowFlow.tsx b/site/app/components/home/WorkflowFlow.tsx new file mode 100644 index 00000000..d8e86afc --- /dev/null +++ b/site/app/components/home/WorkflowFlow.tsx @@ -0,0 +1,63 @@ +import { Fragment } from "react" + +/* + * The four-stage workflow map that frames the code showcase below it: the same + * Write / Extract / Translate labels as the tabs, plus the artifact each stage + * produces, connected left-to-right. Stays inside the Swiss spec grid — hairline + * cells, mono labels, one accent on the terminal stage. Arrows turn downward + * when the row collapses to a single column. + */ + +interface FlowStage { + label: string + artifact: string + note: string + accent?: boolean +} + +const STAGES: FlowStage[] = [ + { label: "Write", artifact: "src/*.tsx", note: "t`…` macro in the component" }, + { label: "Extract", artifact: "pmds", note: "one native command", accent: true }, + { label: "Translate", artifact: ".po catalog", note: "source-string-first" }, + { label: "Render", artifact: "every framework", note: "same runtime model" }, +] + +export function WorkflowFlow() { + return ( +
+ {STAGES.map((stage, index) => ( + + {index > 0 ? ( + + + + + ) : null} +
+

+ {String(index + 1).padStart(2, "0")} +

+

+ {stage.label} +

+

{stage.artifact}

+

{stage.note}

+
+
+ ))} +
+ ) +} diff --git a/site/app/components/proof/BenchmarkChart.tsx b/site/app/components/proof/BenchmarkChart.tsx index 927ce88d..21271289 100644 --- a/site/app/components/proof/BenchmarkChart.tsx +++ b/site/app/components/proof/BenchmarkChart.tsx @@ -7,21 +7,33 @@ import { usePrefersReducedMotion } from "~/hooks/usePrefersReducedMotion" const TICK_STEP_MS = 100 +/* + * Round a data maximum up to a clean axis maximum (a whole multiple of the + * tick step) that always leaves visible headroom, so the longest bar never + * ends hard on the frame. + */ +function niceAxisMaxMs(dataMax: number, stepMs = TICK_STEP_MS): number { + const rounded = Math.ceil(dataMax / stepMs) * stepMs + return rounded - dataMax < stepMs / 2 ? rounded + stepMs : rounded +} + /* * Custom spec-sheet bar chart: linear ms scale (no silent truncation), - * hairline ticks every 100ms, accent bar for Palamedes with the checked - * speedup ratios annotated at the bar end. Bars grow on first view; - * prerender/no-JS/reduced-motion shows full-width bars. + * hairline ticks every 100ms, one accent bar for Palamedes. The axis maximum + * rounds up to a clean number with headroom (see niceAxisMaxMs). Bars grow on + * first view; prerender/no-JS/reduced-motion shows full-width bars. */ export function BenchmarkChart({ corpus }: { corpus: BenchCorpus }) { const ref = useRef(null) const inView = useInView(ref) const reducedMotion = usePrefersReducedMotion() const animate = !reducedMotion && inView - const maxMs = Math.max(...corpus.rows.map((row) => row.medianMs)) - const ticks = Array.from({ length: Math.floor(maxMs / TICK_STEP_MS) }, (_, i) => - Math.round(((i + 1) * TICK_STEP_MS * 100) / maxMs) - ) + const dataMax = Math.max(...corpus.rows.map((row) => row.medianMs)) + const maxMs = niceAxisMaxMs(dataMax) + const tickValues: number[] = [] + for (let value = TICK_STEP_MS; value < maxMs; value += TICK_STEP_MS) { + tickValues.push(value) + } return (
@@ -34,16 +46,16 @@ export function BenchmarkChart({ corpus }: { corpus: BenchCorpus }) { return (
{row.tool}
- {ticks.map((left) => ( + {tickValues.map((value) => ( ))}
- {row.accent ? ( - - {corpus.ratios.lingui} vs Lingui · {corpus.ratios.i18next} vs i18next - - ) : null}
- {row.medianMs.toFixed(2)} ms + {Math.round(row.medianMs)} ms
) })} -
- - 0{ticks.map((_, i) => ` · ${(i + 1) * TICK_STEP_MS}`)} ms — linear scale +
+
+ 0 + {tickValues.map((value) => ( + + {value} + + ))} + {maxMs} +
+ ms · linear

diff --git a/site/app/data/bench.ts b/site/app/data/bench.ts index 078b17d5..7b7d9eab 100644 --- a/site/app/data/bench.ts +++ b/site/app/data/bench.ts @@ -15,7 +15,7 @@ export interface BenchRow { } export interface BenchCorpus { - id: "small" | "medium" + id: "small" | "medium" | "realistic" title: string corpus: string rows: BenchRow[] @@ -35,11 +35,11 @@ export const BENCH_SMALL: BenchCorpus = { title: "Small corpus — 80 files, 640 messages (median of 7 runs)", corpus: "80 files, 640 messages", rows: [ - { tool: "Palamedes", medianMs: 33.58, accent: true }, - { tool: "i18next-parser", medianMs: 526.45 }, - { tool: "Lingui", medianMs: 705.12 }, + { tool: "Palamedes", medianMs: 33.41, accent: true }, + { tool: "i18next-parser", medianMs: 531.39 }, + { tool: "Lingui", medianMs: 738.18 }, ], - ratios: { lingui: "21.00×", i18next: "15.68×" }, + ratios: { lingui: "22.10×", i18next: "15.91×" }, } export const BENCH_MEDIUM: BenchCorpus = { @@ -47,9 +47,21 @@ export const BENCH_MEDIUM: BenchCorpus = { title: "Medium corpus — 240 files, 1920 messages (median of 7 runs)", corpus: "240 files, 1920 messages", rows: [ - { tool: "Palamedes", medianMs: 47.77, accent: true }, - { tool: "i18next-parser", medianMs: 587.52 }, - { tool: "Lingui", medianMs: 812.83 }, + { tool: "Palamedes", medianMs: 46.27, accent: true }, + { tool: "i18next-parser", medianMs: 583 }, + { tool: "Lingui", medianMs: 800.75 }, ], - ratios: { lingui: "17.02×", i18next: "12.30×" }, + ratios: { lingui: "17.31×", i18next: "12.60×" }, +} + +export const BENCH_REALISTIC: BenchCorpus = { + id: "realistic", + title: "Realistic corpus — 400 files, 10,000 messages (median of 7 runs)", + corpus: "400 files, 10,000 messages", + rows: [ + { tool: "Palamedes", medianMs: 83.75, accent: true }, + { tool: "i18next-parser", medianMs: 787.15 }, + { tool: "Lingui", medianMs: 1060.24 }, + ], + ratios: { lingui: "12.66×", i18next: "9.40×" }, } diff --git a/site/app/data/compare.ts b/site/app/data/compare.ts index ed204466..4dadafc1 100644 --- a/site/app/data/compare.ts +++ b/site/app/data/compare.ts @@ -25,7 +25,7 @@ export const COMPARE_TOOLS: CompareTool[] = [ "message + context, stable across refactors", "One model: getI18n() everywhere", "Native (Rust/ferrocat), semantic merge & audits", - "33.58 ms (checked report¹)", + "83.75 ms (checked report¹)", "5 families browser-verified in CI", "New — honest about it; 16 ADRs document the tradeoffs", ], @@ -37,7 +37,7 @@ export const COMPARE_TOOLS: CompareTool[] = [ "Configurable ID strategies", "Multiple entry points (i18n, hooks, macros)", "JS-based tooling with plugin ecosystem", - "705.12 ms (same harness¹)", + "1060.24 ms (same harness¹)", "Broad, community-verified", "Mature, large community, years of production use", ], @@ -45,5 +45,5 @@ export const COMPARE_TOOLS: CompareTool[] = [ ] export const COMPARE_FOOTNOTES = [ - "¹ Median of 7 runs, same corpus and semantic validation — methodology and raw reports in the repo.", + "¹ Median of 7 runs on the realistic corpus (400 files, 10,000 messages), same semantic validation — methodology and raw reports in the repo.", ] diff --git a/site/app/data/matrix.ts b/site/app/data/matrix.ts index 0d689e60..57e5fc1c 100644 --- a/site/app/data/matrix.ts +++ b/site/app/data/matrix.ts @@ -4,9 +4,9 @@ import { repoHref } from "./links" * Typed port of FRAMEWORK_MATRIX_CELLS from docs/site/structure/components.jsx. * Cells carry EXPLICIT links and a per-cell hosting status — never a generated * URL pattern. URL shapes mirror examples/README.md: cookie (one host), route - * (locale path), subdomain (locale host label, pending DNS), tld - * (palamedes-i18n.{com,de,es,fr}, pending domains). Subdomain/tld hosting - * reconciliation is tracked in issue #306. + * (locale path), subdomain (locale host label, live under the per-example + * wildcards), tld (palamedes-i18n.{com,de,es,fr} — domains still pending, so + * these stay provisioning). Remaining tld hosting is tracked in issue #306. */ export type MatrixStatus = "live" | "provisioning" @@ -69,7 +69,11 @@ export const MATRIX_CELLS: MatrixCell[] = FRAMEWORKS.flatMap(({ slug: framework framework, strategy: "subdomain", verified: true as const, - status: "provisioning" as const, + status: "live" as const, + demoLinks: ["en", "de", "es"].map((locale) => ({ + label: locale, + href: `https://${locale}.${framework}-subdomain.examples.palamedes.dev`, + })), sourceHref: repoHref(`examples/${framework}-subdomain`, "tree"), }, { diff --git a/site/app/routes/home.tsx b/site/app/routes/home.tsx index 5f3332a2..eea50220 100644 --- a/site/app/routes/home.tsx +++ b/site/app/routes/home.tsx @@ -12,8 +12,9 @@ import { ProofStrip } from "~/components/home/ProofStrip" import { QuickInstall } from "~/components/home/QuickInstall" import { StatementBand } from "~/components/home/StatementBand" import { TerminalCascade } from "~/components/home/TerminalCascade" +import { WorkflowFlow } from "~/components/home/WorkflowFlow" import { BenchmarkChart } from "~/components/proof/BenchmarkChart" -import { BENCH_SMALL } from "~/data/bench" +import { BENCH_REALISTIC } from "~/data/bench" import { HOME_MODEL_CARDS } from "~/data/features" import { decisionHref, DEMO_NEXTJS_COOKIE, REPO } from "~/data/links" @@ -34,6 +35,13 @@ export default function Home() { {/* ------------------------------------------------------------ hero */}

+ Palamedes

Open-source i18n tooling for JavaScript & TypeScript

One translation model. @@ -42,8 +50,8 @@ export default function Home() {

Write messages where your UI happens. Keep source-string-first .po catalogs your translators can actually read. Ship the same runtime model across Next.js, TanStack - Start, SolidStart, Waku, and React Router — with a Rust core that ran the checked - small-corpus extract/update benchmark 21.0× faster than Lingui on the recorded + Start, SolidStart, Waku, and React Router — with a Rust core that ran a checked + 10,000-message extract/update benchmark 12.66× faster than Lingui on the recorded machine-local run.

@@ -72,7 +80,10 @@ export default function Home() { {/* -------------------------------------------------- 02 — workflow */}
- +
+ + +
{/* ----------------------------------------------------- 03 — proof */} @@ -83,7 +94,7 @@ export default function Home() { >
- + All benchmarks & the verification story @@ -91,10 +102,10 @@ export default function Home() {

{/* ------------------------------------------------ 04 — positioning */} - - Palamedes is narrower than some alternatives on purpose: compile-time authoring, - source-string-first catalogs, and a local workflow your repo owns — with a Rust core doing - the careful work. + + Palamedes deliberately owns less. It keeps the part of i18n you touch every day — your + messages, your catalog, the runtime lookup — and hands everything framework-specific back to + your framework. {/* ------------------------------------------------ 05 — maintainer */} diff --git a/site/app/routes/proof.tsx b/site/app/routes/proof.tsx index 89566777..c3dfd76d 100644 --- a/site/app/routes/proof.tsx +++ b/site/app/routes/proof.tsx @@ -6,7 +6,7 @@ import { CtaBand } from "~/components/home/CtaBand" import { FeatureGrid } from "~/components/home/FeatureGrid" import { BenchmarkChart } from "~/components/proof/BenchmarkChart" import { ScreenshotStrip } from "~/components/proof/ScreenshotStrip" -import { BENCH_MEDIUM, BENCH_SMALL } from "~/data/bench" +import { BENCH_REALISTIC } from "~/data/bench" import { CATALOG_QA_CARDS } from "~/data/features" import { decisionHref, docsHref, repoHref } from "~/data/links" @@ -69,8 +69,7 @@ export default function Proof() { lede="The end-to-end benchmark measures what a developer actually waits for: scan sources, extract messages, update catalogs, write files. Same generated message inventory, rendered into each tool's idiomatic source shape, semantically validated after every run." >
- - +

Honest note

From a47adcac17e8a47a0617758d6336695a9a7e97b0 Mon Sep 17 00:00:00 2001 From: Sebastian Werner Date: Mon, 6 Jul 2026 22:34:17 +0200 Subject: [PATCH 2/2] feat(benchmarks): model realistic extract-time parse volume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The e2e-workflow "realistic" corpus was an all-i18n fixture: every file was dense with messages. Real apps aren't like that — the extractor spends most of its time scanning ordinary, non-i18n source. Rebuild the corpus to match a production web app measured from its Lingui include roots, with figures rounded so they read as a shape, not false precision: - 1,500 files, of which 750 carry i18n markers and 750 have none but still get scanned. - ~400,000 source lines, ~3% of them i18n. Files are padded with deterministic non-i18n filler to the line budgets. - 6,000 messages (plain + ~15% {name} variables) spread thinly across the marked files. This makes the timed workload reflect real parse volume rather than a best-case dense fixture. Fresh numbers on that corpus: Palamedes 174 ms vs Lingui 2254 ms (12.99x) and i18next-parser 1562 ms (9.00x). Also: - Read multiline PO msgids correctly (new src/po.mjs, from PR #358). The old single-line reader treated wrapped msgids — which the extractor emits for longer messages — as empty, which is what made variables/plurals appear to "drop". The native extractor was correct; the benchmark reader was not. - Home/proof/compare/prose and the methodology docs quote the new numbers; drift guard still green. - BenchmarkChart: adaptive tick step so a larger range stays at ~8-10 gridlines instead of 20+. Co-Authored-By: Claude Opus 4.8 --- benchmarks/e2e-workflow/results/latest.json | 526 +++++++++---------- benchmarks/e2e-workflow/results/latest.md | 36 +- benchmarks/e2e-workflow/src/corpus.mjs | 198 +++++-- docs/benchmark-e2e-workflow.md | 37 +- docs/proof-and-benchmarks.md | 16 +- site/app/components/home/ProofStrip.tsx | 4 +- site/app/components/proof/BenchmarkChart.tsx | 21 +- site/app/data/bench.ts | 40 +- site/app/data/compare.ts | 6 +- site/app/routes/home.tsx | 4 +- 10 files changed, 523 insertions(+), 365 deletions(-) diff --git a/benchmarks/e2e-workflow/results/latest.json b/benchmarks/e2e-workflow/results/latest.json index f6a40213..6e46433c 100644 --- a/benchmarks/e2e-workflow/results/latest.json +++ b/benchmarks/e2e-workflow/results/latest.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "benchmark": "palamedes-e2e-extract-update-workflow", - "generatedAt": "2026-07-06T18:05:29.525Z", + "generatedAt": "2026-07-06T21:31:33.279Z", "machineLocal": true, "seed": 20260703, "warmup": 3, @@ -11,7 +11,7 @@ "nodeVersion": "v24.18.0", "platform": "darwin", "arch": "arm64", - "generatedAt": "2026-07-06T18:05:29.525Z" + "generatedAt": "2026-07-06T21:31:33.279Z" }, "versions": { "benchmarkPackage": "@palamedes/benchmark-e2e-workflow", @@ -42,7 +42,7 @@ "changedCount": 48, "newCount": 64, "removedCount": 48, - "sourceBytes": 22960 + "sourceBytes": 23590 }, "validation": { "expectedActiveMessages": 640, @@ -78,26 +78,26 @@ "changedCount": 48, "newCount": 64, "removedCount": 48, - "sourceBytes": 22960, + "sourceBytes": 23590, "warmup": 3, "runs": 7, - "medianMs": 33.406833, + "medianMs": 31.6355, "rawSamplesMs": [ - 32.609792, - 32.987958, - 33.38825, - 33.406833, - 34.685042, - 34.806417, - 39.011041 + 30.960208, + 31.255167, + 31.579583, + 31.6355, + 32.427209, + 32.542334, + 32.64075 ], "stdoutBytes": 178, "stderrBytes": 0, "palamedesTiming": { - "totalMs": 27, + "totalMs": 25, "globMs": 0, "extractMs": 3, - "writeMs": 23, + "writeMs": 21, "totalMessages": 640, "totalFiles": 80 } @@ -112,18 +112,18 @@ "changedCount": 48, "newCount": 64, "removedCount": 48, - "sourceBytes": 22960, + "sourceBytes": 23590, "warmup": 3, "runs": 7, - "medianMs": 738.179333, + "medianMs": 674.050833, "rawSamplesMs": [ - 725.374458, - 727.513333, - 734.014084, - 738.179333, - 743.532083, - 748.106459, - 774.963583 + 669.800167, + 670.56575, + 670.887625, + 674.050833, + 677.150959, + 687.43675, + 696.003833 ], "stdoutBytes": 755, "stderrBytes": 18 @@ -138,18 +138,18 @@ "changedCount": 48, "newCount": 64, "removedCount": 48, - "sourceBytes": 22960, + "sourceBytes": 23590, "warmup": 3, "runs": 7, - "medianMs": 531.385875, + "medianMs": 499.179375, "rawSamplesMs": [ - 509.646458, - 512.649541, - 524.157792, - 531.385875, - 533.002542, - 533.166208, - 534.876959 + 483.354208, + 486.618667, + 491.234375, + 499.179375, + 501.802834, + 504.692917, + 511.235667 ], "stdoutBytes": 11883, "stderrBytes": 0 @@ -166,7 +166,7 @@ "changedCount": 144, "newCount": 192, "removedCount": 144, - "sourceBytes": 68881 + "sourceBytes": 70681 }, "validation": { "expectedActiveMessages": 1920, @@ -202,26 +202,26 @@ "changedCount": 144, "newCount": 192, "removedCount": 144, - "sourceBytes": 68881, + "sourceBytes": 70681, "warmup": 3, "runs": 7, - "medianMs": 46.266458, + "medianMs": 43.36875, "rawSamplesMs": [ - 42.961583, - 45.010791, - 45.765375, - 46.266458, - 46.463, - 46.749792, - 48.281084 + 41.623459, + 42.891958, + 43.158625, + 43.36875, + 43.823, + 43.857042, + 45.825292 ], - "stdoutBytes": 183, + "stdoutBytes": 182, "stderrBytes": 0, "palamedesTiming": { - "totalMs": 40, + "totalMs": 37, "globMs": 1, "extractMs": 9, - "writeMs": 28, + "writeMs": 26, "totalMessages": 1920, "totalFiles": 240 } @@ -236,18 +236,18 @@ "changedCount": 144, "newCount": 192, "removedCount": 144, - "sourceBytes": 68881, + "sourceBytes": 70681, "warmup": 3, "runs": 7, - "medianMs": 800.749625, + "medianMs": 745.330417, "rawSamplesMs": [ - 786.811375, - 791.563834, - 793.403833, - 800.749625, - 804.889333, - 814.356584, - 847.850666 + 737.340167, + 739.180125, + 743.746333, + 745.330417, + 749.243625, + 758.244166, + 761.585625 ], "stdoutBytes": 755, "stderrBytes": 18 @@ -262,18 +262,18 @@ "changedCount": 144, "newCount": 192, "removedCount": 144, - "sourceBytes": 68881, + "sourceBytes": 70681, "warmup": 3, "runs": 7, - "medianMs": 582.999875, + "medianMs": 546.322875, "rawSamplesMs": [ - 567.697625, - 568.527167, - 576.003959, - 582.999875, - 583.251416, - 585.267625, - 617.984541 + 542.484833, + 545.487125, + 545.761208, + 546.322875, + 547.371542, + 549.617125, + 556.995625 ], "stdoutBytes": 35059, "stderrBytes": 0 @@ -283,34 +283,34 @@ { "profile": "realistic", "corpus": { - "fileCount": 400, - "messagesPerFile": 25, - "sourceMessageCount": 10000, - "baselineMessageCount": 9750, - "changedCount": 750, - "newCount": 1000, - "removedCount": 750, - "sourceBytes": 358740 + "fileCount": 1500, + "messagesPerFile": 4, + "sourceMessageCount": 6000, + "baselineMessageCount": 5850, + "changedCount": 450, + "newCount": 600, + "removedCount": 450, + "sourceBytes": 220653 }, "validation": { - "expectedActiveMessages": 10000, + "expectedActiveMessages": 6000, "tools": { "palamedes": { "activeMessagesByLocale": { - "en": 10000, - "de": 10000 + "en": 6000, + "de": 6000 } }, "lingui": { "activeMessagesByLocale": { - "en": 10000, - "de": 10000 + "en": 6000, + "de": 6000 } }, "i18next": { "activeMessagesByLocale": { - "en": 10000, - "de": 10000 + "en": 6000, + "de": 6000 } } } @@ -320,86 +320,86 @@ "tool": "palamedes", "version": "pmds (Palamedes) v1.3.0", "profile": "realistic", - "fileCount": 400, - "sourceMessageCount": 10000, - "baselineMessageCount": 9750, - "changedCount": 750, - "newCount": 1000, - "removedCount": 750, - "sourceBytes": 358740, + "fileCount": 1500, + "sourceMessageCount": 6000, + "baselineMessageCount": 5850, + "changedCount": 450, + "newCount": 600, + "removedCount": 450, + "sourceBytes": 220653, "warmup": 3, "runs": 7, - "medianMs": 83.753833, + "medianMs": 173.498334, "rawSamplesMs": [ - 80.418125, - 82.786833, - 83.686625, - 83.753833, - 84.204, - 84.99, - 90.306208 + 171.482291, + 173.36975, + 173.400833, + 173.498334, + 174.378333, + 175.140125, + 175.794125 ], - "stdoutBytes": 185, + "stdoutBytes": 188, "stderrBytes": 0, "palamedesTiming": { - "totalMs": 77, - "globMs": 2, - "extractMs": 24, - "writeMs": 48, - "totalMessages": 10000, - "totalFiles": 400 + "totalMs": 167, + "globMs": 7, + "extractMs": 122, + "writeMs": 37, + "totalMessages": 6000, + "totalFiles": 1500 } }, { "tool": "lingui", "version": "6.4.0", "profile": "realistic", - "fileCount": 400, - "sourceMessageCount": 10000, - "baselineMessageCount": 9750, - "changedCount": 750, - "newCount": 1000, - "removedCount": 750, - "sourceBytes": 358740, + "fileCount": 1500, + "sourceMessageCount": 6000, + "baselineMessageCount": 5850, + "changedCount": 450, + "newCount": 600, + "removedCount": 450, + "sourceBytes": 220653, "warmup": 3, "runs": 7, - "medianMs": 1060.236458, + "medianMs": 2254.377375, "rawSamplesMs": [ - 1040.99375, - 1051.32075, - 1058.282625, - 1060.236458, - 1071.148167, - 1096.196791, - 1109.829625 + 2151.096042, + 2189.767709, + 2212.124041, + 2254.377375, + 2293.858708, + 2317.975958, + 2364.6685 ], "stdoutBytes": 755, - "stderrBytes": 18 + "stderrBytes": 15 }, { "tool": "i18next", "version": "9.4.0", "profile": "realistic", - "fileCount": 400, - "sourceMessageCount": 10000, - "baselineMessageCount": 9750, - "changedCount": 750, - "newCount": 1000, - "removedCount": 750, - "sourceBytes": 358740, + "fileCount": 1500, + "sourceMessageCount": 6000, + "baselineMessageCount": 5850, + "changedCount": 450, + "newCount": 600, + "removedCount": 450, + "sourceBytes": 220653, "warmup": 3, "runs": 7, - "medianMs": 787.146625, + "medianMs": 1561.820584, "rawSamplesMs": [ - 779.014458, - 783.769625, - 786.497375, - 787.146625, - 792.85375, - 801.310625, - 812.540208 + 1504.384667, + 1508.710833, + 1551.711459, + 1561.820584, + 1565.774833, + 1589.356708, + 1591.7075 ], - "stdoutBytes": 59359, + "stdoutBytes": 221176, "stderrBytes": 0 } ] @@ -416,26 +416,26 @@ "changedCount": 48, "newCount": 64, "removedCount": 48, - "sourceBytes": 22960, + "sourceBytes": 23590, "warmup": 3, "runs": 7, - "medianMs": 33.406833, + "medianMs": 31.6355, "rawSamplesMs": [ - 32.609792, - 32.987958, - 33.38825, - 33.406833, - 34.685042, - 34.806417, - 39.011041 + 30.960208, + 31.255167, + 31.579583, + 31.6355, + 32.427209, + 32.542334, + 32.64075 ], "stdoutBytes": 178, "stderrBytes": 0, "palamedesTiming": { - "totalMs": 27, + "totalMs": 25, "globMs": 0, "extractMs": 3, - "writeMs": 23, + "writeMs": 21, "totalMessages": 640, "totalFiles": 80 } @@ -450,18 +450,18 @@ "changedCount": 48, "newCount": 64, "removedCount": 48, - "sourceBytes": 22960, + "sourceBytes": 23590, "warmup": 3, "runs": 7, - "medianMs": 738.179333, + "medianMs": 674.050833, "rawSamplesMs": [ - 725.374458, - 727.513333, - 734.014084, - 738.179333, - 743.532083, - 748.106459, - 774.963583 + 669.800167, + 670.56575, + 670.887625, + 674.050833, + 677.150959, + 687.43675, + 696.003833 ], "stdoutBytes": 755, "stderrBytes": 18 @@ -476,18 +476,18 @@ "changedCount": 48, "newCount": 64, "removedCount": 48, - "sourceBytes": 22960, + "sourceBytes": 23590, "warmup": 3, "runs": 7, - "medianMs": 531.385875, + "medianMs": 499.179375, "rawSamplesMs": [ - 509.646458, - 512.649541, - 524.157792, - 531.385875, - 533.002542, - 533.166208, - 534.876959 + 483.354208, + 486.618667, + 491.234375, + 499.179375, + 501.802834, + 504.692917, + 511.235667 ], "stdoutBytes": 11883, "stderrBytes": 0 @@ -502,26 +502,26 @@ "changedCount": 144, "newCount": 192, "removedCount": 144, - "sourceBytes": 68881, + "sourceBytes": 70681, "warmup": 3, "runs": 7, - "medianMs": 46.266458, + "medianMs": 43.36875, "rawSamplesMs": [ - 42.961583, - 45.010791, - 45.765375, - 46.266458, - 46.463, - 46.749792, - 48.281084 + 41.623459, + 42.891958, + 43.158625, + 43.36875, + 43.823, + 43.857042, + 45.825292 ], - "stdoutBytes": 183, + "stdoutBytes": 182, "stderrBytes": 0, "palamedesTiming": { - "totalMs": 40, + "totalMs": 37, "globMs": 1, "extractMs": 9, - "writeMs": 28, + "writeMs": 26, "totalMessages": 1920, "totalFiles": 240 } @@ -536,18 +536,18 @@ "changedCount": 144, "newCount": 192, "removedCount": 144, - "sourceBytes": 68881, + "sourceBytes": 70681, "warmup": 3, "runs": 7, - "medianMs": 800.749625, + "medianMs": 745.330417, "rawSamplesMs": [ - 786.811375, - 791.563834, - 793.403833, - 800.749625, - 804.889333, - 814.356584, - 847.850666 + 737.340167, + 739.180125, + 743.746333, + 745.330417, + 749.243625, + 758.244166, + 761.585625 ], "stdoutBytes": 755, "stderrBytes": 18 @@ -562,18 +562,18 @@ "changedCount": 144, "newCount": 192, "removedCount": 144, - "sourceBytes": 68881, + "sourceBytes": 70681, "warmup": 3, "runs": 7, - "medianMs": 582.999875, + "medianMs": 546.322875, "rawSamplesMs": [ - 567.697625, - 568.527167, - 576.003959, - 582.999875, - 583.251416, - 585.267625, - 617.984541 + 542.484833, + 545.487125, + 545.761208, + 546.322875, + 547.371542, + 549.617125, + 556.995625 ], "stdoutBytes": 35059, "stderrBytes": 0 @@ -582,86 +582,86 @@ "tool": "palamedes", "version": "pmds (Palamedes) v1.3.0", "profile": "realistic", - "fileCount": 400, - "sourceMessageCount": 10000, - "baselineMessageCount": 9750, - "changedCount": 750, - "newCount": 1000, - "removedCount": 750, - "sourceBytes": 358740, + "fileCount": 1500, + "sourceMessageCount": 6000, + "baselineMessageCount": 5850, + "changedCount": 450, + "newCount": 600, + "removedCount": 450, + "sourceBytes": 220653, "warmup": 3, "runs": 7, - "medianMs": 83.753833, + "medianMs": 173.498334, "rawSamplesMs": [ - 80.418125, - 82.786833, - 83.686625, - 83.753833, - 84.204, - 84.99, - 90.306208 + 171.482291, + 173.36975, + 173.400833, + 173.498334, + 174.378333, + 175.140125, + 175.794125 ], - "stdoutBytes": 185, + "stdoutBytes": 188, "stderrBytes": 0, "palamedesTiming": { - "totalMs": 77, - "globMs": 2, - "extractMs": 24, - "writeMs": 48, - "totalMessages": 10000, - "totalFiles": 400 + "totalMs": 167, + "globMs": 7, + "extractMs": 122, + "writeMs": 37, + "totalMessages": 6000, + "totalFiles": 1500 } }, { "tool": "lingui", "version": "6.4.0", "profile": "realistic", - "fileCount": 400, - "sourceMessageCount": 10000, - "baselineMessageCount": 9750, - "changedCount": 750, - "newCount": 1000, - "removedCount": 750, - "sourceBytes": 358740, + "fileCount": 1500, + "sourceMessageCount": 6000, + "baselineMessageCount": 5850, + "changedCount": 450, + "newCount": 600, + "removedCount": 450, + "sourceBytes": 220653, "warmup": 3, "runs": 7, - "medianMs": 1060.236458, + "medianMs": 2254.377375, "rawSamplesMs": [ - 1040.99375, - 1051.32075, - 1058.282625, - 1060.236458, - 1071.148167, - 1096.196791, - 1109.829625 + 2151.096042, + 2189.767709, + 2212.124041, + 2254.377375, + 2293.858708, + 2317.975958, + 2364.6685 ], "stdoutBytes": 755, - "stderrBytes": 18 + "stderrBytes": 15 }, { "tool": "i18next", "version": "9.4.0", "profile": "realistic", - "fileCount": 400, - "sourceMessageCount": 10000, - "baselineMessageCount": 9750, - "changedCount": 750, - "newCount": 1000, - "removedCount": 750, - "sourceBytes": 358740, + "fileCount": 1500, + "sourceMessageCount": 6000, + "baselineMessageCount": 5850, + "changedCount": 450, + "newCount": 600, + "removedCount": 450, + "sourceBytes": 220653, "warmup": 3, "runs": 7, - "medianMs": 787.146625, + "medianMs": 1561.820584, "rawSamplesMs": [ - 779.014458, - 783.769625, - 786.497375, - 787.146625, - 792.85375, - 801.310625, - 812.540208 + 1504.384667, + 1508.710833, + 1551.711459, + 1561.820584, + 1565.774833, + 1589.356708, + 1591.7075 ], - "stdoutBytes": 59359, + "stdoutBytes": 221176, "stderrBytes": 0 } ], @@ -671,54 +671,54 @@ "baselineTool": "palamedes", "comparedTool": "lingui", "fasterTool": "palamedes", - "palamedesMedianMs": 33.406833, - "comparedMedianMs": 738.179333, - "speedupFactor": 22.096657082100542 + "palamedesMedianMs": 31.6355, + "comparedMedianMs": 674.050833, + "speedupFactor": 21.306786142150433 }, { "profile": "small", "baselineTool": "palamedes", "comparedTool": "i18next", "fasterTool": "palamedes", - "palamedesMedianMs": 33.406833, - "comparedMedianMs": 531.385875, - "speedupFactor": 15.906502570896201 + "palamedesMedianMs": 31.6355, + "comparedMedianMs": 499.179375, + "speedupFactor": 15.779089156169492 }, { "profile": "medium", "baselineTool": "palamedes", "comparedTool": "lingui", "fasterTool": "palamedes", - "palamedesMedianMs": 46.266458, - "comparedMedianMs": 800.749625, - "speedupFactor": 17.30734660950272 + "palamedesMedianMs": 43.36875, + "comparedMedianMs": 745.330417, + "speedupFactor": 17.185886542729502 }, { "profile": "medium", "baselineTool": "palamedes", "comparedTool": "i18next", "fasterTool": "palamedes", - "palamedesMedianMs": 46.266458, - "comparedMedianMs": 582.999875, - "speedupFactor": 12.60091868281769 + "palamedesMedianMs": 43.36875, + "comparedMedianMs": 546.322875, + "speedupFactor": 12.597155209684392 }, { "profile": "realistic", "baselineTool": "palamedes", "comparedTool": "lingui", "fasterTool": "palamedes", - "palamedesMedianMs": 83.753833, - "comparedMedianMs": 1060.236458, - "speedupFactor": 12.658960432294485 + "palamedesMedianMs": 173.498334, + "comparedMedianMs": 2254.377375, + "speedupFactor": 12.993654307942807 }, { "profile": "realistic", "baselineTool": "palamedes", "comparedTool": "i18next", "fasterTool": "palamedes", - "palamedesMedianMs": 83.753833, - "comparedMedianMs": 787.146625, - "speedupFactor": 9.398335536476283 + "palamedesMedianMs": 173.498334, + "comparedMedianMs": 1561.820584, + "speedupFactor": 9.001934185719616 } ] } \ No newline at end of file diff --git a/benchmarks/e2e-workflow/results/latest.md b/benchmarks/e2e-workflow/results/latest.md index 88d28d34..65c83da8 100644 --- a/benchmarks/e2e-workflow/results/latest.md +++ b/benchmarks/e2e-workflow/results/latest.md @@ -1,6 +1,6 @@ # End-to-End Extract and Catalog Update Benchmark -Generated: 2026-07-06T18:05:29.525Z +Generated: 2026-07-06T21:31:33.279Z Node: v24.18.0 Platform: darwin/arm64 Seed: 20260703 @@ -29,14 +29,14 @@ Machine-local: yes | Tool | Median | Samples | | --- | ---: | --- | -| Palamedes | 33.41 ms | 32.61 ms, 32.99 ms, 33.39 ms, 33.41 ms, 34.69 ms, 34.81 ms, 39.01 ms | -| Lingui | 738.18 ms | 725.37 ms, 727.51 ms, 734.01 ms, 738.18 ms, 743.53 ms, 748.11 ms, 774.96 ms | -| i18next-parser | 531.39 ms | 509.65 ms, 512.65 ms, 524.16 ms, 531.39 ms, 533.00 ms, 533.17 ms, 534.88 ms | +| Palamedes | 31.64 ms | 30.96 ms, 31.26 ms, 31.58 ms, 31.64 ms, 32.43 ms, 32.54 ms, 32.64 ms | +| Lingui | 674.05 ms | 669.80 ms, 670.57 ms, 670.89 ms, 674.05 ms, 677.15 ms, 687.44 ms, 696.00 ms | +| i18next-parser | 499.18 ms | 483.35 ms, 486.62 ms, 491.23 ms, 499.18 ms, 501.80 ms, 504.69 ms, 511.24 ms | | Comparison | Faster | Speedup | | --- | --- | ---: | -| Palamedes vs Lingui | Palamedes | 22.10x | -| Palamedes vs i18next-parser | Palamedes | 15.91x | +| Palamedes vs Lingui | Palamedes | 21.31x | +| Palamedes vs i18next-parser | Palamedes | 15.78x | ## Medium @@ -46,31 +46,31 @@ Machine-local: yes | Tool | Median | Samples | | --- | ---: | --- | -| Palamedes | 46.27 ms | 42.96 ms, 45.01 ms, 45.77 ms, 46.27 ms, 46.46 ms, 46.75 ms, 48.28 ms | -| Lingui | 800.75 ms | 786.81 ms, 791.56 ms, 793.40 ms, 800.75 ms, 804.89 ms, 814.36 ms, 847.85 ms | -| i18next-parser | 583.00 ms | 567.70 ms, 568.53 ms, 576.00 ms, 583.00 ms, 583.25 ms, 585.27 ms, 617.98 ms | +| Palamedes | 43.37 ms | 41.62 ms, 42.89 ms, 43.16 ms, 43.37 ms, 43.82 ms, 43.86 ms, 45.83 ms | +| Lingui | 745.33 ms | 737.34 ms, 739.18 ms, 743.75 ms, 745.33 ms, 749.24 ms, 758.24 ms, 761.59 ms | +| i18next-parser | 546.32 ms | 542.48 ms, 545.49 ms, 545.76 ms, 546.32 ms, 547.37 ms, 549.62 ms, 557.00 ms | | Comparison | Faster | Speedup | | --- | --- | ---: | -| Palamedes vs Lingui | Palamedes | 17.31x | +| Palamedes vs Lingui | Palamedes | 17.19x | | Palamedes vs i18next-parser | Palamedes | 12.60x | ## Realistic -- Corpus: 400 files, 10000 current messages, 9750 baseline messages -- Inventory mix: 750 changed, 1000 new, 750 removed -- Semantic validation: 10000 active messages per locale and tool +- Corpus: 1500 files, 6000 current messages, 5850 baseline messages +- Inventory mix: 450 changed, 600 new, 450 removed +- Semantic validation: 6000 active messages per locale and tool | Tool | Median | Samples | | --- | ---: | --- | -| Palamedes | 83.75 ms | 80.42 ms, 82.79 ms, 83.69 ms, 83.75 ms, 84.20 ms, 84.99 ms, 90.31 ms | -| Lingui | 1060.24 ms | 1040.99 ms, 1051.32 ms, 1058.28 ms, 1060.24 ms, 1071.15 ms, 1096.20 ms, 1109.83 ms | -| i18next-parser | 787.15 ms | 779.01 ms, 783.77 ms, 786.50 ms, 787.15 ms, 792.85 ms, 801.31 ms, 812.54 ms | +| Palamedes | 173.50 ms | 171.48 ms, 173.37 ms, 173.40 ms, 173.50 ms, 174.38 ms, 175.14 ms, 175.79 ms | +| Lingui | 2254.38 ms | 2151.10 ms, 2189.77 ms, 2212.12 ms, 2254.38 ms, 2293.86 ms, 2317.98 ms, 2364.67 ms | +| i18next-parser | 1561.82 ms | 1504.38 ms, 1508.71 ms, 1551.71 ms, 1561.82 ms, 1565.77 ms, 1589.36 ms, 1591.71 ms | | Comparison | Faster | Speedup | | --- | --- | ---: | -| Palamedes vs Lingui | Palamedes | 12.66x | -| Palamedes vs i18next-parser | Palamedes | 9.40x | +| Palamedes vs Lingui | Palamedes | 12.99x | +| Palamedes vs i18next-parser | Palamedes | 9.00x | ## Notes diff --git a/benchmarks/e2e-workflow/src/corpus.mjs b/benchmarks/e2e-workflow/src/corpus.mjs index b7a0dd76..68a2a8de 100644 --- a/benchmarks/e2e-workflow/src/corpus.mjs +++ b/benchmarks/e2e-workflow/src/corpus.mjs @@ -18,12 +18,23 @@ export const PROFILE_DEFINITIONS = { newCount: 192, removedCount: 144, }, + /* + * Models a real web app's extract-time parse volume (measured from the Lingui + * include roots of a production app): most source is NOT i18n. Only ~46% of + * files carry any i18n marker, and only ~3% of lines are i18n — the extractor + * still has to scan every line of the rest. Message shape is plain + ~15% + * {name} variables (no plurals yet — see issue #355 / PR #358). + */ realistic: { - fileCount: 400, - messagesPerFile: 25, - changedCount: 750, - newCount: 1000, - removedCount: 750, + layout: "realistic", + messages: 6000, + markedFiles: 750, + unmarkedFiles: 750, + markedFileLines: 374, + unmarkedFileLines: 160, + changedCount: 450, + newCount: 600, + removedCount: 450, }, large: { fileCount: 720, @@ -46,7 +57,7 @@ export async function createWorkflowCorpus({ profileName, rootDir, seed = DEFAUL throw new Error(`Unknown workflow benchmark profile: ${profileName}`) } - const messageCount = profile.fileCount * profile.messagesPerFile + const messageCount = profile.messages ?? profile.fileCount * profile.messagesPerFile const generated = createMessageInventory({ messageCount, profile, seed }) const profileRoot = path.join(rootDir, profileName) const toolRoots = { @@ -67,12 +78,15 @@ export async function createWorkflowCorpus({ profileName, rootDir, seed = DEFAUL writeI18nextWorkspace(toolRoots.i18next, generated, profile), ]) + const fileCount = + profile.layout === "realistic" ? profile.markedFiles + profile.unmarkedFiles : profile.fileCount + return { profileName, seed, roots: toolRoots, - fileCount: profile.fileCount, - messagesPerFile: profile.messagesPerFile, + fileCount, + messagesPerFile: profile.messagesPerFile ?? Math.round(messageCount / fileCount), sourceMessageCount: generated.sourceMessages.length, baselineMessageCount: generated.baselineMessages.length, currentMessages: generated.sourceMessages.map((message) => message.current), @@ -187,6 +201,11 @@ async function writeI18nextWorkspace(rootDir, inventory, profile) { } async function writeToolSourceFiles(rootDir, sourceMessages, profile, renderer) { + if (profile.layout === "realistic") { + await writeRealisticSourceFiles(rootDir, sourceMessages, profile, renderer) + return + } + const files = Array.from({ length: profile.fileCount }, (_, fileIndex) => []) for (let index = 0; index < sourceMessages.length; index += 1) { @@ -205,7 +224,87 @@ async function writeToolSourceFiles(rootDir, sourceMessages, profile, renderer) } } -function renderPalamedesSource(fileIndex, messages, extension) { +/* + * Realistic web layout: messages are spread thinly across `markedFiles`, and + * `unmarkedFiles` carry no i18n at all — but the extractor still has to scan + * every line. Both kinds are padded with non-i18n filler to the measured line + * budgets so the timed workload reflects real parse volume, not a dense + * all-messages fixture. + */ +async function writeRealisticSourceFiles(rootDir, sourceMessages, profile, renderer) { + const marked = Array.from({ length: profile.markedFiles }, () => []) + for (let index = 0; index < sourceMessages.length; index += 1) { + marked[index % profile.markedFiles].push(sourceMessages[index]) + } + + const generatedDir = path.join(rootDir, "src", "generated") + const writes = [] + let fileIndex = 0 + + /* Marked files stay .ts: message calls extract reliably next to filler, + * whereas .tsx + filler + hits a JSX parse edge in the extractor. */ + for (let i = 0; i < profile.markedFiles; i += 1) { + const filename = path.join(generatedDir, `fixture-${String(fileIndex).padStart(4, "0")}.ts`) + writes.push( + writeFile(filename, renderer(fileIndex, marked[i], "ts", profile.markedFileLines), "utf8") + ) + fileIndex += 1 + } + + for (let j = 0; j < profile.unmarkedFiles; j += 1) { + const extension = fileIndex % 3 === 0 ? "tsx" : "ts" + const filename = path.join( + generatedDir, + `fixture-${String(fileIndex).padStart(4, "0")}.${extension}` + ) + writes.push( + writeFile(filename, renderFillerModule(fileIndex, profile.unmarkedFileLines), "utf8") + ) + fileIndex += 1 + } + + await Promise.all(writes) +} + +const FILLER_UNIT_LINES = 13 + +/* Deterministic non-i18n source: imports, interfaces and helpers with no + * translation markers. Emits whole units only (never cuts a declaration in + * half — that would be a syntax error the extractor bails on), then pads with + * comment lines to hit `lineCount` exactly so file size stays realistic. */ +function fillerLines(fileIndex, lineCount) { + const lines = ['import { useMemo } from "react"', ""] + let unit = 0 + while (lines.length + FILLER_UNIT_LINES <= lineCount) { + const n = unit + 1 + lines.push( + `interface Record${fileIndex}_${unit} {`, + " id: number", + " slug: string", + " weight: number", + "}", + "", + `export function compute${fileIndex}_${unit}(input: number): Record${fileIndex}_${unit} {`, + ` const id = (input * ${n} + ${fileIndex}) % 9973`, + ` const slug = "node-" + id.toString(36) + "-${unit}"`, + ` const weight = Math.round((id / ${n}) * 100) / 100`, + " return { id, slug, weight }", + "}", + "" + ) + unit += 1 + } + while (lines.length < lineCount) { + lines.push(`// filler line ${lines.length}`) + } + return lines +} + +function renderFillerModule(fileIndex, lineCount) { + return `${fillerLines(fileIndex, lineCount).join("\n")}\n` +} + +function renderPalamedesSource(fileIndex, messages, extension, targetLines) { const imports = ['import { defineMessage, t } from "@palamedes/core/macro"'] if (extension === "tsx") { imports.push('import { Trans } from "@palamedes/react/macro"') @@ -216,10 +315,12 @@ function renderPalamedesSource(fileIndex, messages, extension) { imports, messages, extension, + fileIndex, + targetLines, }) } -function renderLinguiSource(fileIndex, messages, extension) { +function renderLinguiSource(fileIndex, messages, extension, targetLines) { const imports = ['import { defineMessage, t } from "@lingui/core/macro"'] if (extension === "tsx") { imports.push('import { Trans } from "@lingui/react/macro"') @@ -230,62 +331,81 @@ function renderLinguiSource(fileIndex, messages, extension) { imports, messages, extension, + fileIndex, + targetLines, }) } -function renderMacroSource({ functionName, imports, messages, extension }) { - const lines = [...imports, "", `export function ${functionName}() {`, " const values = ["] +/* Variable messages ({name}) are authored with defineMessage — t({ message }) + * would drop the interpolation. Plain messages keep the t/defineMessage mix. */ +function authorMacroMessage(message, index) { + if (message.includes("{name}") || index % 3 !== 0) { + return ` defineMessage({ message: ${JSON.stringify(message)} }).message,` + } + return ` t({ message: ${JSON.stringify(message)} }),` +} + +function renderMacroSource({ functionName, imports, messages, extension, fileIndex, targetLines }) { + const body = [`export function ${functionName}() {`, " const values = ["] for (let index = 0; index < messages.length; index += 1) { - const message = messages[index].current - if (index % 3 === 0) { - lines.push(` t({ message: ${JSON.stringify(message)} }),`) - } else { - lines.push(` defineMessage({ message: ${JSON.stringify(message)} }).message,`) - } + body.push(authorMacroMessage(messages[index].current, index)) } - lines.push(" ]") + body.push(" ]") if (extension === "tsx") { - lines.push( + /* Trans wraps a plain message — {name} in JSX text would be read as an + * expression, not message content. */ + const trans = messages.find((entry) => !entry.current.includes("{name}")) ?? messages[0] + body.push( " return (", "

", - ` ${escapeJsxText(messages[0].current)}`, + ` ${escapeJsxText(trans.current)}`, " {values.length}", "
", " )" ) } else { - lines.push(' return values.join("\\n")') + body.push(' return values.join("\\n")') } - lines.push("}", "") - return lines.join("\n") + body.push("}", "") + return withFiller(imports, body, fileIndex, targetLines) } -function renderI18nextSource(fileIndex, messages, extension) { - const lines = [ - 'import i18next from "i18next"', - "", +function renderI18nextSource(fileIndex, messages, extension, targetLines) { + const imports = ['import i18next from "i18next"'] + const body = [ `export function i18nextFixture${String(fileIndex).padStart(4, "0")}() {`, " const values = [", ] for (const entry of messages) { - lines.push(` i18next.t(${JSON.stringify(entry.current)}),`) + body.push(` i18next.t(${JSON.stringify(entry.current)}),`) } - lines.push(" ]") + body.push(" ]") if (extension === "tsx") { - lines.push(' return
{values.join("\\\\n")}
') + body.push(' return
{values.join("\\\\n")}
') } else { - lines.push(' return values.join("\\n")') + body.push(' return values.join("\\n")') } - lines.push("}", "") - return lines.join("\n") + body.push("}", "") + return withFiller(imports, body, fileIndex, targetLines) +} + +/* Assemble a source file. In the realistic layout (targetLines set) the message + * function is padded with non-i18n filler so the file reaches the line budget; + * otherwise the dense small/medium fixture shape is kept. */ +function withFiller(imports, body, fileIndex, targetLines) { + if (!targetLines) { + return [...imports, "", ...body].join("\n") + } + const fillerCount = Math.max(0, targetLines - imports.length - 1 - body.length) + return [...imports, "", ...fillerLines(fileIndex, fillerCount), ...body].join("\n") } async function writePoCatalogs(rootDir, messages, generator) { @@ -346,11 +466,21 @@ function translate(locale, message) { return locale === "en" ? message : `[de] ${message}` } +/* ~15% of messages carry a simple {name} variable, the rest are plain (plurals + * are deferred — see issue #355 / PR #358). Keyed off index % 100 so the split + * is exact and stable across the current/previous variants of a message. */ +function isVariableMessage(index) { + return index % 100 < 15 +} + function makeMessage(seed, index, variant) { const area = pick(AREAS, seed, index, 1) const action = pick(ACTIONS, seed, index, 2) const surface = pick(SURFACES, seed, index, 3) const token = `${String(index).padStart(5, "0")}-${variant === "previous" ? "old" : "now"}` + if (isVariableMessage(index)) { + return `${capitalize(action)} ${area} ${surface} for {name} ${token}` + } return `${capitalize(action)} ${area} ${surface} item ${token}` } diff --git a/docs/benchmark-e2e-workflow.md b/docs/benchmark-e2e-workflow.md index 660d2130..72f7ff24 100644 --- a/docs/benchmark-e2e-workflow.md +++ b/docs/benchmark-e2e-workflow.md @@ -106,11 +106,11 @@ Median results: | Tool | Median | | -------------- | ----------: | -| Palamedes | `33.41 ms` | -| Lingui | `738.18 ms` | -| i18next-parser | `531.39 ms` | +| Palamedes | `31.64 ms` | +| Lingui | `674.05 ms` | +| i18next-parser | `499.18 ms` | -On this run, Palamedes measured `22.10x` faster than Lingui and `15.91x` +On this run, Palamedes measured `21.31x` faster than Lingui and `15.78x` faster than i18next-parser. ### Medium @@ -126,31 +126,34 @@ Median results: | Tool | Median | | -------------- | ----------: | -| Palamedes | `46.27 ms` | -| Lingui | `800.75 ms` | -| i18next-parser | `583.00 ms` | +| Palamedes | `43.37 ms` | +| Lingui | `745.33 ms` | +| i18next-parser | `546.32 ms` | -On this run, Palamedes measured `17.31x` faster than Lingui and `12.60x` +On this run, Palamedes measured `17.19x` faster than Lingui and `12.60x` faster than i18next-parser. ### Realistic -Corpus: +Corpus (modeled on a production web app's Lingui include roots — most source is +not i18n, but the extractor still has to scan all of it; figures rounded so they +read as a shape, not false precision): -- `400` files -- `10,000` current messages -- `9,750` baseline messages -- `750` changed, `1,000` new, `750` removed +- `1,500` files (`750` with i18n markers, `750` without) +- `~400,000` source lines (~3% carry i18n syntax) +- `6,000` current messages (~15% with a `{name}` variable) +- `5,850` baseline messages +- `450` changed, `600` new, `450` removed Median results: | Tool | Median | | -------------- | -----------: | -| Palamedes | `83.75 ms` | -| Lingui | `1060.24 ms` | -| i18next-parser | `787.15 ms` | +| Palamedes | `173.50 ms` | +| Lingui | `2254.38 ms` | +| i18next-parser | `1561.82 ms` | -On this run, Palamedes measured `12.66x` faster than Lingui and `9.40x` +On this run, Palamedes measured `12.99x` faster than Lingui and `9.00x` faster than i18next-parser. ## Reading The Numbers diff --git a/docs/proof-and-benchmarks.md b/docs/proof-and-benchmarks.md index 1fc68001..e1c11d95 100644 --- a/docs/proof-and-benchmarks.md +++ b/docs/proof-and-benchmarks.md @@ -242,11 +242,17 @@ Environment: Median results from that run: -| Profile | Palamedes | Lingui | i18next-parser | -| --------- | ---------: | -----------: | -------------: | -| Small | `33.41 ms` | `738.18 ms` | `531.39 ms` | -| Medium | `46.27 ms` | `800.75 ms` | `583.00 ms` | -| Realistic | `83.75 ms` | `1060.24 ms` | `787.15 ms` | +| Profile | Palamedes | Lingui | i18next-parser | +| --------- | ----------: | -----------: | -------------: | +| Small | `31.64 ms` | `674.05 ms` | `499.18 ms` | +| Medium | `43.37 ms` | `745.33 ms` | `546.32 ms` | +| Realistic | `173.50 ms` | `2254.38 ms` | `1561.82 ms` | + +The `realistic` profile is modeled on a production web app's Lingui include +roots: `~1,500` files across `~400,000` lines, only about half of which carry +any i18n marker. Most of the source is ordinary, non-i18n code — the extractor +still has to scan every line, which is the extract-time cost a real project +pays. (Figures are rounded so they read as a shape, not false precision.) The harness validates that all three tools write the same active source-message set before publishing timings. It renders the same logical message inventory diff --git a/site/app/components/home/ProofStrip.tsx b/site/app/components/home/ProofStrip.tsx index 3697dee7..1f5c6b9d 100644 --- a/site/app/components/home/ProofStrip.tsx +++ b/site/app/components/home/ProofStrip.tsx @@ -15,8 +15,8 @@ const STATS: Stat[] = [ { value: "20", label: "browser-verified example apps", href: "/frameworks" }, { value: "5 × 4", label: "frameworks × locale strategies", href: "/frameworks" }, { - value: "12.66×", - label: "faster than Lingui — 10k-message extract/update benchmark, machine-local run", + value: "12.99×", + label: "faster than Lingui — realistic 1,500-file extract/update benchmark, machine-local run", href: "/proof", }, { value: "16", label: "ADRs documenting every tradeoff", href: decisionHref() }, diff --git a/site/app/components/proof/BenchmarkChart.tsx b/site/app/components/proof/BenchmarkChart.tsx index 21271289..bccbf5d6 100644 --- a/site/app/components/proof/BenchmarkChart.tsx +++ b/site/app/components/proof/BenchmarkChart.tsx @@ -5,23 +5,29 @@ import { docsHref } from "~/data/links" import { useInView } from "~/hooks/useInView" import { usePrefersReducedMotion } from "~/hooks/usePrefersReducedMotion" -const TICK_STEP_MS = 100 +/* Tick step that keeps the axis to ~8-10 gridlines whatever the range. */ +function chooseStep(dataMax: number): number { + if (dataMax <= 1000) return 100 + if (dataMax <= 3000) return 250 + if (dataMax <= 6000) return 500 + return 1000 +} /* * Round a data maximum up to a clean axis maximum (a whole multiple of the * tick step) that always leaves visible headroom, so the longest bar never * ends hard on the frame. */ -function niceAxisMaxMs(dataMax: number, stepMs = TICK_STEP_MS): number { +function niceAxisMaxMs(dataMax: number, stepMs: number): number { const rounded = Math.ceil(dataMax / stepMs) * stepMs return rounded - dataMax < stepMs / 2 ? rounded + stepMs : rounded } /* * Custom spec-sheet bar chart: linear ms scale (no silent truncation), - * hairline ticks every 100ms, one accent bar for Palamedes. The axis maximum - * rounds up to a clean number with headroom (see niceAxisMaxMs). Bars grow on - * first view; prerender/no-JS/reduced-motion shows full-width bars. + * hairline ticks at an adaptive step, one accent bar for Palamedes. The axis + * maximum rounds up to a clean number with headroom. Bars grow on first view; + * prerender/no-JS/reduced-motion shows full-width bars. */ export function BenchmarkChart({ corpus }: { corpus: BenchCorpus }) { const ref = useRef(null) @@ -29,9 +35,10 @@ export function BenchmarkChart({ corpus }: { corpus: BenchCorpus }) { const reducedMotion = usePrefersReducedMotion() const animate = !reducedMotion && inView const dataMax = Math.max(...corpus.rows.map((row) => row.medianMs)) - const maxMs = niceAxisMaxMs(dataMax) + const step = chooseStep(dataMax) + const maxMs = niceAxisMaxMs(dataMax, step) const tickValues: number[] = [] - for (let value = TICK_STEP_MS; value < maxMs; value += TICK_STEP_MS) { + for (let value = step; value < maxMs; value += step) { tickValues.push(value) } diff --git a/site/app/data/bench.ts b/site/app/data/bench.ts index 7b7d9eab..a7d3ecae 100644 --- a/site/app/data/bench.ts +++ b/site/app/data/bench.ts @@ -19,6 +19,11 @@ export interface BenchCorpus { title: string corpus: string rows: BenchRow[] + /* + * Speedup ratios. Not rendered in the chart (the bars carry the story), but + * asserted against the checked-in report by scripts/verify-site-bench-data.mjs + * so the numbers quoted in prose (hero, ProofStrip) can't silently drift. + */ ratios: { lingui: string; i18next: string } } @@ -30,16 +35,23 @@ export const BENCH_META = { reportPath: "benchmarks/e2e-workflow/results/latest.md", } +/* + * Only BENCH_REALISTIC is charted on the site (home + proof). BENCH_SMALL and + * BENCH_MEDIUM are kept as the checked-in reference for the smaller corpora: + * they back the tables in the benchmark docs and are validated against the + * report by scripts/verify-site-bench-data.mjs. Keep all three in sync with + * benchmarks/e2e-workflow/results/latest.md (the drift guard enforces it). + */ export const BENCH_SMALL: BenchCorpus = { id: "small", title: "Small corpus — 80 files, 640 messages (median of 7 runs)", corpus: "80 files, 640 messages", rows: [ - { tool: "Palamedes", medianMs: 33.41, accent: true }, - { tool: "i18next-parser", medianMs: 531.39 }, - { tool: "Lingui", medianMs: 738.18 }, + { tool: "Palamedes", medianMs: 31.64, accent: true }, + { tool: "i18next-parser", medianMs: 499.18 }, + { tool: "Lingui", medianMs: 674.05 }, ], - ratios: { lingui: "22.10×", i18next: "15.91×" }, + ratios: { lingui: "21.31×", i18next: "15.78×" }, } export const BENCH_MEDIUM: BenchCorpus = { @@ -47,21 +59,21 @@ export const BENCH_MEDIUM: BenchCorpus = { title: "Medium corpus — 240 files, 1920 messages (median of 7 runs)", corpus: "240 files, 1920 messages", rows: [ - { tool: "Palamedes", medianMs: 46.27, accent: true }, - { tool: "i18next-parser", medianMs: 583 }, - { tool: "Lingui", medianMs: 800.75 }, + { tool: "Palamedes", medianMs: 43.37, accent: true }, + { tool: "i18next-parser", medianMs: 546.32 }, + { tool: "Lingui", medianMs: 745.33 }, ], - ratios: { lingui: "17.31×", i18next: "12.60×" }, + ratios: { lingui: "17.19×", i18next: "12.60×" }, } export const BENCH_REALISTIC: BenchCorpus = { id: "realistic", - title: "Realistic corpus — 400 files, 10,000 messages (median of 7 runs)", - corpus: "400 files, 10,000 messages", + title: "Realistic corpus — 1,500 files across ~400k lines, 6,000 messages (median of 7 runs)", + corpus: "1,500 files (750 with i18n), ~400k lines, 6,000 messages", rows: [ - { tool: "Palamedes", medianMs: 83.75, accent: true }, - { tool: "i18next-parser", medianMs: 787.15 }, - { tool: "Lingui", medianMs: 1060.24 }, + { tool: "Palamedes", medianMs: 173.5, accent: true }, + { tool: "i18next-parser", medianMs: 1561.82 }, + { tool: "Lingui", medianMs: 2254.38 }, ], - ratios: { lingui: "12.66×", i18next: "9.40×" }, + ratios: { lingui: "12.99×", i18next: "9.00×" }, } diff --git a/site/app/data/compare.ts b/site/app/data/compare.ts index 4dadafc1..937070ec 100644 --- a/site/app/data/compare.ts +++ b/site/app/data/compare.ts @@ -25,7 +25,7 @@ export const COMPARE_TOOLS: CompareTool[] = [ "message + context, stable across refactors", "One model: getI18n() everywhere", "Native (Rust/ferrocat), semantic merge & audits", - "83.75 ms (checked report¹)", + "174 ms (checked report¹)", "5 families browser-verified in CI", "New — honest about it; 16 ADRs document the tradeoffs", ], @@ -37,7 +37,7 @@ export const COMPARE_TOOLS: CompareTool[] = [ "Configurable ID strategies", "Multiple entry points (i18n, hooks, macros)", "JS-based tooling with plugin ecosystem", - "1060.24 ms (same harness¹)", + "2254 ms (same harness¹)", "Broad, community-verified", "Mature, large community, years of production use", ], @@ -45,5 +45,5 @@ export const COMPARE_TOOLS: CompareTool[] = [ ] export const COMPARE_FOOTNOTES = [ - "¹ Median of 7 runs on the realistic corpus (400 files, 10,000 messages), same semantic validation — methodology and raw reports in the repo.", + "¹ Median of 7 runs on the realistic corpus (1,500 files, 6,000 messages, ~400k lines — half the files not even i18n), same semantic validation — methodology and raw reports in the repo.", ] diff --git a/site/app/routes/home.tsx b/site/app/routes/home.tsx index eea50220..92d36049 100644 --- a/site/app/routes/home.tsx +++ b/site/app/routes/home.tsx @@ -51,8 +51,8 @@ export default function Home() { Write messages where your UI happens. Keep source-string-first .po catalogs your translators can actually read. Ship the same runtime model across Next.js, TanStack Start, SolidStart, Waku, and React Router — with a Rust core that ran a checked - 10,000-message extract/update benchmark 12.66× faster than Lingui on the recorded - machine-local run. + extract/update benchmark across a real-app-shaped 1,500-file corpus 12.99× faster than + Lingui on the recorded machine-local run.

Get started in 5 minutes