Skip to content

perf: memoise pluralize() and singularize() per application - #4184

Merged
bpamiri merged 5 commits into
developfrom
peter/memoise-inflection
Oct 5, 2026
Merged

bpamiri merged 5 commits into
developfrom
peter/memoise-inflection

Conversation

@bpamiri

@bpamiri bpamiri commented Oct 3, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4150

Summary

pluralize() / singularize() ($singularizeOrPluralize) redid all their setup on every call:

  • rebuilding the irregulars list from settings;
  • cleaning the uncountables list;
  • rebuilding a 2D array of about 20 regex rules;
  • running up to 20 ReFindNoCase checks.

The result depends only on the text, the direction and the inflection settings, and the framework calls these on a small vocabulary (model, table and route names).

Changes (vendor/wheels/global/strings.cfm, settings.cfm)

  • $inflect(text, which) is the existing inflection body, moved unchanged. It's the uncached reference the spec compares against.
  • $singularizeOrPluralize keeps its signature. count = 1 still returns the input, and the count / returnCount formatting is still applied after the lookup, so it's never cached.
  • $cachedInflection serves $inflect()'s result from application.wheels.inflectionCache.entries, keyed by which|text:
    • Case. Struct keys are case-insensitive, so each entry keeps its exact text and a case-sensitive Compare guards the hit. A case variant (Person vs person) is computed, never served the other spelling's result.
    • Bounded. Texts over 100 characters aren't cached, and the cache is cleared when it reaches 2,000 entries, so arbitrary input can't grow it without bound.
  • $inflectionCache returns the cache, or "" (no caching) in two cases:
    • Before startup, before the application settings exist.
    • Tenant override. When request.wheels.tenant.config overrides uncountables or irregulars, because $get() honours per-tenant settings, so results can differ per tenant.
  • Invalidation:
    • set(uncountables=…) / set(irregulars=…) drop the cache;
    • a fingerprint (the uncountables list plus the number of irregulars) rebuilds it when a word is added to or removed from the settings struct directly. Changing an existing irregular's plural in place isn't detected; change it through set(). Fingerprinting every pair was measured to double the cost of a cache hit (Lucee 7 5 → 8 µs, RustCFML 5.5 → 10.5 µs);
    • reload rebuilds application.wheels.

The cache is a nested struct of strings in application.wheels, so invariant 2 (no functions on application) isn't involved. It doesn't use Java maps, so it works on RustCFML.

Measurements

Per call, median of 3 runs of 2,000 calls after 50 warm-up calls. "Before" is develop's strings.cfm / settings.cfm swapped into the same worktree. SQLite, Apple M3 Ultra.

Engine pluralize("item") before → after singularize("products") before → after
RustCFML v0.693.0 (CI pin) 86 → 5 µs 116.5 → 5 µs
Adobe ColdFusion 2023 299.5 → 9.5 µs 381 → 9 µs
Adobe ColdFusion 2025 305.5 → 11 µs 419.5 → 9.5 µs
BoxLang 152.5 → 11.5 µs 194 → 11.5 µs
Lucee 7 25 → 5 µs 27.5 → 4.5 µs

Test plan

New vendor/wheels/tests/specs/global/InflectionCacheSpec.cfc:

  • Corpus: over about 100 words, pluralize / singularize equal the uncached $inflect() on a miss and on a hit. The words cover regular words, irregulars, uncountables, camelCase, mixed case and edge cases.
  • Formatting: count / returnCount behave as before.
  • Case: person / Person / PERSON each get their own correct result.
  • Settings changes:
    • set(uncountables=…) and set(irregulars=…) take effect immediately;
    • so does set() of an existing irregular's plural;
    • so does a word added to the irregulars struct directly.
  • Tenants: a tenant override of uncountables bypasses the cache.
  • Bounds: at most 2,000 entries; long text isn't cached.

The corpus includes vertex, index and half again now that #4186 has fixed BoxLang's crash on them.

Runs (directory= runs on SQLite; each cell is pass / fail / error):

Engine global model controller view
Lucee 7 311 / 0 / 0 1317 / 0 / 0 591 / 0 / 0 605 / 0 / 0
Adobe ColdFusion 2023 311 / 0 / 0 1317 / 0 / 0 591 / 0 / 0 605 / 0 / 0
Adobe ColdFusion 2025 311 / 0 / 0 1317 / 0 / 0 591 / 0 / 0 605 / 0 / 0
BoxLang 312 / 0 / 0 1320 / 0 / 0 591 / 0 / 0 605 / 0 / 0
RustCFML v0.693.0 311 / 0 / 0 1313 / 0 / 0 591 / 0 / 0 605 / 0 / 0

The full Lucee 7 suite (tools/test-local.sh) also passes: 6527 passed.

After merging develop (#4186) at 03995a9: global is 0 fail / 0 error on all six engines, InflectionCacheSpec is 8/8 and InflectionRuleGroupsSpec 3/3 on each, and the full Lucee 7 suite passes (6533). The existing global/stringsSpec passes unchanged.

After merging develop for 4.2 (with #4188 and #4191): both PRs changed the singularize rule line in the old $singularizeOrPluralize body, which this PR moves into $inflect(). The merge keeps this PR's structure and carries both rule changes into $inflect(); its rule lines are identical to develop's. global specs (SQLite) 321 / 0 / 0 on Lucee 7, Lucee 6 and BoxLang, 320 / 0 / 0 on Adobe 2023, Adobe 2025 and RustCFML v0.693.0 (including SingularizeSesRuleSpec and SingularizeDatabasesSpec through the cache); full core suite, Lucee 7 + SQLite: 6739 passed, including the internal repo-path specs.

🤖 Generated with Claude Code

The inflection setup (irregulars, uncountables, the regex rule table) was rebuilt on every
call. Results are now served from application.wheels.inflectionCache, keyed by direction and
exact text, cleared by set(uncountables/irregulars), a settings fingerprint and reload,
bypassed under a tenant override, and bounded to 2,000 entries of up to 100 characters.

Refs #4150

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
@bpamiri

bpamiri commented Oct 3, 2026

Copy link
Copy Markdown
Collaborator Author

rev1-r1 review @ 06aa8d5 (draft, 4.3): APPROVE on code; merge gated on compat-matrix run 37111637301 and on 4.2.0 shipping.

  • No logic change: $inflect() is the old body verbatim, minus the count != 1 branch, which moves up into the public function. pluralize/singularize with count = 1 or returnCount behave as before.
  • Cache correctness:
    • Keys are which|text, and each entry stores the exact text, so a case variant (struct keys are case-insensitive) is computed rather than served another spelling's result.
    • Texts over 100 characters aren't cached, and the cache clears at 2000 entries, so arbitrary input can't grow it without bound.
    • It's bypassed whenever the current tenant overrides uncountables or irregulars, so per-tenant inflection stays exact.
    • set(uncountables|irregulars=…) drops it, and a reload rebuilds the application struct.
  • Concurrency: unlocked reads and writes on the application-scoped struct are benign here. Engine structs are thread-safe for single operations, a lost write only means a recompute, and a concurrent clear only means misses.
  • Specs: InflectionCacheSpec covers cache hits equalling fresh results over the corpus, case variants, the tenant bypass, invalidation via set(), and the size bound.

LOW: the fingerprint (the uncountables list plus the irregulars count) won't notice an irregular word's value changed in place in the settings struct (application.wheels.irregulars.person = "peoples"), because the count is the same. set() covers the supported path. Folding the irregulars' keys and values into the fingerprint, or documenting "change inflection settings with set()", would close it.

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Wheels Test Results

219 271 tests   215 045 ✅  1h 18m 27s ⏱️
 16 960 suites    4 226 💤
     32 files          0 ❌

Results for commit 4d49d7b.

♻️ This comment has been updated with latest results.

bpamiri and others added 4 commits October 3, 2026 03:05
…ection

Signed-off-by: Peter Amiri <peter@alurium.com>

# Conflicts:
#	vendor/wheels/global/strings.cfm
…f an existing irregular

#4186 fixed the BoxLang crash, so the three words go back into the equivalence corpus. A
changed plural for an existing irregular isn't fingerprinted (it would double a cache hit), so
a spec pins that changing it through set() takes effect, and the docblock and fragment say so.

Refs #4150

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Peter Amiri <peter@alurium.com>
…ection

Signed-off-by: Peter Amiri <peter@alurium.com>

# Conflicts:
#	vendor/wheels/global/strings.cfm
…ection

Signed-off-by: Peter Amiri <peter@alurium.com>

# Conflicts:
#	vendor/wheels/global/strings.cfm
@bpamiri bpamiri added this to the v4.2.0 milestone Oct 5, 2026
@bpamiri
bpamiri marked this pull request as ready for review October 5, 2026 06:16
@bpamiri

bpamiri commented Oct 5, 2026

Copy link
Copy Markdown
Collaborator Author

Final: APPROVE @ 4d49d7b (lifted into 4.2; matrix 37271623294, all 32 legs read)

Delta since my approval at 03995a9

Matrix

  • 32/32 legs have 0 fail, 0 error.
  • In every leg (lucee6/7, adobe2023/2025, boxlang × every database), all with 0 skipped:
    • InflectionCacheSpec 8/8
    • SingularizeDatabasesSpec 3/3 (databases → database through the cached singularize())
    • SingularizeSesRuleSpec 2/2 (psychoanalyses → psychoanalysis)
    • InflectionRuleGroupsSpec 3/3
  • So on every engine the cache returns the new singularize results.
  • The set of skipped specs is identical to a current develop-based run (fix: singularize databases to database instead of databasis #4191's matrix 37269045190) on lucee7-mysql, adobe2025-sqlite, boxlang-oracle and adobe2023-sqlserver.

No open findings.

@bpamiri
bpamiri merged commit 914231f into develop Oct 5, 2026
64 checks passed
@bpamiri
bpamiri deleted the peter/memoise-inflection branch October 5, 2026 06:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(global): memoise pluralize()/singularize() results

1 participant