diff --git a/.changeset/reuse-locale-collator.md b/.changeset/reuse-locale-collator.md new file mode 100644 index 0000000000..ef35f73797 --- /dev/null +++ b/.changeset/reuse-locale-collator.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Compare strings under locale collation with one `Intl.Collator` per compare-options object instead of calling `localeCompare` for each comparison. The order is unchanged. Building a collator on every comparison was slow on Hermes for Android, where each one is a Java object. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index a8e7b6deeb..f265993121 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -2178,6 +2178,17 @@ an ordinary unindexed on-demand prefix against its bounded request, virtual field advice, and direction-free comparison guidance. It observes first acquisitions and warning text, not later window moves or real-provider cost. +The comparison oracle's `comparison.locale-option-history` property checks +locale string collation over generated histories of up to twelve comparisons. +Each comparison picks one of seven option sets (default locale, `en-US` plain, +numeric, base sensitivity and upper case first, `sv`, `de`), receives either the +shared option object of its set or a fresh copy, and must order its two strings +as a new `Intl.Collator(locale, localeOptions)` does. Constructed histories put +the numeric `en-US` set after the plain one on `item2`/`item10`. A collator kept +for every option set, and one kept per locale, fail it. It does not cover +option objects changed after use, or collation through Collection and query +paths, which the rows above own. + ## Unit-test retirement transfers in PR #2116 The diff --git a/docs/contributing/oracle-reviews/perf-locale-collator.md b/docs/contributing/oracle-reviews/perf-locale-collator.md new file mode 100644 index 0000000000..9e81500c7c --- /dev/null +++ b/docs/contributing/oracle-reviews/perf-locale-collator.md @@ -0,0 +1,79 @@ +# Cached locale collator oracle review + +Reviewed source head: the commit that adds this record on +`perf/db-reuse-locale-collator`. It changes `ascComparator` in +`packages/db/src/utils/comparison.ts` and adds the +`comparison.locale-option-history` property to +`packages/db/tests/comparison-oracle.property.test.ts`. + +## Contract and evidence + +Under locale string collation, `ascComparator` orders two strings as +`Intl.Collator(locale, localeOptions)` does. The authority is +`StringCollationConfig` and `CompareOptions`, and ECMA-402, which defines +`String.prototype.localeCompare(that, locales, options)` as a comparison by a new +`Intl.Collator(locales, options)`. + +The change keeps this result and changes its cost. Before, every comparison +called `localeCompare`, and Hermes for Android builds a Java collator for each +call. Now each compare-options object keeps the collator it first builds, in a +`WeakMap` keyed by that object. + +The cache adds state across comparisons, which the stateless comparison laws +cannot see. The new property judges histories of comparisons under mixed option +sets. Its model builds a new `Intl.Collator` for every step and imports no +production helper. + +## Mutant results + +The production source was restored after each run. Command: +`pnpm --filter @tanstack/db exec vitest run --coverage.enabled=false tests/comparison-oracle.property.test.ts -t locale-option-history`. + +| Production | Outcome | +| --- | --- | +| Parent (`localeCompare` per call) | 11 of 11 passed. The law is unchanged, so the parent passes it. | +| This change | 11 of 11 passed. | +| One collator kept for every option set | 7 failures. Shrunk to one step: `item2` against `item10` under numeric `en-US` after plain `en-US`. | +| One collator kept per locale, ignoring `localeOptions` | 7 failures, same shrunk step. | + +## ORC-012 requirement audit + +| Requirement | Outcome | +| --- | --- | +| ORC-001 | Applicable. The law and authority are above and in the property. Limits: ascending comparisons through `ascComparator`; option objects are not changed after use; Collection and query paths keep their owners in the coverage map. | +| ORC-002 | Applicable. The model calls `new Intl.Collator(locale, localeOptions).compare` for each step and imports nothing from production. | +| ORC-003 | Applicable. The property's law, authority and limits state the contract; the grammar, driver and check are separate functions beside it. | +| ORC-004 | Applicable. Histories have 1 to 12 steps over seven option sets (default locale; `en-US` plain, `numeric`, `sensitivity: "base"`, `caseFirst: "upper"`; `sv`; `de`) and eight strings. Every two sets that share a locale order some pair differently. Each step gets the set's shared options object or a fresh copy. | +| ORC-005 | Applicable. The driver calls the exported `ascComparator` with `CompareOptions` built as a Collection builds them, and records the sign of each result. | +| ORC-006 | Applicable. Three calibrations: one collator for every option set, one collator per locale, and a construction whose reach observation is removed. Each is rejected under its named law. The production mutants above confirm the first two. | +| ORC-007 | Applicable. A fixed-seed campaign (`20260911`) and a random campaign share one budget. The property is registered as `comparison.locale-option-history`, so the replay variables select it. | +| ORC-008 | Inapplicable. The model is a stateless function of each step. | +| ORC-009 | Applicable. "Option set" names one `CompareOptions` configuration; "locale string collation" is `stringSort: 'locale'`. | +| ORC-010 | Applicable. A violation names the step, its option set, and the owed and observed signs. The driver has no cleanup. | +| ORC-011 | Not provided. No second formulation judges the cache; the stated limit is the bounded grammar above. | +| ORC-013 | Inapplicable. No threshold or range law. | +| ORC-014 | Applicable, limited. The host engine's `Intl` supplies the premise. The oracle runs on Node's ICU, not on Hermes. On Hermes for Android, a 30,000-string sort gave the same order with a cached collator as with `localeCompare`. | +| ORC-015 | Applicable. Declared states: "an earlier option set orders the pair differently" and "an earlier option set with the same locale orders the pair differently". The constructed history keeps at most 10 generated steps between its two added steps, so it stays inside the 12-step grammar. Every built history records its required states; the check fails when the driver's reach observations miss one, and the third calibration shows that failure. Generated reach in one run: 77 and 64 of 100 histories (fixed seed), 73 and 59 of 100 (random). | +| ORC-016 | Applicable. The file registers the property only through `oracleProperty` and adds no unit tests. | + +## Open for maintainers + +- **Option objects changed after use.** The old code read a changed + `localeOptions` or `locale` on the next comparison; the cache keeps the + collator it built. An index built under the old options keeps its stored + order, so a change after use already left indexes inconsistent. The law states this as a + limit and needs a maintainer decision. +- **Runtimes without `Intl.Collator`.** `localeCompare` works without `Intl` in + some engine builds (for example Node built without Intl support). The default + comparison now needs `Intl.Collator`. Explicit locales already need `Intl` + through `Intl.getCanonicalLocales` in index matching. +- **RED.** A performance change under an unchanged law passes on the parent. + The `inArray` constant-list review (`perf-in-constant-set.md`) records the same + shape: mutants and a requirement audit, no parent failure. + +## Performance evidence + +Hermes, Android emulator (x86_64, 4 cores), sorting 30,000 random strings +(about 489,000 comparisons), three runs each: `localeCompare` 11.0–12.3 µs per +comparison, a cached `Intl.Collator` 1.16–1.21 µs, a plain comparison +0.19–0.22 µs. The two collation methods produced the same order. diff --git a/packages/db/src/utils/comparison.ts b/packages/db/src/utils/comparison.ts index e8e2c01b03..8c5633ecc8 100644 --- a/packages/db/src/utils/comparison.ts +++ b/packages/db/src/utils/comparison.ts @@ -19,6 +19,23 @@ export function isUnorderable(value: any): boolean { ) } +type LocaleCompareOptions = Extract + +// `localeCompare(b, locale, options)` compares as a new +// `Intl.Collator(locale, options)` would. Some engines build that collator on +// every call (Hermes on Android builds a Java object each time), which costs +// far more than the comparison. Each option set keeps the collator it built. +const localeCollators = new WeakMap() + +function localeCollator(opts: LocaleCompareOptions): Intl.Collator { + let collator = localeCollators.get(opts) + if (collator === undefined) { + collator = new Intl.Collator(opts.locale, opts.localeOptions) + localeCollators.set(opts, collator) + } + return collator +} + /** * Universal comparison function for all data types * Handles null/undefined, strings, arrays, dates, objects, and primitives @@ -48,7 +65,7 @@ export const ascComparator = (a: any, b: any, opts: CompareOptions): number => { return opts.compare(a, b) } if (opts.stringSort === `locale`) { - return a.localeCompare(b, opts.locale, opts.localeOptions) + return localeCollator(opts).compare(a, b) } // For lexical sort we rely on direct comparison for primitive values } diff --git a/packages/db/tests/comparison-oracle.property.test.ts b/packages/db/tests/comparison-oracle.property.test.ts index 7d17309ccd..a6816e1e0a 100644 --- a/packages/db/tests/comparison-oracle.property.test.ts +++ b/packages/db/tests/comparison-oracle.property.test.ts @@ -10,7 +10,13 @@ import { normalizeValue, } from '../src/utils/comparison' import { DEFAULT_COMPARE_OPTIONS } from '../src/utils' -import { oracleProperty, oracleReplayRequested } from './oracle-config.js' +import { + differs, + oracleProperty, + oracleReplayRequested, + pass, + violation, +} from './oracle-config.js' import { defaultOpts, lexicalOpts } from './comparison-options.js' import { rejection } from './calibration-rejection.js' import { nativeCampaignReceipt } from './native-campaign-receipt' @@ -1005,3 +1011,269 @@ describe(`Temporal default-order refinement`, () => { assertZonedDateTimeOrder, ) }) + +// The locale option sets and strings of the collation history. Each set that +// shares a locale with another differs from it on at least one string pair +// below, so an answer kept from an earlier set can be seen. +const collationSets: ReadonlyArray<{ + locale?: string + localeOptions?: Intl.CollatorOptions +}> = [ + {}, + { locale: `en-US` }, + { locale: `en-US`, localeOptions: { numeric: true } }, + { locale: `en-US`, localeOptions: { sensitivity: `base` } }, + { locale: `en-US`, localeOptions: { caseFirst: `upper` } }, + { locale: `sv` }, + { locale: `de` }, +] +const collationStrings = [ + `item2`, + `item10`, + `a`, + `A`, + `ä`, + `z`, + `resume`, + `résumé`, +] as const + +/** One comparison: an option set, whether it arrives as a fresh copy, and two strings. */ +type CollationStep = { + set: number + copy: boolean + left: number + right: number +} +type CollationHistory = ReadonlyArray + +const arbitraryCollationStep: fc.Arbitrary = fc.record({ + set: fc.nat({ max: collationSets.length - 1 }), + copy: fc.boolean(), + left: fc.nat({ max: collationStrings.length - 1 }), + right: fc.nat({ max: collationStrings.length - 1 }), +}) + +// The model builds a new collator for every comparison, which is the +// definition of String.prototype.localeCompare(that, locales, options). +function collationSign(set: number, left: number, right: number): number { + const { locale, localeOptions } = collationSets[set]! + return Math.sign( + new Intl.Collator(locale, localeOptions).compare( + collationStrings[left]!, + collationStrings[right]!, + ), + ) +} + +const OTHER_SET_DIFFERS = `an earlier option set orders the pair differently` +const SAME_LOCALE_DIFFERS = `an earlier option set with the same locale orders the pair differently` + +function collationReach(history: CollationHistory): Array { + const reached = new Set() + history.forEach((step, index) => { + const owed = collationSign(step.set, step.left, step.right) + for (const earlier of history.slice(0, index)) { + if (earlier.set === step.set) continue + if (collationSign(earlier.set, step.left, step.right) === owed) continue + reached.add(OTHER_SET_DIFFERS) + if ( + collationSets[earlier.set]!.locale === collationSets[step.set]!.locale + ) + reached.add(SAME_LOCALE_DIFFERS) + } + }) + return [...reached] +} + +function localeCompareOptions(set: number): CompareOptions { + const { locale, localeOptions } = collationSets[set]! + return { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + ...(locale !== undefined && { locale }), + ...(localeOptions !== undefined && { localeOptions: { ...localeOptions } }), + } +} + +// Production receives one option object per set for the whole history, or a +// structurally equal copy at the steps marked `copy`. +function driveCollationHistory( + history: CollationHistory, + observe: (checkpoint: string, value: unknown) => void, + compare: (a: string, b: string, opts: CompareOptions) => number, +): void { + const shared = collationSets.map((_, set) => localeCompareOptions(set)) + history.forEach((step, index) => { + const opts = step.copy ? localeCompareOptions(step.set) : shared[step.set]! + observe( + `step ${index}`, + Math.sign( + compare( + collationStrings[step.left]!, + collationStrings[step.right]!, + opts, + ), + ), + ) + }) + for (const state of collationReach(history)) observe(`reach`, state) +} + +// Construction requirements are test-only provenance. The pinned framework +// checks only seeded construction identities, so every generated and shrunk +// construction carries its obligation too, and the check enforces it. +const collationRequirements = new WeakMap< + CollationHistory, + ReadonlyArray +>() + +const NUMERIC_AFTER_PLAIN = `numeric option after the plain set of its locale` +const GENERATED_STEPS = 10 + +function numericAfterPlain(generated: CollationHistory): CollationHistory { + const built = [ + { set: 1, copy: false, left: 0, right: 1 }, + ...generated.slice(0, GENERATED_STEPS), + { set: 2, copy: false, left: 0, right: 1 }, + ] + collationRequirements.set(built, [OTHER_SET_DIFFERS, SAME_LOCALE_DIFFERS]) + return built +} + +function collationModel(history: CollationHistory) { + return { + signs: history.map((step) => + collationSign(step.set, step.left, step.right), + ), + requiredStates: collationRequirements.get(history) ?? [], + } +} + +function checkCollation( + history: CollationHistory, + observations: ReadonlyArray<{ checkpoint: string; value: unknown }>, + owed: ReturnType, +) { + const steps = observations.filter((entry) => entry.checkpoint !== `reach`) + for (const [index, observation] of steps.entries()) { + const verdict = differs( + observation.checkpoint, + `comparison.locale-option-history`, + observation.value, + owed.signs[index], + `step ${index} compared under option set ${history[index]!.set}`, + ) + if (verdict) return verdict + } + const reached = new Set( + observations + .filter((entry) => entry.checkpoint === `reach`) + .map((entry) => entry.value), + ) + const missing = owed.requiredStates.filter((state) => !reached.has(state)) + return missing.length === 0 + ? pass + : violation( + `constructed states`, + `constructed history did not enter ${missing.join(`, `)}`, + `constructed reach`, + ) +} + +// A wrong comparison that keeps the collator of the first option set it sees +// for a key, as a cache with a missing key part would. +function collatorKeptBy( + key: (opts: CompareOptions) => string, +): (a: string, b: string, opts: CompareOptions) => number { + const collators = new Map() + return (a, b, opts) => { + const localeOpts = opts as CompareOptions & { + locale?: string + localeOptions?: Intl.CollatorOptions + } + let collator = collators.get(key(opts)) + if (collator === undefined) { + collator = new Intl.Collator(localeOpts.locale, localeOpts.localeOptions) + collators.set(key(opts), collator) + } + return collator.compare(a, b) + } +} + +describe(`locale collation over a history of option sets`, () => { + oracleProperty({ + id: `comparison.locale-option-history`, + law: { + statement: `Under locale string collation, every comparison orders two strings as a new Intl.Collator(locale, localeOptions) of its own option set does, whichever option sets earlier comparisons used and whether the option object is shared or a copy.`, + authority: `${comparisonAuthority}; ECMA-402 defines String.prototype.localeCompare(that, locales, options) as comparison by a new Intl.Collator(locales, options).`, + limits: `Ascending comparisons of eight fixed strings under seven option sets (the default locale, en-US with no options, numeric, base sensitivity and upper case first, sv and de) through ascComparator. Option objects are not changed after use. Direction, null placement and non-string values belong to the other comparison laws.`, + }, + owns: [`src/utils/comparison.ts`], + histories: { + arbitrary: fc.array(arbitraryCollationStep, { + minLength: 1, + maxLength: GENERATED_STEPS + 2, + }), + states: [OTHER_SET_DIFFERS, SAME_LOCALE_DIFFERS], + constructed: [ + { + name: NUMERIC_AFTER_PLAIN, + enters: [OTHER_SET_DIFFERS, SAME_LOCALE_DIFFERS], + build: numericAfterPlain, + }, + ], + runs: campaignRuns, + fixedSeeds: [fixedSeed], + }, + model: collationModel, + driver: (history, observe) => + driveCollationHistory(history, observe, ascComparator), + check: checkCollation, + calibrations: [ + { + name: `rejects a construction that omits a required state`, + law: `constructed reach`, + history: { constructed: NUMERIC_AFTER_PLAIN }, + corrupt: (trace, history) => { + const state = collationModel(history).requiredStates[0] + return state === undefined + ? undefined + : trace.filter( + (entry) => + entry.checkpoint !== `reach` || entry.value !== state, + ) + }, + }, + { + name: `rejects one collator kept for every option set`, + law: `comparison.locale-option-history`, + history: { + constructed: NUMERIC_AFTER_PLAIN, + }, + driver: (history, observe) => + driveCollationHistory( + history, + observe, + collatorKeptBy(() => `all`), + ), + }, + { + name: `rejects a collator kept per locale that ignores its options`, + law: `comparison.locale-option-history`, + history: { + constructed: NUMERIC_AFTER_PLAIN, + }, + driver: (history, observe) => + driveCollationHistory( + history, + observe, + collatorKeptBy( + (opts) => (opts as { locale?: string }).locale ?? `default`, + ), + ), + }, + ], + }) +}) diff --git a/packages/db/tests/property-registry/db-core.ts b/packages/db/tests/property-registry/db-core.ts index 39fc82f6fd..df3b6a04ec 100644 --- a/packages/db/tests/property-registry/db-core.ts +++ b/packages/db/tests/property-registry/db-core.ts @@ -35,6 +35,7 @@ export const properties: ReadonlyArray = [ `btree-map.infinite-comparator`, `btree-map.long-campaign`, `btree-map.dense-cuts`, + `comparison.locale-option-history`, `comparison.non-finite-witnesses`, `comparison.signed-infinity-custom`, `comparison.temporal-witnesses`, @@ -145,7 +146,7 @@ export const owners: ReadonlyArray = [ [ `db/tests/comparison-oracle.property.test.ts`, `comparison`, - `non-finite-witnesses signed-infinity-custom temporal-witnesses`, + `locale-option-history non-finite-witnesses signed-infinity-custom temporal-witnesses`, ], [ `db/tests/index-update-oracle.property.test.ts`,