diff --git a/packages/core/mcp/engine-server.mjs b/packages/core/mcp/engine-server.mjs index 45a4f5a950..0943eb1787 100755 --- a/packages/core/mcp/engine-server.mjs +++ b/packages/core/mcp/engine-server.mjs @@ -704,8 +704,11 @@ function suffixStripped(token) { return token; } var STOP_WORD_STEMS = new Set([...STOP_WORDS].map(stem)); +function splitSymbolCase(value) { + return value.replace(/([a-z0-9])([A-Z])/gu, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/gu, "$1 $2"); +} function capabilitySituationTokens(value) { - return value.toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length > 1 && !STOP_WORDS.has(token)).map(stem).filter((token) => !STOP_WORD_STEMS.has(token)); + return splitSymbolCase(value).toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((token) => token.length > 1 && !STOP_WORDS.has(token)).map(stem).filter((token) => !STOP_WORD_STEMS.has(token)); } var tokens = capabilitySituationTokens; function tokenWeights(entries) { @@ -725,6 +728,36 @@ function tokenWeights(entries) { function weightOf(weights, token) { return weights.get(token) ?? 0; } +function nameWords(value) { + return tokens(value.replace(/([a-zA-Z])(\d+)/gu, "$1 $2")).filter((token) => !/\d/u.test(token)); +} +function normaliseName(value) { + return value.toLocaleLowerCase().replace(/[^a-z0-9]+/gu, ""); +} +function spelledWords(value) { + return value.toLocaleLowerCase().split(/[^a-z0-9]+/u).filter((word) => word.length > 0); +} +function spellsName(situation, symbol) { + const words = spelledWords(situation); + const whole = normaliseName(symbol); + for (let start = 0; start < words.length; start += 1) { + let piece = ""; + for (let end = start; end < words.length && end < start + 4; end += 1) { + piece += normaliseName(words[end] ?? ""); + if (piece === whole) return true; + if (piece.length >= whole.length) break; + } + } + return false; +} +function nameTokenWeights(entries) { + return tokenWeights( + entries.map((entry) => ({ + aliases: [nameWords(entry.symbol).join(" ")], + situations: [entry.summary] + })) + ); +} var DISTINCTIVE_SITUATION_SHARE = 0.02; var DISTINCTIVE_FLOOR = Math.log(1 + 1 / DISTINCTIVE_SITUATION_SHARE); var AGREEMENT_WEIGHT = 0.4; @@ -785,6 +818,62 @@ function situationScore(query, situations, aliases, weights) { const matchedSituation = readable.matchedSituation.length > 0 ? readable.matchedSituation : best >= RELEVANCE_FLOOR ? readable.bestReadableSituation : ""; return matchedSituation.length > 0 ? { matchedSituation, score: best } : { matchedSituation: "", score: 0 }; } +var EXACT_SYMBOL_LEAD = 4; +var NAME_BASE_LEAD = 2; +var NAME_SHARE_LEAD = 1.4; +var SYMBOL_WEIGHT_FLOOR = 4.2; +var SINGLE_WORD_SHARE_FLOOR = 0.3; +function scoreSymbolName(symbol, situation, queryTokens, weights) { + const name = [...new Set(nameWords(symbol))]; + if (name.length === 0) return void 0; + const nameWeights = name.map((token) => weightOf(weights, token)); + const specificity = nameWeights.reduce((sum, weight) => sum + weight, 0); + const requestTotal = [...queryTokens].reduce((sum, token) => sum + weightOf(weights, token), 0); + const share = requestTotal === 0 ? 0 : specificity / requestTotal; + const nameScore = NAME_BASE_LEAD + NAME_SHARE_LEAD * Math.min(1, share); + if (spellsName(situation, symbol)) { + if (normaliseName(situation) === normaliseName(symbol)) { + return { score: EXACT_SYMBOL_LEAD, specificity }; + } + if (name.length === 1) { + if (weightOf(weights, name[0] ?? "") < SYMBOL_WEIGHT_FLOOR) return void 0; + if (share < SINGLE_WORD_SHARE_FLOOR) return void 0; + return { score: (NAME_BASE_LEAD + NAME_SHARE_LEAD) * Math.min(1, share), specificity }; + } + return { score: nameScore, specificity }; + } + if (name.length < 2) return void 0; + const queryWords = new Set(nameWords(situation)); + if (!name.every((token) => queryWords.has(token))) return void 0; + if (Math.max(...nameWeights) < SYMBOL_WEIGHT_FLOOR) return void 0; + return { score: nameScore, specificity }; +} +var SUMMARY_WEIGHT = 0.4; +function summaryScore(summary, queryTokens, queryText, weights) { + const candidate = scorePhrase(summary, queryTokens, queryText, weights); + return candidate === void 0 ? 0 : candidate.score * SUMMARY_WEIGHT; +} +function capabilityScore(query, entry, weights, nameWeights, situation) { + const situations = { + ...situationScore(query, entry.situations, entry.aliases, weights), + specificity: 0 + }; + if (query.length === 0) return situations; + const queryTokens = new Set(query); + const symbol = scoreSymbolName(entry.symbol, situation, queryTokens, nameWeights); + const summary = summaryScore(entry.summary, queryTokens, query.join(" "), nameWeights); + const symbolScore = symbol?.score ?? 0; + const best = Math.max(situations.score, symbolScore, summary); + if (best <= situations.score) return situations; + if (symbol !== void 0 && symbolScore >= best) { + return { matchedSituation: entry.symbol, score: symbolScore, specificity: symbol.specificity }; + } + return { + matchedSituation: situations.matchedSituation || entry.situations[0] || entry.summary, + score: best, + specificity: 0 + }; +} function notOwnedSituationScore(query, situations) { if (query.length === 0) return { matchedSituation: "", score: 0 }; const queryText = query.join(" "); @@ -885,6 +974,7 @@ function searchMechanics(situation, manifest, scope) { const query = tokens(situation); const limit = scope === "request" ? MAX_COMPLETE_REQUEST_RESULTS : MAX_SITUATION_RESULTS; const weights = tokenWeights(manifest.entries); + const nameWeights = nameTokenWeights(manifest.entries); const notOwned = notOwnedMatch(manifest, query); if (notOwned !== void 0 && isSpecificNotOwnedMatch(query, notOwned.matchedSituation)) { return { @@ -893,10 +983,10 @@ function searchMechanics(situation, manifest, scope) { verdict: "none" }; } - const results = manifest.entries.map((entry) => ({ entry, ...situationScore(query, entry.situations, entry.aliases, weights) })).filter( + const results = manifest.entries.map((entry) => ({ entry, ...capabilityScore(query, entry, weights, nameWeights, situation) })).filter( ({ matchedSituation, score }) => matchedSituation.length > 0 && score >= RELEVANCE_FLOOR ).sort( - (left, right) => right.score - left.score || `${left.entry.importPath}:${left.entry.symbol}`.localeCompare( + (left, right) => right.score - left.score || right.specificity - left.specificity || `${left.entry.importPath}:${left.entry.symbol}`.localeCompare( `${right.entry.importPath}:${right.entry.symbol}` ) ).filter( @@ -905,11 +995,14 @@ function searchMechanics(situation, manifest, scope) { ) === index ).slice(0, limit).map(({ entry, matchedSituation, score }) => ({ constraints: entry.constraints, + ...entry.deprecated === void 0 ? {} : { deprecated: entry.deprecated }, example: entry.example, importPath: entry.importPath, matchedSituation, + ...entry.overrides === void 0 ? {} : { overrides: entry.overrides }, ...entry.requires === void 0 ? {} : { requires: entry.requires }, score, + signature: entry.signature, summary: entry.summary, symbol: entry.symbol })); @@ -944,7 +1037,7 @@ var TOOL_DEFINITIONS = [ { annotations: { destructiveHint: false, openWorldHint: false, readOnlyHint: true }, name: "engine_search_capabilities", - description: 'Search the installed engine by concrete gameplay mechanic. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation and score explain every result. The response verdict "none" is an actionable answer with guidance, not a failed search.', + description: 'Search the installed engine by concrete gameplay mechanic or by an exact exported symbol name. Decompose genres first. Use scope "request" for the mechanically explicit full request and "mechanic" for each focused search; matchedSituation and score explain every result. A row carries the answer (importPath, signature, example, constraints, overrides, deprecations), so call engine_capability_detail only for a symbol the search did not return. The response verdict "none" is an actionable answer with guidance, not a failed search.', inputSchema: { additionalProperties: false, properties: { @@ -1073,7 +1166,7 @@ function writeLine(value) { `); } function toolText(value) { - return { content: [{ text: JSON.stringify(value, null, 2), type: "text" }] }; + return { content: [{ text: JSON.stringify(value), type: "text" }] }; } async function searchReferences(situation, limit = 5) { const local = loadLocalReferenceGames(); @@ -1293,7 +1386,12 @@ async function runCli(argv, manifestFile = defaultManifestPath(), io = { { arguments: JSON.parse(json), name }, manifestFile ); - io.stdout(`${result.content[0].text} + let text2 = result.content[0].text; + try { + text2 = JSON.stringify(JSON.parse(text2), null, 2); + } catch { + } + io.stdout(`${text2} `); return 0; } catch (error) { diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 58f3cac3f4..8586dabf35 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -116,67 +116,67 @@ const PACK_GENERATED_PAGES: readonly string[] = ["capability-reference.md"]; const SCAFFOLD_TREE_HASHES: Readonly> = { // scaffold-pin:begin "action-rpg": { - shared: "618fc0da29be00df175c654b9e0239971fff904ca90d15316ac833cf4d3360b8", + shared: "e771f2032c1121b03e5213aea9d17e13a979d5466925c0acfc771b8afe577c60", kit: "a8d70b273cef24257fcffa5ff29033005501615a23a20ad6e542afee61013efa", }, adventure: { - shared: "3ffa5def15f688e3ea87eb689b35dffe18e12c98df7a52497d483c492313d0dd", + shared: "0c17a4c036d6be008b3c31a2bf6495c3e282ea3eea7b677e51181801eeb4d0f1", kit: "d2fa700c0186ed7eb9199700ed5b027078420801d0d09de76954364f94937d7a", }, collectathon: { - shared: "7bb3e329118f0a2fb174037f6084f14a1392241ac4ba577d4f86babbc1a2e763", + shared: "effdee05cf87ba2eab7f7bb3c09d8b04a9e2f2bfd84f8150c675825ed71f8466", kit: "d442044c496f13384c098fa60573220cf41f5c0c07ac13ada43c1a5f0f64de72", }, kart: { - shared: "017719c2db08a143f8a166264b4d5178c6caedc1487c905e42c42accfbd74304", + shared: "f70cdddfd146d3364486479fdff9c2827e302eeb4dc7107f97dedd15617478ca", kit: "edd8ff2a1a9bb9465eaa6c82ff830b57d75f3b594018c45cc7cf3a6c737bd9c2", }, minimal: { - shared: "259a65b3b39d48c6e4a57568a900276e31a488416dfc842ff94d9a2821ea7727", + shared: "30f3b611b3660fbc60a111bcdb2ae4f8e921094b015b2d305820ae01b660d448", kit: "c211a38224fc1b10e6f8a2e3a626bb56ee4f16ef36114e331936a3649d81925f", }, platformer: { - shared: "050711e6aec4b42dd35933882009e524d10593f1fe86ab8cc46e5dd40c1725c6", + shared: "d90142bef0834e78f4f93f970627cf8dffc7c6da58d79c930dda92a988efe292", kit: "53f077d2b87719369152286f211dccd7dd721615e9e2dab8584e19b8ef68b1c6", }, puzzle: { - shared: "487ff9a1353ad28e49308ec5058dc755cfa8e8bb0ffad0ea1b2cfdc053c3fbe7", + shared: "a91fdae593e0d6c75720a21c5a90727f166c3e9e03bf1ee203ad120df53a0e45", kit: "81db308b4a7c9dbb711555f4b4355d4c0fa3baa6d5d2f3a80327333fe5b39401", }, racing: { - shared: "aef769ca1cfc034524908ef8ae8222d8bdda12a4d499ec605a42138667c609fc", + shared: "cbb692efd65b9490dfe71b75a86586eb2088f4336dd623a08b93317b1e3ba2b7", kit: "502ce8d65e38e69bfcec7c4c9981284048809676c6267f2a945534ed6a3f14c9", }, rain: { - shared: "2ac30555c1b2a99930c7fe3b59f95784e54737ca15cd64b9e57bf983839930b3", + shared: "d5b2311f051cba749eaa483eb980b1a3e4884af26eabe2af9d904aa10f7b25a2", kit: "f0adf851fb6d0e14acdf903b47a00e3fa41ff88ca5f7086e43ea3a09d4609b96", }, rts: { - shared: "87e279f98ac41038fe5a870d4bdcd2cdb0f19e99b9923b79286ed9a99b7614ef", + shared: "7c374f7187a2787d1d174e2c22b4bccfbd86bed21275943dc15639095b7b7b24", kit: "15ae383f30b33a6ca2c3185f51d3365f5fbe71b49f1432a3d6b30c3dc576d669", }, runner: { - shared: "b7261bb849ddb42f95282dd59e76fab6be51556433b66da39c48cadf1adf9811", + shared: "9a5c61b31b7d6fe228449dda48718cc97a9d91c30dce85d4052236cae968217f", kit: "b90f44f06df584db1557264c0fb07167adeb0b136c547839884ff1bcddca1158", }, sailing: { - shared: "8c0d8afe41bc87036b5f885f038b9d4111321710d78143723869d9c4d1c61e09", + shared: "b236051f0db1f52c34ab103afcc312f7dcebd6c94d2d9746c3fd2d27bbb7d345", kit: "db196dbad5d5b1e1c6b20bac8369c28426f7e2d7dac5e2ee948ec18a79e70cf0", }, shooter: { - shared: "4fe65b7c7da2779fbbf878d8952da34b253ec0d273866b49fbe28969e4243906", + shared: "380ff86d3c3c4612d7226abec95bb4775e37113f5bb9acc3a0875ed16f43815d", kit: "0e89ad8f17773794527b8bb153f265b6a7344792498f2a173447a666ecd60cf5", }, snow: { - shared: "b5e2a1ea614da1442dfbea47227e8c7e945372f5fe7d6c0ace5ea0e62a156a93", + shared: "806bb4c5280625b4110b87c72e8e026d0af3689ad1f293d516985e7f5b6ffc1f", kit: "4da758c138d7f80b9a4e88903e2c86172c0029bd160da1ebe5f37b72bcfea823", }, starter: { - shared: "1755088c86fde98617ab77f5addf6ec1415ae6f804e74935ed751106a5b4a60c", + shared: "fd5ade5e3c5fb8a7c8a7bb2ab9f3e744400c6d275f3e7d92e43723b6e109ad63", kit: "232c5278ee55f8581bf3b2b25c53d8a852a4a1704947ee2a6d22f3756c724a1e", }, "tower-defense": { - shared: "13f86e288d99c9bcf8276e094627ffd106b4d5be698dca7bbbfa37b2c689ee33", + shared: "c83d3b71375c3651dc67f7a3d216b6dc167fc699e26f6d2aada1fe87559e8f00", kit: "7fa359a8a390a9bcbb2c832ba4fe66f45129170f387333173625cab93e0c7ba0", }, // scaffold-pin:end diff --git a/packages/create-threenative/agent-files/.agents/skills/ponytail/SKILL.md b/packages/create-threenative/agent-files/.agents/skills/ponytail/SKILL.md index 0e163b6753..699a8680a0 100644 --- a/packages/create-threenative/agent-files/.agents/skills/ponytail/SKILL.md +++ b/packages/create-threenative/agent-files/.agents/skills/ponytail/SKILL.md @@ -26,7 +26,7 @@ problem, not instead of it: read the code the change touches and trace the real flow end to end, then climb. Two rungs work → take the higher one and move on. 1. **Does this need to exist at all?** Speculative need = skip it, say so in one line. (YAGNI) -2. **Already installed in the engine?** For a new game, search `engine_search_capabilities` for the full request and each concrete mechanic and inspect hits with `engine_capability_detail`; for a change to an existing game, search for that change (and references for each gameplay mechanic) as the existing-game route in `threenative-capabilities` says, without restarting whole-game discovery or planning. Reuse the returned system — a node, convention, helper or subpath that already ships is the answer, not a thing to re-implement. This rung *is* the critical capability gate; keep a change's recorded result rather than treating each edit as a new game. Keep installed systems instead of hand-writing replacements. +2. **Already installed in the engine?** For a new game, search `engine_search_capabilities` for the full request and each concrete mechanic and read every row (a row answers; `engine_capability_detail` is only for a symbol the search did not return); for a change to an existing game, search for that change (and references for each gameplay mechanic) as the existing-game route in `threenative-capabilities` says, without restarting whole-game discovery or planning. Reuse the returned system — a node, convention, helper or subpath that already ships is the answer, not a thing to re-implement. This rung *is* the critical capability gate; keep a change's recorded result rather than treating each edit as a new game. Keep installed systems instead of hand-writing replacements. 3. **Already in this game?** Reuse a helper, type, or pattern that already lives in `src/`. Re-implementing what sits a few files over is the most common slop. 4. **Stdlib or engine convention does it?** `loadAll`, `addInSlices`, `GroundSnap`, an input vector, a physics query — reach for those before writing your own loop. 5. **Already-installed dependency solves it?** Use it; never add a new one for what a few lines can do. diff --git a/packages/create-threenative/agent-files/.claude/hooks/discovery-gate.mjs b/packages/create-threenative/agent-files/.claude/hooks/discovery-gate.mjs index a8372024c7..45c4a34058 100644 --- a/packages/create-threenative/agent-files/.claude/hooks/discovery-gate.mjs +++ b/packages/create-threenative/agent-files/.claude/hooks/discovery-gate.mjs @@ -114,12 +114,12 @@ try { log = readFileSync(logFile, "utf8"); } catch (error) { deny( - `Discovery gate: cannot read ${path.relative(root, logFile)} (${error.code ?? error.message}), so the engine was not searched in this project. Before you create ${created.join(", ")}, run ${SEARCH} and engine_capability_detail on every hit. Outside this folder: ${BRIDGE}. Opt out with TN_DISCOVERY_GATE=off.`, + `Discovery gate: cannot read ${path.relative(root, logFile)} (${error.code ?? error.message}), so the engine was not searched in this project. Before you create ${created.join(", ")}, run ${SEARCH} and read every row; engine_capability_detail is only for a symbol the search did not return. Outside this folder: ${BRIDGE}. Opt out with TN_DISCOVERY_GATE=off.`, ); process.exit(0); } const since = Date.parse(state.prompt ?? "") || 0; if (!searchedSince(log, since)) deny( - `Discovery gate: no engine_search_capabilities call since the last user prompt. Before you create ${created.join(", ")}, run ${SEARCH}, then engine_capability_detail on every hit, and use what it returns. Outside this folder: ${BRIDGE}. Opt out with TN_DISCOVERY_GATE=off.`, + `Discovery gate: no engine_search_capabilities call since the last user prompt. Before you create ${created.join(", ")}, run ${SEARCH} and read every row — engine_capability_detail is only for a symbol the search did not return — and use what it returns. Outside this folder: ${BRIDGE}. Opt out with TN_DISCOVERY_GATE=off.`, ); diff --git a/packages/engine-mcp/__tests__/search.spec.ts b/packages/engine-mcp/__tests__/search.spec.ts index 07e4f29e6c..c306c975ea 100644 --- a/packages/engine-mcp/__tests__/search.spec.ts +++ b/packages/engine-mcp/__tests__/search.spec.ts @@ -726,6 +726,27 @@ describe("threenative-engine-mcp", () => { expect(searchResults(query, workspaceManifest)[0]?.symbol).toBe(symbol); }); + /** + * A one-word name is also an ordinary English word. Spelled inside a longer request it must not + * displace the mechanic the request is about: `View` is a React rectangle, `scene`, `depth`, + * `velocity` and `connect` are `three/tsl` buffers and a transport. The word has to hold a real + * share of the request, and it has to be rare in the name index, or the row is a wrong lead. + */ + it.each([ + ["camera follows the player with a third person view", "View"], + ["the enemy velocity should push the player back when hit", "velocity"], + ["make the water depth fog darker underwater", "depth"], + ["make the scene feel foggy and moody", "Scene"], + ])("does not answer '%s' with the ordinary word '%s'", (query, symbol) => { + expect(searchResults(query, workspaceManifest)[0]?.symbol).not.toBe(symbol); + }); + + it("keeps a one-word name that holds the request", () => { + expect(searchResults("add godrays", workspaceManifest)[0]?.symbol).toBe("godrays"); + expect(searchResults("enable ssgi", workspaceManifest)[0]?.symbol).toBe("ssgi"); + expect(searchResults("use rapier", workspaceManifest)[0]?.symbol).toBe("rapier"); + }); + /** Spelling `characterbody3d` or `Area3D` as words means the same node as the camel case. */ it.each([ ["character body 3d movement", "CharacterBody3D"], diff --git a/packages/engine-mcp/src/index.ts b/packages/engine-mcp/src/index.ts index ea51322dcd..5f7208f228 100644 --- a/packages/engine-mcp/src/index.ts +++ b/packages/engine-mcp/src/index.ts @@ -712,6 +712,12 @@ const NAME_SHARE_LEAD = 1.4; * not for a symbol; `godrays` and `traa` reach 2 and `mergeParts`' own words reach 11 and 15. */ const SYMBOL_WEIGHT_FLOOR = 4.2; +/** + * A one-word name spelled in passing also has to hold this much of the request. `depth` is rare + * enough for the weight floor, and a request about underwater fog still says it once; below this + * share the word is scenery and the row it would lead is a wrong answer at a weak score. + */ +const SINGLE_WORD_SHARE_FLOOR = 0.3; interface ISymbolScore { readonly score: number; @@ -738,10 +744,20 @@ function scoreSymbolName( // `use addInSlices for loading` — is asking for that symbol: equality with the whole name, not // with what the stemmer leaves of it, which is how `afterPhysics` once answered "physics". if (spellsName(situation, symbol)) { - return { - score: normaliseName(situation) === normaliseName(symbol) ? EXACT_SYMBOL_LEAD : nameScore, - specificity, - }; + if (normaliseName(situation) === normaliseName(symbol)) { + return { score: EXACT_SYMBOL_LEAD, specificity }; + } + // A one-word name spelled inside a longer request is the weakest form of the signal: `view`, + // `scene`, `depth`, `velocity` and `connect` are ordinary English that a request uses without + // naming the symbol. It must then earn its place twice — the word has to be one the name index + // rarely uses, and it has to hold a real share of the request — or `camera follows the player + // with a third person view` answers the React `View` rectangle over the camera it asks for. + if (name.length === 1) { + if (weightOf(weights, name[0] ?? "") < SYMBOL_WEIGHT_FLOOR) return undefined; + if (share < SINGLE_WORD_SHARE_FLOOR) return undefined; + return { score: (NAME_BASE_LEAD + NAME_SHARE_LEAD) * Math.min(1, share), specificity }; + } + return { score: nameScore, specificity }; } // A one-word name has to be spelled; a name scattered through the prose needs the gates below. if (name.length < 2) return undefined; diff --git a/scripts/__tests__/capability-manifest.spec.ts b/scripts/__tests__/capability-manifest.spec.ts index 184c7e5a03..d15478eca7 100644 --- a/scripts/__tests__/capability-manifest.spec.ts +++ b/scripts/__tests__/capability-manifest.spec.ts @@ -797,11 +797,14 @@ describe("capability manifest generator", () => { ids: ["request.rough-diffuse-substrate-eon"], owners: ["useRoughDiffuse"], }, - // PRD-865: seven aliases below are recalled with the aliases stripped, because these - // capabilities' own summaries name the Unreal vocabulary (`global illumination`, `virtual - // shadow`, `lumen surface cache`, `radiance cache`). The alias rows are still the vocabulary - // the requests use; they are no longer those rows' only path. Marked, not deleted: a later - // PRD that needs alias-necessity strictness for Unreal terms should start here. + // PRD-865: seven aliases below are recalled with the aliases stripped, for two reasons the + // new channels introduced. `SurfaceCache` and `RadianceCache` are found because the request + // spells their name (`lumen surface cache`, `radiance cache`). `GIBudget` and + // `ShadowFilterBudget` are found through their situations once the tokenizer splits the + // camelCase of `sg.GlobalIlluminationQuality` and `sg.ShadowQuality` into words. The alias + // rows are still the vocabulary the requests use, and still decide rank 1, but they are no + // longer those rows' only path. Marked, not deleted: a later PRD that needs alias-necessity + // strictness for Unreal terms should start here. "lumen scalability": { baselineAlreadyRecalled: true, ids: ["request.gi-quality-scalability"],