Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
526 changes: 377 additions & 149 deletions benchmarks/e2e-workflow/results/latest.json

Large diffs are not rendered by default.

41 changes: 29 additions & 12 deletions benchmarks/e2e-workflow/results/latest.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# End-to-End Extract and Catalog Update Benchmark

Generated: 2026-07-06T09:09:36.849Z
Generated: 2026-07-06T21:31:33.279Z
Node: v24.18.0
Platform: darwin/arm64
Seed: 20260703
Expand All @@ -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

Expand All @@ -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 | 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 | 21.00x |
| Palamedes vs i18next-parser | Palamedes | 15.68x |
| Palamedes vs Lingui | Palamedes | 21.31x |
| Palamedes vs i18next-parser | Palamedes | 15.78x |

## Medium

Expand All @@ -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 | 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.02x |
| Palamedes vs i18next-parser | Palamedes | 12.30x |
| Palamedes vs Lingui | Palamedes | 17.19x |
| Palamedes vs i18next-parser | Palamedes | 12.60x |

## Realistic

- 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 | 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.99x |
| Palamedes vs i18next-parser | Palamedes | 9.00x |

## Notes

Expand Down
195 changes: 166 additions & 29 deletions benchmarks/e2e-workflow/src/corpus.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ 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: {
layout: "realistic",
messages: 6000,
markedFiles: 750,
unmarkedFiles: 750,
markedFileLines: 374,
unmarkedFileLines: 160,
changedCount: 450,
newCount: 600,
removedCount: 450,
},
large: {
fileCount: 720,
messagesPerFile: 8,
Expand All @@ -39,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 = {
Expand All @@ -60,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),
Expand Down Expand Up @@ -180,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) {
Expand All @@ -198,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 + <Trans> 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"')
Expand All @@ -209,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"')
Expand All @@ -223,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 (",
" <section>",
` <Trans>${escapeJsxText(messages[0].current)}</Trans>`,
` <Trans>${escapeJsxText(trans.current)}</Trans>`,
" <span>{values.length}</span>",
" </section>",
" )"
)
} 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 <section>{values.join("\\\\n")}</section>')
body.push(' return <section>{values.join("\\\\n")}</section>')
} 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) {
Expand Down Expand Up @@ -339,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}`
}

Expand Down
2 changes: 1 addition & 1 deletion benchmarks/e2e-workflow/src/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading