perf(i18n): build the every-locale match indexes on first use

Seven matchers built their index over every shipped catalog at module
load (the GLP-1 tag map, the nutrient and metric names of the MCP reads,
the SNOMED and LOINC lab names, the Coach's health words, the lab-name
pattern), so loading the worker graph read all eight catalogs at boot.
Each index is now built the first time it is asked for.

The boot profile records the change: the longest busy stretch of the
main thread at boot fell from 753 ms to 241 ms, the live heap after five
idle minutes from 164 to 152 MB, and the catalog chunk sources (two of
9.7 MB) are gone.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 07:01:03 +02:00
1 parent a859adb59c
commit b5f5a5a580
10 files changed
+2033 -30

No files matched your search

File diff suppressed because it is too large. Load diff
+298
View File
@@ -0,0 +1,298 @@
# Boot profile v1.45.0-boot-after
Commit a859adb59, 2026-10-22T04:49:50.419Z. Production build, every role, heap ceiling 792 MB, 300 s idle. 14 cores, load average 8.6,9.4,10.7 at the end.
- first answer from /api/health: 850 ms; background role ready: 3038 ms
- event-loop stalls logged: 0, max 0 ms
## Stalls
## Heap samples
| at s | used MB | old | large obj | code | external | array buffers | rss |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 6 | 166 | 91 | 53 | 3 | 12 | 1 | 430 |
| 306 | 153 | 90 | 53 | 4 | 11 | 0 | 599 |
## Full collections (mark-compact)
10 mark-compacts during the run. Used heap before the collection, and what survived it (the live heap):
| at s | before MB | after MB |
| ---: | ---: | ---: |
| 0 | 20.2 | 17.1 |
| 0 | 55.3 | 52.6 |
| 0 | 83.2 | 81.1 |
| 1 | 148.4 | 132.7 |
| 16 | 159.6 | 153.2 |
| 17 | 153.4 | 151 |
| 62 | 167.5 | 157.5 |
| 246 | 183.4 | 152.6 |
| 303 | 165.6 | 151.8 |
| 303 | 151.8 | 151.8 |
## Main-thread busy stretches over 50 ms without an idle sample, first two minutes
### at 6 ms, 175 ms
| self time by module | ms |
| --- | ---: |
| npm:next | 47.2 |
| node:internal/modules/cjs/loader | 34.8 |
| (native) | 34.5 |
| node:internal/bootstrap/realm | 25.7 |
| node:internal/deps/undici/undici | 8.8 |
| node:path | 6.3 |
| node:internal/modules/package_json_reader | 3 |
| node:internal/net | 3 |
| node:internal/modules/esm/resolve | 2.5 |
| node:fs | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
### at 203 ms, 241 ms
| self time by module | ms |
| --- | ---: |
| (native) | 52.7 |
| node:internal/modules/cjs/loader | 40.1 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 31.7 |
| wasm://wasm/00d1cb12 | 28.7 |
| npm:@prisma/client | 28 |
| npm:next | 16.3 |
| node:internal/modules/esm/utils | 12 |
| node:fs | 4.5 |
| npm:zod | 3 |
| node:internal/modules/helpers | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/instrumentation.ts | 80.8 |
| src/lib/db-session.ts | 49.5 |
| src/lib/boot/startup-state.ts | 25.6 |
| src/lib/boot/boot-steps.ts | 25.6 |
| src/lib/logging/background.ts | 25.6 |
| src/lib/process-type.ts | 25.5 |
| src/lib/logging/context.ts | 13.6 |
| src/lib/validations/measurement.ts | 12.1 |
| src/lib/logging/redact.ts | 10.5 |
| src/lib/validations/entry-instant.ts | 4.6 |
### at 501 ms, 178 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 53.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 37.5 |
| npm:next | 18 |
| npm:zod | 12 |
| node:internal/modules/esm/utils | 10.5 |
| (native) | 8.7 |
| node:fs | 6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 4.5 |
| npm:undici | 4.5 |
| node:internal/url | 1.8 |
| inclusive, application modules | ms |
| --- | ---: |
| src/app/api/health/route.ts | 61.4 |
| src/lib/api-errors.ts | 27 |
| src/lib/jobs/job-failures.ts | 14.9 |
| src/lib/jobs/offhost-backup.ts | 14.9 |
| src/lib/managed-profiles/handover-access.ts | 14.9 |
| src/lib/sharing/acting-session.ts | 14.9 |
| src/lib/db-session.ts | 13.4 |
| src/lib/export/environment-backup.ts | 13.4 |
| src/lib/export/full-backup-payload.ts | 13.4 |
| src/lib/logging/background.ts | 10.5 |
### at 833 ms, 184 ms
| self time by module | ms |
| --- | ---: |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 38.9 |
| node:internal/modules/cjs/loader | 28.6 |
| (native) | 22.8 |
| src/lib/i18n/server-catalogs.ts | 15.1 |
| npm:yaml | 15 |
| node:fs | 12.1 |
| npm:zod | 9 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.9 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 4.5 |
| npm:semver | 3 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 129.1 |
| src/lib/jobs/reminder/google-health-sync.ts | 88.5 |
| src/lib/whoop/sync.ts | 79.5 |
| src/lib/whoop/sync-body.ts | 63 |
| src/lib/insights/bucket-series.ts | 60 |
| src/lib/whoop/oauth-descriptor.ts | 60 |
| src/lib/whoop/sync-core.ts | 60 |
| src/instrumentation.ts | 43.4 |
| src/lib/import/unzip-export-xml.ts | 31.4 |
| src/lib/jobs/integration-status-null-probe.ts | 31.4 |
### at 1099 ms, 54 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 23.9 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 10.6 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 5.8 |
| src/lib/daily/today-overview.ts | 4.5 |
| src/lib/jobs/reminder-worker.ts | 3 |
| node:fs | 3 |
| npm:pg-boss | 1.5 |
| npm:zod | 1.5 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 52.3 |
| src/lib/jobs/reminder/insights-handlers.ts | 22.3 |
| src/lib/ai/prompts/arrival-reaction.ts | 12 |
| src/lib/daily/score-steady.ts | 10.5 |
| src/lib/dashboard/bands.ts | 6 |
| src/lib/jobs/data-arrival.ts | 4.5 |
| src/lib/dashboard/snapshot-read.ts | 4.5 |
| src/lib/daily/today-overview.ts | 4.5 |
| src/lib/daily/priority-item-key.ts | 4.5 |
| src/lib/ai/prompts/status-batch.ts | 3 |
### at 1309 ms, 51 ms
| self time by module | ms |
| --- | ---: |
| node:internal/modules/cjs/loader | 28 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 6 |
| node:fs | 4.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 3 |
| turbopack:///[turbopack]/nodejs/runtime/runtime-base.ts | 1.5 |
| src/lib/jobs/failure-condition.ts | 1.5 |
| src/lib/labs/parse-reference-range.ts | 1.5 |
| npm:zod | 1.5 |
| node:internal/linkedlist | 1.5 |
| (native) | 0.9 |
| inclusive, application modules | ms |
| --- | ---: |
| src/lib/jobs/reminder-worker.ts | 48.2 |
| src/lib/jobs/reminder/ops-handlers.ts | 14.1 |
| src/lib/medications/ingredients/label-extract.ts | 3 |
| src/lib/export/restore-backup.ts | 2.1 |
| src/lib/jobs/failure-condition.ts | 1.5 |
| src/lib/documents/ai-runs/summary-run.ts | 1.5 |
| src/lib/documents/auto-stage-labs.ts | 1.5 |
| src/lib/documents/ai-runs/store.ts | 1.5 |
| src/lib/labs/parse-reference-range.ts | 1.5 |
| src/lib/labs/ocr-upload.ts | 1.5 |
## Self time by module until the worker is ready
| module | ms |
| --- | ---: |
| (native) | 230.8 |
| node:internal/modules/cjs/loader | 223.6 |
| turbopack:///[turbopack]/nodejs/runtime/build-base.ts | 124.7 |
| npm:next | 98.8 |
| wasm://wasm/00d1cb12 | 62.6 |
| npm:@prisma/client | 56.2 |
| node:fs | 34.6 |
| node:internal/bootstrap/realm | 28.7 |
| npm:pg | 28.3 |
| npm:zod | 27 |
| npm:pg-boss | 24.6 |
| node:internal/modules/esm/utils | 22.5 |
| turbopack:///[turbopack]/shared/runtime/async-module.ts | 20.6 |
| npm:luxon | 17 |
| src/lib/i18n/server-catalogs.ts | 15.1 |
| npm:yaml | 15 |
| npm:pg-pool | 13 |
| node:internal/deps/undici/undici | 10.4 |
| node:path | 7.8 |
| turbopack:///[turbopack]/shared/runtime/runtime-utils.ts | 7.5 |
| node:internal/async_hooks | 7.5 |
| node:internal/process/task_queues | 7.3 |
| node:internal/modules/package_json_reader | 6 |
| npm:undici | 6 |
| npm:pg-protocol | 6 |
## Heap snapshot after 300 s idle (live heap after a full GC)
160.1 MB in 1275329 nodes; module source strings over 50 KB: 39.8 MB.
- parsed locale catalogs: 8 objects, 7 MB retained
| node type | MB |
| --- | ---: |
| string | 68.7 |
| array | 26.4 |
| code | 21.1 |
| object | 14.1 |
| hidden | 8.9 |
| native | 8 |
| object shape | 5.9 |
| closure | 5.2 |
| concatenated string | 1.3 |
| regexp | 0.2 |
| sliced string | 0.2 |
| number | 0.1 |
| synthetic | 0 |
| symbol | 0 |
| bigint | 0 |
| constructor | MB | count |
| --- | ---: | ---: |
| array:(object elements) | 20.4 | 23797 |
| object:Object | 10.2 | 274231 |
| native:system / JSArrayBufferData | 7.6 | 171 |
| array:(object properties) | 3.6 | 6512 |
| array: | 2.4 | 10502 |
| closure: | 1.6 | 29042 |
| object:system / Context | 1.6 | 28873 |
| closure:native_bind | 1 | 20782 |
| object:Array | 0.6 | 19679 |
| object:a | 0.4 | 4438 |
| native:Node / histogram | 0.4 | 1 |
| closure:get | 0.3 | 4736 |
| object:d | 0.3 | 4425 |
| object:Set | 0.2 | 5673 |
| closure:set | 0.2 | 3432 |
| object:Module | 0.2 | 1958 |
| closure:i | 0.1 | 1581 |
| closure:n | 0.1 | 1572 |
| object:Promise | 0.1 | 1277 |
| object:n | 0.1 | 1881 |
| largest retained subtrees | MB | retaining path |
| --- | ---: | --- |
| object:t | 33.9 | [1] > [10] > 60 > extension > global > prisma > [1] |
| object:global | 7.9 | [1] > [10] > 60 > extension |
| object:system / Context | 7.4 | Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [25] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4] > context |
| object:Module | 5.6 | [1] > [10] > 97 > process > nextTick > patchedNextTick > context > system / Context > require > require > cache > .n |
| code:.next/server/chu | 5.6 | require > cache > Object > .next/server/chunks/_1kwfnjg._.js > Module > exports > Array > [1] > > shared > script |
| code:.next/server/chu | 5.6 | global > prisma > [1] > t > _engineConfig > Object > compilerWasm > Object > getRuntime > getRuntime > shared > script |
| object:Generator | 4.4 | [6] > principal_realm > Node / PrincipalRealm > base_object_list > Node / BaseObjectList > [26] > Node / ModuleWrap > native_to_javascript > ModuleWrap > 2 > [4 |
| string:AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/ | 4.4 | [1] > [21] > 62203 |
| object:Object | 3.3 | system / Context > require > require > cache > Object > .next/server/chunks/[turbopack]_runtime.js > Module > export |
| largest strings | MB |
| --- | ---: |
| module.exports=[234494,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=> | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| module.exports=[17856,A=>{"use strict";var e,C,g,I,Q=Object.defineProperty,B={},s={$:()=>i | 5.6 |
| const wasm = "AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGAB | 4.4 |
| AGFzbQEAAAABtgRLYAN/f38Bf2ACf38Bf2ACf38AYAF/AGADf39/AGAEf39/fwBgBX9/f39/AGABfwF/YAZ/f39/f3 | 4.4 |
| lliADOAUngIEAACgKwAgBgAAoSsAIAcAAKErACAIAACiKwAgCQAAoysAIAoAAKQrACALAAClKwAgDAAApisAIA0AAK | 1.9 |
| (()=>{var e,t,r={"./dist/build/webpack/alias/react-dom-server.js"(e,t,r){"use strict";var | 1.3 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[842865,(e,t,r)=>{let i=e.r(522734);t.exports=function(e){return e?/-----BE | 1.2 |
| module.exports=[799092,(e,t,n)=>{"use strict";Object.defineProperty(n,"__esModule",{value: | 1.1 |
| // v1.4.34 web-freeze. Schema is locked additive-only until iOS launch // + v1.5.0 marker. | 1.1 |
| module.exports=[971743,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1.1 |
| module.exports=[835717,(A,e,t)=>{"use strict";let r=A.r(57328),{addAbortListener:s}=A.r(68 | 1 |
| module.exports=[767189,e=>e.a(async(t,a)=>{try{var n=e.i(64105),r=e.i(563616),i=t([n]);[n] | 0.8 |
| module.exports=[594108,806176,674750,98746,t=>{"use strict";var i;let r;function n(t){retu | 0.7 |
+13 -3
View File
@@ -388,7 +388,17 @@ function buildDerivedHealthTokens(): ReadonlySet<string> {
return tokens;
}
const DERIVED_HEALTH_TOKENS = buildDerivedHealthTokens();
let memoDerivedHealthTokensCache:
ReturnType<typeof memoDerivedHealthTokensBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function memoDerivedHealthTokens(): ReturnType<
typeof memoDerivedHealthTokensBuild
> {
return (memoDerivedHealthTokensCache ??= memoDerivedHealthTokensBuild());
}
function memoDerivedHealthTokensBuild() {
return buildDerivedHealthTokens();
}
/**
* Test seam: the guard suite asserts the derived set is non-empty and that
@@ -396,13 +406,13 @@ const DERIVED_HEALTH_TOKENS = buildDerivedHealthTokens();
* metric labels fails the suite rather than quietly narrowing the allow-list.
*/
export function derivedHealthTokens(): ReadonlySet<string> {
return DERIVED_HEALTH_TOKENS;
return memoDerivedHealthTokens();
}
/** True when any whole word of the message is a health noun from the bundles. */
function matchesDerivedHealthToken(message: string): boolean {
for (const word of healthWordTokens(message)) {
if (DERIVED_HEALTH_TOKENS.has(word)) return true;
if (memoDerivedHealthTokens().has(word)) return true;
}
return false;
}
+11 -3
View File
@@ -152,11 +152,19 @@ function buildAnalyteNamePattern(): RegExp {
);
}
const DERIVED_ANALYTE_NAMES = buildAnalyteNamePattern();
let derivedAnalyteNamesCache:
ReturnType<typeof derivedAnalyteNamesBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function derivedAnalyteNames(): ReturnType<typeof derivedAnalyteNamesBuild> {
return (derivedAnalyteNamesCache ??= derivedAnalyteNamesBuild());
}
function derivedAnalyteNamesBuild() {
return buildAnalyteNamePattern();
}
/** Test seam: the guard suite pins that the derived pattern covers every locale. */
export function derivedAnalyteNamePattern(): RegExp {
return DERIVED_ANALYTE_NAMES;
return derivedAnalyteNames();
}
/**
@@ -175,7 +183,7 @@ export function looksLikeLabDocument(text: string): boolean {
for (const signal of LAB_SIGNALS) {
const hit =
signal.test(folded) ||
(signal === ANALYTE_SIGNAL && DERIVED_ANALYTE_NAMES.test(folded));
(signal === ANALYTE_SIGNAL && derivedAnalyteNames().test(folded));
if (hit && ++hits >= 2) return true;
}
return false;
+13 -3
View File
@@ -355,14 +355,24 @@ function buildLocalisedAliases(): Record<string, string> {
return index;
}
const LOCALISED_LAB_ALIASES = buildLocalisedAliases();
let memoLocalisedLabAliasesCache:
ReturnType<typeof memoLocalisedLabAliasesBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function memoLocalisedLabAliases(): ReturnType<
typeof memoLocalisedLabAliasesBuild
> {
return (memoLocalisedLabAliasesCache ??= memoLocalisedLabAliasesBuild());
}
function memoLocalisedLabAliasesBuild() {
return buildLocalisedAliases();
}
/**
* Test seam: the guard suite asserts the derived index covers every shipped
* locale and contradicts no hand-written alias. Production reads the constant.
*/
export function localisedLabAliases(): Readonly<Record<string, string>> {
return LOCALISED_LAB_ALIASES;
return memoLocalisedLabAliases();
}
/**
@@ -462,7 +472,7 @@ export function resolveLabCoding(
// every resolution that worked before working the same way.
const resolvedKey = LAB_LOINC_BY_KEY[key]
? key
: (LAB_ALIASES[key] ?? LOCALISED_LAB_ALIASES[key]);
: (LAB_ALIASES[key] ?? memoLocalisedLabAliases()[key]);
if (!resolvedKey) return null;
const mapping = LAB_LOINC_BY_KEY[resolvedKey];
if (!mapping) return null;
+10 -3
View File
@@ -110,7 +110,14 @@ function buildLocalisedIndex(): Record<string, QualitativeConcept> {
return index;
}
const LOCALISED_SNOMED = buildLocalisedIndex();
let localisedSnomedCache: ReturnType<typeof localisedSnomedBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function localisedSnomed(): ReturnType<typeof localisedSnomedBuild> {
return (localisedSnomedCache ??= localisedSnomedBuild());
}
function localisedSnomedBuild() {
return buildLocalisedIndex();
}
/**
* Test seam: the guard suite asserts the derived index covers every shipped
@@ -120,7 +127,7 @@ const LOCALISED_SNOMED = buildLocalisedIndex();
export function localisedQualitativeIndex(): Readonly<
Record<string, QualitativeConcept>
> {
return LOCALISED_SNOMED;
return localisedSnomed();
}
/**
@@ -144,7 +151,7 @@ export function qualitativeValueConcept(
valueText: string,
): FhirCodeableConcept {
const key = normaliseQualitativeKey(valueText);
const concept = HAND_WRITTEN_SNOMED[key] ?? LOCALISED_SNOMED[key];
const concept = HAND_WRITTEN_SNOMED[key] ?? localisedSnomed()[key];
if (!concept) {
return { text: valueText };
}
+2 -2
View File
@@ -42,7 +42,7 @@ import {
getLabHistory,
resolveRichMetric,
MCP_METRIC_STATUS_DISCOVERY,
LOCALISED_METRIC_NAME_COLLISIONS,
localisedMetricNameCollisions,
metricStatusDiscoveryRows,
} from "../rich-reads";
import { locales } from "@/lib/i18n/config";
@@ -292,7 +292,7 @@ describe("resolveRichMetric — localised metric names", () => {
// the set is empty and pinned empty: a NEW collision fails here instead of
// silently resolving to whichever entry happened to be built first.
it("refuses a word two different metrics claim, and only those words", () => {
expect([...LOCALISED_METRIC_NAME_COLLISIONS].sort()).toEqual([]);
expect([...localisedMetricNameCollisions()].sort()).toEqual([]);
});
// The property, not a sample: over every metric and every shipped locale,
+16 -8
View File
@@ -85,13 +85,21 @@ export const NUTRIENT_LABELS: Readonly<Record<NutrientCode, string>> =
* `locales`, so a seventh language starts resolving the moment its bundle
* lands, with no edit here.
*/
const NUTRIENT_NAME_INDEX = buildLocalisedLabelIndex<NutrientCode>(
NUTRIENT_CODES.map((code) => ({
id: code,
messageKey: nutrientLabelKey(code),
value: code,
})),
);
let nutrientNameIndexCache:
ReturnType<typeof nutrientNameIndexBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function nutrientNameIndex(): ReturnType<typeof nutrientNameIndexBuild> {
return (nutrientNameIndexCache ??= nutrientNameIndexBuild());
}
function nutrientNameIndexBuild() {
return buildLocalisedLabelIndex<NutrientCode>(
NUTRIENT_CODES.map((code) => ({
id: code,
messageKey: nutrientLabelKey(code),
value: code,
})),
);
}
/** Overview mode (no `nutrient` arg) mirrors `GET /api/nutrients`'s bounds. */
const OVERVIEW_DEFAULT_DAYS = 14;
@@ -148,7 +156,7 @@ export function resolveNutrientCode(input: string): NutrientCode | null {
if (!raw) return null;
const key = raw.toLowerCase().replace(/[\s-]+/g, "_");
if (isNutrientCode(key)) return key;
return NUTRIENT_NAME_INDEX.index.get(foldLabel(raw)) ?? null;
return nutrientNameIndex().index.get(foldLabel(raw)) ?? null;
}
/**
+12 -5
View File
@@ -560,7 +560,13 @@ export async function metricStatusDiscoveryRows(userId: string): Promise<
//
// The alias tables stay as they are and stay English: `resting_hr`, `spo2`,
// `vo2max` are protocol slugs, not translations of anything.
const LOCALISED_METRIC_INDEX = (() => {
let localisedMetricIndexCache:
ReturnType<typeof buildLocalisedMetricIndex> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function localisedMetricIndex(): ReturnType<typeof buildLocalisedMetricIndex> {
return (localisedMetricIndexCache ??= buildLocalisedMetricIndex());
}
function buildLocalisedMetricIndex() {
const exposed = new Map<MeasurementType, RichMetric>();
const claim = (metric: RichMetric | null) => {
if (metric && !exposed.has(metric.measurementType)) {
@@ -579,7 +585,7 @@ const LOCALISED_METRIC_INDEX = (() => {
value: exposed.get(type as MeasurementType) ?? null,
}));
return buildLocalisedLabelIndex(entries);
})();
}
/**
* Words two different measurement types claim in some bundle, and which
@@ -587,8 +593,9 @@ const LOCALISED_METRIC_INDEX = (() => {
* is empty: a NEW member is a regression to look at rather than something to
* resolve by coin flip.
*/
export const LOCALISED_METRIC_NAME_COLLISIONS: ReadonlySet<string> =
LOCALISED_METRIC_INDEX.ambiguous;
export function localisedMetricNameCollisions(): ReadonlySet<string> {
return localisedMetricIndex().ambiguous;
}
/**
* Resolve a free-text metric name to a single scalar series. Forgiving for an
@@ -632,7 +639,7 @@ export function resolveRichMetric(input: string): RichMetric | null {
// on PULSE_WAVE_VELOCITY, because "pulse-wave velocity" contains "puls".
// A value-less hit (a metric this surface does not expose) falls through
// rather than short-circuiting, so the English behaviour below is untouched.
const localised = LOCALISED_METRIC_INDEX.index.get(foldLabel(raw));
const localised = localisedMetricIndex().index.get(foldLabel(raw));
if (localised) return localised;
// 5. English display-name substring match (e.g. "heart-rate variability").
@@ -64,7 +64,14 @@ function buildIndex(): Map<string, Glp1SideEffectTag> {
return index;
}
const TAG_INDEX = buildIndex();
let tagIndexCache: ReturnType<typeof tagIndexBuild> | undefined;
/** Built on first use, not at module load: it reads every shipped catalog. */
function tagIndex(): ReturnType<typeof tagIndexBuild> {
return (tagIndexCache ??= tagIndexBuild());
}
function tagIndexBuild() {
return buildIndex();
}
/**
* Test seam: the guard suite asserts the index covers every locale and that no
@@ -74,7 +81,7 @@ export function glp1SideEffectTagIndex(): ReadonlyMap<
string,
Glp1SideEffectTag
> {
return TAG_INDEX;
return tagIndex();
}
/**
@@ -89,7 +96,7 @@ export function glp1SideEffectTagIndex(): ReadonlyMap<
export function resolveGlp1SideEffectTag(
raw: string,
): Glp1SideEffectTag | null {
return TAG_INDEX.get(normaliseSideEffectTag(raw)) ?? null;
return tagIndex().get(normaliseSideEffectTag(raw)) ?? null;
}
/** What one `MoodEntry.tags` column contributed. */