@polycode-projects/the-mechanical-code-talker 1.9.0 → 1.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
@@ -88,6 +88,8 @@
88
88
  "init:persona:human": "node bin/tmct.mjs init --with-persona human",
89
89
  "init:persona:empty": "node bin/tmct.mjs init --with-persona empty",
90
90
  "init:large": "node bin/tmct.mjs init && node bin/tmct.mjs import --corpus seon && node bin/tmct.mjs import --corpus conceptnet && node bin/tmct.mjs import --corpus aws && node bin/tmct.mjs import --corpus python && node bin/tmct.mjs import --corpus java",
91
+ "init:xl": "node bin/tmct.mjs init --persona-size large && node bin/tmct.mjs import --corpus seon && node bin/tmct.mjs import --corpus conceptnet && node bin/tmct.mjs import --corpus aws && node bin/tmct.mjs import --corpus python && node bin/tmct.mjs import --corpus java && node bin/tmct.mjs import --corpus wordnet-xl",
92
+ "init:xxl": "node bin/tmct.mjs init --persona-size large && node bin/tmct.mjs import --corpus seon && node bin/tmct.mjs import --corpus conceptnet && node bin/tmct.mjs import --corpus aws && node bin/tmct.mjs import --corpus python && node bin/tmct.mjs import --corpus java && node bin/tmct.mjs import --corpus wordnet-full && node bin/tmct.mjs import --corpus namenet",
91
93
  "memory": "node bin/tmct.mjs memory",
92
94
  "syllogise": "node bin/tmct.mjs syllogise",
93
95
  "viz": "node bin/tmct.mjs viz",
package/src/chat.mjs CHANGED
@@ -677,7 +677,7 @@ async function answerEdgeCount(graph, query) {
677
677
  * Consulted only when answerCount can't map the noun to a graph class (an unknown
678
678
  * kind) AND a session's memory is in hand. Returns the count string or null (no
679
679
  * such fact → the honest "I can't count …" from answerCount stands). */
680
- async function countFromFacts(graph, memoryDir, query, biasByBundle = {}) {
680
+ async function countFromFacts(graph, memoryDir, query, biasByBundle = {}, cache = null) {
681
681
  if (!graph || !memoryDir) return null;
682
682
  const m = String(query).match(/\b(?:how many|number of|count(?:\s+the)?)\s+([a-z]+)\b/i);
683
683
  if (!m) return null;
@@ -686,7 +686,7 @@ async function countFromFacts(graph, memoryDir, query, biasByBundle = {}) {
686
686
  let normFactTerm;
687
687
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
688
688
  const objVariants = factTermVariants(normFactTerm, asked);
689
- const isa = (await factRows(memoryDir))
689
+ const isa = (await factRows(memoryDir, cache))
690
690
  .filter((f) => ISA_PREDICATES.has(f.predicate) && objVariants.has(f.object));
691
691
  // pick the highest-bias, then highest-trust asserted subject that maps to a
692
692
  // countable graph class (rankByBiasThenTrust: bias-tied/unconfigured degrades
@@ -721,7 +721,7 @@ async function countFromFacts(graph, memoryDir, query, biasByBundle = {}) {
721
721
  // graph-cardinality count untouched — same honest-decline discipline as
722
722
  // every other lane here).
723
723
  const HOW_MANY_ARE_RE = /^how\s+many\s+([\w-]+)\s+(?:are|is)\s+(.+?)[?.!\s]*$/i;
724
- async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}) {
724
+ async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}, cache = null) {
725
725
  if (!memoryDir) return null;
726
726
  const m = String(query).trim().match(HOW_MANY_ARE_RE);
727
727
  if (!m) return null;
@@ -730,7 +730,7 @@ async function answerQuantifierRecall(memoryDir, query, biasByBundle = {}) {
730
730
  let normFactTerm;
731
731
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
732
732
  const subjVariants = factTermVariants(normFactTerm, asked);
733
- const rows = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && subjVariants.has(f.subject));
733
+ const rows = (await factRows(memoryDir, cache)).filter((f) => ISA_PREDICATES.has(f.predicate) && subjVariants.has(f.subject));
734
734
  if (!rows.length) return null; // never heard of this subject at all — let answerCount own the shape
735
735
  const objVariants = factTermVariants(normFactTerm, m[2]);
736
736
  const hit = rankByBiasThenTrust(rows.filter((f) => objVariants.has(f.object)), biasByBundle)[0];
@@ -2067,7 +2067,7 @@ const GENERIC_ANCHOR_NOUNS = new Set(["thing", "concept", "object", "entity"]);
2067
2067
  * fact-grounded term matches under the EXACT spelling teachFact itself stored
2068
2068
  * it under. Failure-tolerated: no memory dir / no match → false, never a
2069
2069
  * guessed "yes". */
2070
- async function isGroundedByFact(term, memoryDir) {
2070
+ async function isGroundedByFact(term, memoryDir, cache = null) {
2071
2071
  if (!memoryDir) return false;
2072
2072
  const raw = String(term ?? "").trim();
2073
2073
  if (!raw) return false;
@@ -2083,7 +2083,7 @@ async function isGroundedByFact(term, memoryDir) {
2083
2083
  // OPERATOR actually taught (or a prior `tmct syllogise` entailment) anchors
2084
2084
  // a term here. factRows (not memoryFacts) is used specifically because it's
2085
2085
  // the one read path that carries sourceTypes for this filter.
2086
- const rows = await factRows(memoryDir);
2086
+ const rows = await factRows(memoryDir, cache);
2087
2087
  const isTaught = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
2088
2088
  return rows.some((f) => MINT_ISA_PREDICATES.has(f.predicate) && isTaught(f) && (f.subject === t || f.object === t));
2089
2089
  }
@@ -2098,13 +2098,13 @@ async function isGroundedByFact(term, memoryDir) {
2098
2098
  * narrower and NOUN-specific — see its own comment — so an object that's
2099
2099
  * merely a known ADJECTIVE doesn't get misrouted into the class/subClassOf
2100
2100
  * branch instead of the property branch.) */
2101
- async function isGroundedTerm(term, lex, memoryDir) {
2101
+ async function isGroundedTerm(term, lex, memoryDir, cache = null) {
2102
2102
  const raw = String(term ?? "").trim();
2103
2103
  if (!raw) return false;
2104
2104
  if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
2105
2105
  const { classify } = await import("./grammar/lexicon.mjs");
2106
2106
  if (classify(raw, lex)) return true;
2107
- return isGroundedByFact(raw, memoryDir);
2107
+ return isGroundedByFact(raw, memoryDir, cache);
2108
2108
  }
2109
2109
 
2110
2110
  /** The "both sides ungrounded" grounding NUDGE (operator refinement,
@@ -2127,15 +2127,15 @@ async function isGroundedTerm(term, lex, memoryDir) {
2127
2127
  * unchanged) whenever the payload doesn't fit the shape, or at least one
2128
2128
  * side IS already grounded — a DIFFERENT, more specific reason it declined,
2129
2129
  * where this nudge would be actively unhelpful noise. */
2130
- async function ungroundedPairHint(payload, lexicon, memoryDir) {
2130
+ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null) {
2131
2131
  if (!memoryDir) return "";
2132
2132
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2133
2133
  if (!m) return "";
2134
2134
  const [, , subjectRaw, , objectRaw] = m;
2135
2135
  const { loadLexicon } = await import("./grammar/lexicon.mjs");
2136
2136
  const lex = lexicon || loadLexicon();
2137
- if (await isGroundedTerm(subjectRaw, lex, memoryDir)) return "";
2138
- if (await isGroundedTerm(objectRaw, lex, memoryDir)) return "";
2137
+ if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache)) return "";
2138
+ if (await isGroundedTerm(objectRaw, lex, memoryDir, cache)) return "";
2139
2139
  // 2026-07-10 (found live via SKILL_BENCHMARK_CONVERSATION.md playtest, a
2140
2140
  // classic first-thing-a-user-tries example: "john is a man"): the original
2141
2141
  // suggestion chained the second term UNDER the first's now-grounded proper
@@ -2179,7 +2179,7 @@ async function ungroundedPairHint(payload, lexicon, memoryDir) {
2179
2179
  * the SUBJECT, not about the "remember that" wrapper). Only the "every"
2180
2180
  * determiner records a quantifier (point 3: "a"/bare/"your" read as one
2181
2181
  * specific entity, not a class-level generalization). */
2182
- async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }) {
2182
+ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
2183
2183
  if (!memoryDir) return null;
2184
2184
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2185
2185
  if (!m) return null;
@@ -2211,7 +2211,7 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2211
2211
  // lexicon noun — both are always treated as class-level (never property),
2212
2212
  // consistent with unknownObjectFallback (below) always minting a CLASS.
2213
2213
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
2214
- || (await isGroundedByFact(objectRaw, memoryDir))) {
2214
+ || (await isGroundedByFact(objectRaw, memoryDir, cache))) {
2215
2215
  return teachFact(memoryDir, sessionId, {
2216
2216
  subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
2217
2217
  });
@@ -2297,7 +2297,7 @@ async function objectReadsAsNonNoun(word) {
2297
2297
  return false;
2298
2298
  }
2299
2299
  }
2300
- async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }) {
2300
+ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
2301
2301
  if (!memoryDir) return null;
2302
2302
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2303
2303
  if (!m) return null;
@@ -2305,9 +2305,9 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
2305
2305
  if (!/^(?:every|each|all)$/i.test((det || "").trim())) return null; // class-level mint needs a real universal quantifier
2306
2306
  const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
2307
2307
  const lex = lexicon || loadLexicon();
2308
- const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir);
2308
+ const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache);
2309
2309
  if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
2310
- const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir);
2310
+ const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir, cache);
2311
2311
  if (objectGrounded) return null; // object already known — nothing to mint
2312
2312
  if (await objectReadsAsNonNoun(objectRaw)) return null; // reads like an adjective/verb, not a class noun — defer to unknownAdjectiveFallback
2313
2313
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
@@ -2399,7 +2399,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
2399
2399
  * otherwise provide. "the cache is bespoke" and "Mary is female" both carry
2400
2400
  * one of those signals (the leading "the", and capitalization,
2401
2401
  * respectively); "module is banana" carries none. */
2402
- async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }) {
2402
+ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
2403
2403
  if (!memoryDir) return null;
2404
2404
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2405
2405
  if (!m) return null;
@@ -2410,7 +2410,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2410
2410
  // membership sentence, unknownSubjectFallback/unknownObjectFallback's own
2411
2411
  // territory (already had first refusal on it) — never misread as a property.
2412
2412
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
2413
- || (await isGroundedByFact(objectRaw, memoryDir))) return null;
2413
+ || (await isGroundedByFact(objectRaw, memoryDir, cache))) return null;
2414
2414
  // Subject-side groundedness — strip a leading "the"/"a"/"an" first
2415
2415
  // (normFactTerm's own article-strip, mirrored here) so "the cache" checks
2416
2416
  // groundedness under its real head noun "cache", the same spelling
@@ -2418,7 +2418,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2418
2418
  const bareSubject = subjectRaw.replace(/^(?:the|an?)\s+/i, "").trim() || subjectRaw;
2419
2419
  const hadArticle = bareSubject !== subjectRaw;
2420
2420
  const capitalized = /^[A-Z]/.test(bareSubject);
2421
- const factGrounded = await isGroundedByFact(bareSubject, memoryDir);
2421
+ const factGrounded = await isGroundedByFact(bareSubject, memoryDir, cache);
2422
2422
  const genericAnchor = GENERIC_ANCHOR_NOUNS.has(bareSubject.toLowerCase());
2423
2423
  // A bare (no article, no capitalization) subject grounded ONLY via the
2424
2424
  // static lexicon is exactly the pinned "module is banana" shape — see this
@@ -2745,7 +2745,7 @@ const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)
2745
2745
  * is tried against the remember-wrapped surface too. */
2746
2746
  const RETRACT_FORGET_RE = /^forget\s+(?:that\s+)?(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
2747
2747
 
2748
- async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2748
+ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null }) {
2749
2749
  // Tier 6 playtest: this lane read the raw, un-normalized query, so a closed
2750
2750
  // discourse-marker preamble ahead of a teach sentence ("howdy pardner,
2751
2751
  // remember that TaskController is fragile") corrupted TEACH_RE's own match —
@@ -3174,7 +3174,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3174
3174
  // assertTurn ITSELF records the "every" quantifier (point 3) on a plain
3175
3175
  // universal success, so every caller (this loop AND the top-level
3176
3176
  // declarative-sentence dispatch in runTurn) gets it uniformly.
3177
- const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
3177
+ const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon, cache });
3178
3178
  if (stored) return { text: stored.answer, via: "assert", miss: false };
3179
3179
  }
3180
3180
  // BUG "redis" fix (Feature A point 1): the real ACE grammar just declined
@@ -3183,7 +3183,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3183
3183
  // Covers BOTH the bare and the wrapped surface (payload is already
3184
3184
  // unwrapped either way) — see unknownSubjectFallback's own docblock for
3185
3185
  // the exact narrowing rules (object must still be known, etc.).
3186
- const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon });
3186
+ const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
3187
3187
  if (fallback) return fallback;
3188
3188
  // MIRROR mint fallback (Feature A, 2026-07-09 operator-authorized vocabulary-
3189
3189
  // growth extension): the known-subject/unknown-object asymmetry — tried
@@ -3191,7 +3191,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3191
3191
  // lexicon (or a prior taught fact) already grounds can mint a brand-new
3192
3192
  // object term. See unknownObjectFallback's own docblock for the exact
3193
3193
  // narrowing rules (the "both sides ungrounded" safety guard, etc.).
3194
- const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon });
3194
+ const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
3195
3195
  if (objectFallback) return objectFallback;
3196
3196
  // ADJECTIVE-MINT fallback (PLAN_TAUGHT_RELATIONS.md Item 5, Phase 1): tried
3197
3197
  // right after unknownObjectFallback declines, so a grounded subject (static
@@ -3200,7 +3200,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3200
3200
  // docblock for the exact narrowing rules (the "both sides ungrounded"
3201
3201
  // safety guard, and why this must be a standalone function rather than
3202
3202
  // nested inside unknownSubjectFallback).
3203
- const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon });
3203
+ const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache);
3204
3204
  if (adjectiveFallback) return adjectiveFallback;
3205
3205
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
3206
3206
  // (a bare "X is deprecated" is never silently reified), and only after the
@@ -3271,7 +3271,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3271
3271
  // replacement, exactly like "did" above — see ungroundedPairHint's own
3272
3272
  // docblock for why this is scoped to the "both sides ungrounded, fits the
3273
3273
  // X is/are Y shape" case only.
3274
- const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir);
3274
+ const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache);
3275
3275
  return {
3276
3276
  text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
3277
3277
  + `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
@@ -4205,11 +4205,27 @@ async function memoryFacts(memoryDir) {
4205
4205
  /** Load memory once and resolve every reified Fact into a TRUST-BEARING row
4206
4206
  * ({subject,predicate,object,provenance,trust,sourceTypes,…}) via core's
4207
4207
  * readFactRows — the seam the answer layer ranks + cites without re-walking the
4208
- * graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → []. */
4209
- async function factRows(memoryDir) {
4208
+ * graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → [].
4209
+ *
4210
+ * `cache` (PLAN_GRAPH_SCAN.md "Query side: memoize the per-turn reload"): an
4211
+ * optional, caller-owned plain object (`{ rows: null }`, e.g. one runTurn call's
4212
+ * own `factRowsCache`) — when `cache.rows` is already populated, it's returned
4213
+ * directly, skipping loadMemory/readFactRows entirely; otherwise the result is
4214
+ * computed as before and stashed onto `cache.rows` for the next caller sharing
4215
+ * the same cache this turn. Absent/null (the default) reproduces today's
4216
+ * behavior exactly — a fresh, uncached reload every call — so every caller that
4217
+ * doesn't pass one is byte-for-byte unaffected. Never shared across turns or
4218
+ * with mutateMemory (see the plan doc for why a global cache was rejected).
4219
+ * `cache.reloads` is bumped once per REAL loadMemory/readFactRows call (never on
4220
+ * a cache hit) purely so a test can assert "computed once per turn" by call
4221
+ * count instead of wall-clock — see test/chat-factrows-cache.test.mjs. */
4222
+ async function factRows(memoryDir, cache = null) {
4223
+ if (cache?.rows) return cache.rows;
4210
4224
  try {
4211
4225
  const { loadMemory, readFactRows } = await import("./memory/core.mjs");
4212
- return readFactRows(await loadMemory(memoryDir));
4226
+ const rows = readFactRows(await loadMemory(memoryDir));
4227
+ if (cache) { cache.rows = rows; cache.reloads = (cache.reloads || 0) + 1; }
4228
+ return rows;
4213
4229
  } catch {
4214
4230
  return [];
4215
4231
  }
@@ -4612,7 +4628,7 @@ function uniqueFacts(rows) {
4612
4628
  * returns the handle's `payload` directly with ZERO fs calls — so a caller
4613
4629
  * that hands this a handle already carrying the embedded page's full graph
4614
4630
  * gets a pure, disk-free traversal, no bundle-time module shimming needed. */
4615
- export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4631
+ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
4616
4632
  let normFactTerm;
4617
4633
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4618
4634
  const q = String(query).trim();
@@ -4632,7 +4648,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4632
4648
  const usedForQ = q.match(WHAT_USED_FOR_RE);
4633
4649
  if (usedForQ) {
4634
4650
  const variants = factTermVariants(normFactTerm, usedForQ[1]);
4635
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
4651
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
4636
4652
  if (hits.length) {
4637
4653
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4638
4654
  const lines = ranked.map(renderFactLine);
@@ -4653,7 +4669,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4653
4669
  const m = q.match(re);
4654
4670
  if (!m) continue;
4655
4671
  const variants = factTermVariants(normFactTerm, m[1]);
4656
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === predicate && variants.has(f.object));
4672
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === predicate && variants.has(f.object));
4657
4673
  if (!hits.length) continue; // try the next candidate marker, don't give up yet
4658
4674
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4659
4675
  const lines = ranked.map(renderFactLine);
@@ -4703,7 +4719,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4703
4719
  // factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
4704
4720
  // bias-weighted ranking below needs each hit's sourceIds to resolve which
4705
4721
  // bundle it came from (memory/bias.mjs's biasForRow).
4706
- const subjectHits = (await factRows(memoryDir)).filter((f) => variants.has(f.subject));
4722
+ const subjectHits = (await factRows(memoryDir, cache)).filter((f) => variants.has(f.subject));
4707
4723
  let hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
4708
4724
  if (!hits.length) {
4709
4725
  // The subject itself is known, but not under this specific relation —
@@ -4763,7 +4779,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4763
4779
  const canDo = q.match(WHAT_CAN_DO_RE);
4764
4780
  if (canDo) {
4765
4781
  const variants = factTermVariants(normFactTerm, canDo[1]);
4766
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
4782
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
4767
4783
  if (!hits.length) return null;
4768
4784
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4769
4785
  const lines = ranked.map(renderFactLine);
@@ -4782,7 +4798,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4782
4798
  const hasQ = q.match(WHAT_HAS_RE);
4783
4799
  if (hasQ && !HAS_TEMPORAL_TAIL.has(hasQ[1].trim().split(/\s+/)[0]?.toLowerCase())) {
4784
4800
  const variants = factTermVariants(normFactTerm, hasQ[1]);
4785
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
4801
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
4786
4802
  if (!hits.length) return null;
4787
4803
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4788
4804
  const lines = ranked.map(renderFactLine);
@@ -4819,7 +4835,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4819
4835
  : inheritsQ?.[1];
4820
4836
  if (inheritsObj) {
4821
4837
  const variants = factTermVariants(normFactTerm, inheritsObj);
4822
- const hits = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
4838
+ const hits = (await factRows(memoryDir, cache)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
4823
4839
  // Only diverts on a REAL hit — same discipline every other reader in this
4824
4840
  // cascade follows (CAN_ASK_RE/WHAT_CAN_DO_RE/WHAT_HAS_RE above all `return
4825
4841
  // null` on zero hits too). A zero-hit case here must NOT invent its own
@@ -4847,7 +4863,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4847
4863
  const know = q.match(KNOW_ABOUT_RE);
4848
4864
  if (know) {
4849
4865
  const variants = factTermVariants(normFactTerm, know[1]);
4850
- const rows = await factRows(memoryDir);
4866
+ const rows = await factRows(memoryDir, cache);
4851
4867
  // Bug E subtype walk (operator follow-up request, this session): a
4852
4868
  // cycle-safe BFS DOWNWARD over isa-family facts from the term's own
4853
4869
  // variants — every fact whose OBJECT is in the current frontier
@@ -5283,7 +5299,7 @@ function inheritsChain(graph, startId) {
5283
5299
  * "what kind of thing is an X" reports X's own type (subject-side first).
5284
5300
  * Miss-only and run AFTER factAnswer returns null, so it never shadows the
5285
5301
  * subject-side answer or a schema hit. Returns { text, replace:true } or null. */
5286
- async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}) {
5302
+ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
5287
5303
  if (!miss) return null;
5288
5304
  let normFactTerm;
5289
5305
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
@@ -5330,7 +5346,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5330
5346
  // fragile" is the SAME one-word-out-of-alignment problem the hedge adverbs
5331
5347
  // above were fixed for, just a dialect opener instead of a hedge adverb.
5332
5348
  const qHedge = q.replace(/^(?:actually|really|honestly|yeah\s+nah)\s*,?\s+/i, "");
5333
- const rows = await factRows(memoryDir);
5349
+ const rows = await factRows(memoryDir, cache);
5334
5350
  if (!rows.length) {
5335
5351
  // Tier-5 playtest fix (cycle 2), found live: with TRULY zero facts
5336
5352
  // remembered yet (a fresh session, nothing taught at all), the early
@@ -6411,10 +6427,10 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
6411
6427
  * only (a `/describe` names ONE code entity as the subject of its own facts,
6412
6428
  * not every fact that merely mentions it in passing) — null when memory holds
6413
6429
  * nothing about this subject. */
6414
- async function describedFacts(memoryDir, label, biasByBundle = {}) {
6430
+ async function describedFacts(memoryDir, label, biasByBundle = {}, cache = null) {
6415
6431
  let normFactTerm;
6416
6432
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
6417
- const rows = await factRows(memoryDir);
6433
+ const rows = await factRows(memoryDir, cache);
6418
6434
  if (!rows.length) return null;
6419
6435
  const variants = factTermVariants(normFactTerm, label);
6420
6436
  const hits = rankByBiasThenTrust(rows.filter((f) => variants.has(f.subject)), biasByBundle);
@@ -7349,7 +7365,7 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
7349
7365
  * shipped corpus/seon file (seonDefinitions), so it works without per-repo memory
7350
7366
  * seeding; the memory fact rows only ADD remembered "A is a X" examples when present.
7351
7367
  * Lazy + failure-tolerated throughout (chat.mjs ethos). Returns { text, instances }. */
7352
- async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates }) {
7368
+ async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates, cache = null }) {
7353
7369
  const rawTerm = conceptTermOf(query, envelope);
7354
7370
  if (!rawTerm) return null;
7355
7371
  let normFactTerm; let composeConcept; let CONCEPT_CLASS;
@@ -7369,7 +7385,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
7369
7385
  try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
7370
7386
  }
7371
7387
  if (!g) return null;
7372
- const rows = memoryDir ? await factRows(memoryDir) : [];
7388
+ const rows = memoryDir ? await factRows(memoryDir, cache) : [];
7373
7389
  let composed;
7374
7390
  try { composed = composeConcept(g, term, { definition, factRows: rows }); }
7375
7391
  catch { return null; }
@@ -7425,7 +7441,7 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
7425
7441
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
7426
7442
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
7427
7443
  * normal answer, never a crash. */
7428
- async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {} }) {
7444
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null }) {
7429
7445
  const ts = new Date().toISOString();
7430
7446
  // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
7431
7447
  // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
@@ -7949,8 +7965,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7949
7965
  let bareMetaHit = null;
7950
7966
  if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape)) {
7951
7967
  if (memoryDir) {
7952
- bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle))
7953
- ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle));
7968
+ bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache))
7969
+ ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
7954
7970
  // HANDOVER.md 2026-07-10 item 10 (dropped-article gap): a bare "what is X"
7955
7971
  // with NO taught fact but a KNOWN curated corpus term ("what is cache", no
7956
7972
  // article) used to lose this exact same isConversationalCandidate race —
@@ -8042,8 +8058,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8042
8058
  // reified fact is stronger evidence than a transcript echo. Subject-side facts
8043
8059
  // first (factAnswer), then the reverse-membership read-back (factReadBack) so an
8044
8060
  // asserted "every X is a Y" answers "what is a Y" too.
8045
- const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
8046
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
8061
+ const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache))
8062
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
8047
8063
  if (fact) {
8048
8064
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
8049
8065
  via = "fact";
@@ -8117,7 +8133,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8117
8133
  let conceptAllIds = null;
8118
8134
  let conceptPending = null;
8119
8135
  if (via === "composed" || via === "corpus/seon") {
8120
- const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates });
8136
+ const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates, cache });
8121
8137
  if (concept) {
8122
8138
  answer = concept.text; via = "corpus/seon"; recordMiss = false;
8123
8139
  conceptInstances = concept.instances;
@@ -8174,7 +8190,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8174
8190
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
8175
8191
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
8176
8192
  if (miss && recordMiss && via === "composed") {
8177
- const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
8193
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache });
8178
8194
  if (taught) {
8179
8195
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
8180
8196
  note(trace, `lane: (4) TEACH — TEACH_RE/OWNS_TEACH_RE/BARE_DECLARATIVE_RE matched, ${taught.miss ? "but the payload could not be stored" : "reified into .tmct/memory"}`);
@@ -8540,7 +8556,7 @@ const GOAL_BY_COMMAND = {
8540
8556
  * field now (Bug F point 5) — mirrors runAsk's own `goal` field so
8541
8557
  * withGoalLine's short "Goal (inferred): …" line fires for command
8542
8558
  * dispatches too, not just ask()-parsed queries. */
8543
- async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {} }) {
8559
+ async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {}, cache = null }) {
8544
8560
  const ts = new Date().toISOString();
8545
8561
  const sp = line.indexOf(" ");
8546
8562
  const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
@@ -8651,7 +8667,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
8651
8667
  // matching taught facts (subject === the resolved entity, trust-ranked)
8652
8668
  // under the code-map answer, mirroring the ask-path's fact-append pattern.
8653
8669
  if (name === "describe" && memoryDir) {
8654
- const facts = await describedFacts(memoryDir, ent.label, biasByBundle);
8670
+ const facts = await describedFacts(memoryDir, ent.label, biasByBundle, cache);
8655
8671
  if (facts) { answer = `${answer}\n${facts}`; note(trace, "source: memory facts (describedFacts) appended to the code-map answer"); }
8656
8672
  }
8657
8673
  return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
@@ -8699,7 +8715,7 @@ function renderAmbiguousAssert(line, ambiguous, normFactTerm) {
8699
8715
  * relation-shaped with 0-1 surviving readings), so this adds exactly one
8700
8716
  * cheap check ahead of the EXISTING, unchanged parseAce path below — every
8701
8717
  * single-reading sentence renders byte-identically to before. */
8702
- async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
8718
+ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null }) {
8703
8719
  try {
8704
8720
  const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
8705
8721
  // A session handle carries its own loaded lexicon (createSession loads it once);
@@ -8778,7 +8794,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
8778
8794
  const newSubj = normFactTerm(res.triples[0].subject);
8779
8795
  const newObj = normFactTerm(res.triples[0].object);
8780
8796
  const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
8781
- const priorEdges = (await factRows(memoryDir))
8797
+ const priorEdges = (await factRows(memoryDir, cache))
8782
8798
  .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f)
8783
8799
  && !(normFactTerm(f.subject) === newSubj && normFactTerm(f.object) === newObj))
8784
8800
  .map((f) => [normFactTerm(f.subject), normFactTerm(f.object)]);
@@ -8930,8 +8946,22 @@ function rewriteUsesAsBaseFrame(text) {
8930
8946
  return null;
8931
8947
  }
8932
8948
 
8933
- export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {} } = {}) {
8949
+ export async function runTurn(input, { config, source = defaultSource, graph = null, focus = null, last = null, memoryDir = null, sessionId = "", env = process.env, lexicon = null, narrate = false, vocabHint = null, tel = null, biasByBundle = {}, factRowsCache: injectedFactRowsCache = null } = {}) {
8934
8950
  const line = String(input ?? "").trim();
8951
+ // PLAN_GRAPH_SCAN.md "Query side: memoize the per-turn reload": ONE fresh,
8952
+ // empty cache for this turn only — every factRows() reader reached from this
8953
+ // call (factAnswer, factReadBack, describedFacts, countFromFacts,
8954
+ // answerQuantifierRecall, assertTurn, teachLane's grounding fallbacks,
8955
+ // conceptForceAnswer, …) shares it via `ctx`/an explicit trailing arg, so the
8956
+ // first reader to run computes loadMemory+readFactRows once and every later
8957
+ // reader THIS TURN reuses that same result instead of reloading from disk.
8958
+ // Never persisted, never shared across turns or with mutateMemory (a global
8959
+ // cache was explicitly rejected — see the plan doc's own reasoning: a reader
8960
+ // could observe a mutator's half-written object). `injectedFactRowsCache` is a
8961
+ // TEST-ONLY escape hatch (default null, so every real caller gets a fresh one
8962
+ // exactly as before) — passing one in lets a test observe `.reloads` after the
8963
+ // call to assert the real load path ran exactly once this turn.
8964
+ const factRowsCache = injectedFactRowsCache ?? { rows: null };
8935
8965
  // The captured residue is used for RECOGNITION at every dispatch site below
8936
8966
  // (asBareCommand, conversationalTurn, assertTurn, the count lanes, runAsk);
8937
8967
  // the ORIGINAL `line` survives untouched for record.query/logLines fidelity
@@ -8959,7 +8989,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8959
8989
  // pass one gets it computed here instead, so "try this vocabulary example" is
8960
8990
  // never wrong regardless of caller.
8961
8991
  const resolvedVocabHint = vocabHint ?? vocabExampleHint(await hasSeededVocabulary(memoryDir));
8962
- const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle };
8992
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache };
8963
8993
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
8964
8994
  // that why/say-more re-renders; a conversational turn does not (it preserves it).
8965
8995
  // FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
@@ -9051,7 +9081,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
9051
9081
  // authority gate declines (returns null) for anything answerCount should own,
9052
9082
  // so ordinary structural counts fall through completely unaffected.
9053
9083
  if (memoryDir) {
9054
- const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle);
9084
+ const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle, factRowsCache);
9055
9085
  if (quantifierRecall != null) {
9056
9086
  note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
9057
9087
  note(trace, "lane: answerQuantifierRecall — matched HOW_MANY_ARE_RE with a subject tmct has facts about; literal recall, never real counting");
@@ -9079,7 +9109,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
9079
9109
  // ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
9080
9110
  // class count). countFromFacts declines on a real graph kind, so ordinary
9081
9111
  // counts are unaffected; it only speaks for a remembered object noun.
9082
- const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle) : null;
9112
+ const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle, factRowsCache) : null;
9083
9113
  if (viaFact != null) {
9084
9114
  note(trace, 'goal: get a count of an asserted-vocabulary kind ("every X is a Y" inherited cardinality)');
9085
9115
  note(trace, "lane: countFromFacts — the counted noun matched a remembered isa-fact's SUBJECT, whose class IS countable");
@@ -762,8 +762,56 @@ async function persistMemory(dir, payload) {
762
762
  * reached). `await fn(payload)` is a documented no-op for every existing
763
763
  * SYNC caller (appendUtterance(s), appendFacts) — awaiting a non-Promise
764
764
  * value just resolves to it, byte-identical behaviour to calling it plain. */
765
+ // ---- mutateMemory-scoped lookup index (PLAN_GRAPH_SCAN.md Phase 1) ----------
766
+ // syncFactSources's per-fact bookkeeping (upsertSource, upsertIndividual,
767
+ // upsertEdge's statedBy path, statedByObjectsFor, sourcesByIdMap) used to each
768
+ // re-scan payload.individuals or the statedBy edge list from scratch, turning
769
+ // one appendFacts batch of n facts into O(n^2) work. mutateMemory now builds
770
+ // three lookup Maps once per call (one O(n) pass) and attaches them to payload
771
+ // under a Symbol key — JSON.stringify skips Symbol-keyed properties
772
+ // automatically, so persistMemory's graph.json write is byte-identical to
773
+ // before. Every helper below checks for the Symbol slot: present → O(1) Map
774
+ // lookup; absent (a bare payload object built outside mutateMemory, e.g. a
775
+ // test fixture) → today's exact linear-scan fallback, so nothing outside
776
+ // mutateMemory's own call chain can observe a behaviour change. The index is
777
+ // discarded when mutateMemory returns — it never survives across calls, so
778
+ // there is no invalidation logic to get wrong.
779
+ const MEMORY_INDEX = Symbol("mutateMemory lookup index");
780
+
781
+ /** Build the three lookup Maps from the just-loaded payload and attach them
782
+ * under MEMORY_INDEX. Any code that pushes a new individual into
783
+ * payload.individuals, or a new statedBy edge, must also write the matching
784
+ * index entry in that same statement (see upsertIndividual/upsertSource/
785
+ * upsertEdge/appendFacts below) — the same discipline appendFacts's own
786
+ * local `byId` Map already used for the Fact upsert, generalised here. */
787
+ function buildMemoryIndex(payload) {
788
+ const individualsById = new Map();
789
+ const sourcesById = new Map();
790
+ const statedByBySubject = new Map();
791
+ for (const ind of payload.individuals || []) {
792
+ if (!ind?.id) continue;
793
+ individualsById.set(ind.id, ind);
794
+ if (ind.class === SOURCE_CLASS) sourcesById.set(ind.id, ind);
795
+ }
796
+ const statedGroup = (payload.objectProperties || []).find((g) => g?.prop === STATED_BY_PROP);
797
+ for (const e of statedGroup?.examples || []) {
798
+ if (!e?.subject) continue;
799
+ const list = statedByBySubject.get(e.subject);
800
+ if (list) list.push(e.object);
801
+ else statedByBySubject.set(e.subject, [e.object]);
802
+ }
803
+ payload[MEMORY_INDEX] = { individualsById, sourcesById, statedByBySubject };
804
+ return payload[MEMORY_INDEX];
805
+ }
806
+
807
+ /** The active lookup index for this payload, or null when this payload wasn't
808
+ * built by mutateMemory (a bare test fixture) — callers fall back to a
809
+ * linear scan in that case. */
810
+ const memoryIndexOf = (payload) => payload?.[MEMORY_INDEX] || null;
811
+
765
812
  async function mutateMemory(dir, fn) {
766
813
  const payload = await loadMemory(dir);
814
+ buildMemoryIndex(payload);
767
815
  const out = (await fn(payload)) ?? payload;
768
816
  migrateLegacyProvenance(out);
769
817
  recomputeSourceReliability(out);
@@ -827,9 +875,10 @@ const sourceLabel = (id) => String(id).replace(/^src:/, "");
827
875
  function upsertSource(payload, desc, createdAtCandidate) {
828
876
  const info = sourceIdFor(desc);
829
877
  if (!info) return null;
830
- const prior = payload.individuals.find((i) => i?.id === info.id);
878
+ const idx = memoryIndexOf(payload);
879
+ const prior = idx ? idx.individualsById.get(info.id) : payload.individuals.find((i) => i?.id === info.id);
831
880
  const created = firstWriteCreatedAt(prior, desc?.createdAt || createdAtCandidate);
832
- upsertIndividual(payload, {
881
+ const ind = {
833
882
  id: info.id, label: sourceLabel(info.id), class: SOURCE_CLASS,
834
883
  derived_from: [], mentions: [],
835
884
  attributes: [
@@ -839,7 +888,9 @@ function upsertSource(payload, desc, createdAtCandidate) {
839
888
  ...(info.url ? [{ prop: "mgx:sourceUrl", key: "sourceUrl", value: info.url }] : []),
840
889
  ...(info.rule ? [{ prop: "mgx:sourceRule", key: "sourceRule", value: info.rule }] : []),
841
890
  ],
842
- });
891
+ };
892
+ const stored = upsertIndividual(payload, ind);
893
+ if (idx) idx.sourcesById.set(info.id, stored);
843
894
  return info.id;
844
895
  }
845
896
 
@@ -906,13 +957,23 @@ export function provenanceTagToSource(tag) {
906
957
  /** Map a payload's Source individuals into the { id: Source } shape computeTrust
907
958
  * resolves against. */
908
959
  function sourcesByIdMap(payload) {
960
+ const idx = memoryIndexOf(payload);
909
961
  const m = {};
962
+ if (idx) {
963
+ // idx.sourcesById is kept incrementally correct by upsertSource, so this
964
+ // is O(distinct Sources) — a handful, roughly one per corpus/provider —
965
+ // never O(all individuals), unlike the fallback rebuild below.
966
+ for (const [id, ind] of idx.sourcesById) m[id] = ind;
967
+ return m;
968
+ }
910
969
  for (const i of payload.individuals) if (i?.class === SOURCE_CLASS) m[i.id] = i;
911
970
  return m;
912
971
  }
913
972
 
914
973
  /** The Source ids a Fact is statedBy, read off the edge group. */
915
974
  function statedByObjectsFor(payload, factId) {
975
+ const idx = memoryIndexOf(payload);
976
+ if (idx) return (idx.statedByBySubject.get(factId) || []).slice();
916
977
  const g = payload.objectProperties.find((x) => x?.prop === STATED_BY_PROP);
917
978
  return (g?.examples || []).filter((e) => e?.subject === factId).map((e) => e.object);
918
979
  }
@@ -1039,8 +1100,9 @@ function recomputeSourceReliability(payload) {
1039
1100
  }
1040
1101
  if (!bySource.size) return;
1041
1102
 
1103
+ const idx = memoryIndexOf(payload);
1042
1104
  for (const [sid, counts] of bySource) {
1043
- const source = payload.individuals.find((i) => i?.id === sid);
1105
+ const source = idx ? idx.individualsById.get(sid) : payload.individuals.find((i) => i?.id === sid);
1044
1106
  if (!source) continue;
1045
1107
  setAttr(source, SOURCE_RELIABILITY_PROP, "sourceReliability", String(sessionReliabilityFrom(counts)));
1046
1108
  // Own-attribute mutation in place (PLAN_VIZ.md §2) — same reasoning as recomputeFactTrust.
@@ -1053,16 +1115,38 @@ function recomputeSourceReliability(payload) {
1053
1115
  const affected = new Set();
1054
1116
  for (const e of statedGroup?.examples || []) if (bySource.has(e?.object)) affected.add(e.subject);
1055
1117
  for (const id of affected) {
1056
- const ind = payload.individuals.find((i) => i?.id === id);
1118
+ const ind = idx ? idx.individualsById.get(id) : payload.individuals.find((i) => i?.id === id);
1057
1119
  if (ind) recomputeFactTrust(payload, ind);
1058
1120
  }
1059
1121
  }
1060
1122
 
1061
- /** Upsert an individual by id (replace-in-place keeps ordering stable). */
1123
+ /** Upsert an individual by id (replace-in-place keeps ordering stable).
1124
+ * Returns the individual object actually stored in payload.individuals — the
1125
+ * caller (e.g. upsertSource) should index THAT reference, not `ind` itself,
1126
+ * since the indexed path below merges into the prior object in place rather
1127
+ * than replacing the array slot. When a lookup index is present (built by
1128
+ * mutateMemory), an existing individual is updated via Object.assign — same
1129
+ * array position AND same object identity as before, so it stays trivially
1130
+ * in sync with individualsById without a second Map write, and a brand-new
1131
+ * individual is pushed + indexed in the same statement. Absent an index
1132
+ * (a bare payload built outside mutateMemory), this is EXACTLY the original
1133
+ * findIndex + replace-or-push code. */
1062
1134
  function upsertIndividual(payload, ind) {
1135
+ const idx = memoryIndexOf(payload);
1136
+ if (idx) {
1137
+ const prior = idx.individualsById.get(ind.id);
1138
+ if (prior) {
1139
+ Object.assign(prior, ind);
1140
+ return prior;
1141
+ }
1142
+ payload.individuals.push(ind);
1143
+ idx.individualsById.set(ind.id, ind);
1144
+ return ind;
1145
+ }
1063
1146
  const i = payload.individuals.findIndex((x) => x?.id === ind.id);
1064
- if (i >= 0) payload.individuals[i] = ind;
1065
- else payload.individuals.push(ind);
1147
+ if (i >= 0) { payload.individuals[i] = ind; return ind; }
1148
+ payload.individuals.push(ind);
1149
+ return ind;
1066
1150
  }
1067
1151
 
1068
1152
  /** Upsert one edge into the named relation group (dedupe by subject>object). Stamps `createdAt`
@@ -1076,6 +1160,27 @@ function upsertEdge(payload, { predicate, prop }, edge) {
1076
1160
  group = { predicate, prop, count: 0, examples: [] };
1077
1161
  payload.objectProperties.push(group);
1078
1162
  }
1163
+ // statedBy-only fast path (PLAN_GRAPH_SCAN.md Phase 1): statedByBySubject
1164
+ // tracks, per fact, the small list of Source ids already stated it (almost
1165
+ // always 0-1 during a seed), so the overwhelmingly common case — a brand
1166
+ // new (subject,object) statedBy pair — can append directly without the
1167
+ // find+filter scan of the WHOLE statedBy edge list below. Every other
1168
+ // predicate (saidInSession, inReplyTo, ...) is untouched and always takes
1169
+ // the original path.
1170
+ const idx = prop === STATED_BY_PROP ? memoryIndexOf(payload) : null;
1171
+ if (idx) {
1172
+ const existing = idx.statedByBySubject.get(edge.subject);
1173
+ if (!existing || !existing.includes(edge.object)) {
1174
+ group.examples.push({ ...edge, createdAt: edge.createdAt || nowIso() });
1175
+ group.count = group.examples.length;
1176
+ if (existing) existing.push(edge.object);
1177
+ else idx.statedByBySubject.set(edge.subject, [edge.object]);
1178
+ return;
1179
+ }
1180
+ // Rare re-assert of the exact same (subject,object) pair — fall through
1181
+ // to the exact original find+filter dance so first-write-wins createdAt
1182
+ // is preserved; the index is kept accurate below too.
1183
+ }
1079
1184
  // Edges are flat ({subject, object, ...}), not attribute-bearing individuals, so this can't
1080
1185
  // reuse firstWriteCreatedAt (which reads `.attributes`) directly — same discipline, edge shape:
1081
1186
  // the prior edge's OWN createdAt wins if it has one, else the incoming candidate, else now.
@@ -1086,6 +1191,11 @@ function upsertEdge(payload, { predicate, prop }, edge) {
1086
1191
  );
1087
1192
  group.examples.push({ ...edge, createdAt });
1088
1193
  group.count = group.examples.length;
1194
+ if (idx) {
1195
+ const list = idx.statedByBySubject.get(edge.subject) || [];
1196
+ if (!list.includes(edge.object)) list.push(edge.object);
1197
+ idx.statedByBySubject.set(edge.subject, list);
1198
+ }
1089
1199
  }
1090
1200
 
1091
1201
  /** Recount `classes[]` from the individuals — every memory class stays counted
@@ -1341,7 +1451,13 @@ export async function appendFacts(dir, facts) {
1341
1451
  if (!prepared.length) return { ids, appended: 0, skipped };
1342
1452
  await mutateMemory(dir, (payload) => {
1343
1453
  // id → individual index for O(1) upsert (the array grows to thousands).
1344
- const byId = new Map(payload.individuals.map((i) => [i?.id, i]));
1454
+ // When mutateMemory already built the Symbol-keyed lookup index, reuse
1455
+ // THAT Map directly (same object) instead of rescanning payload.individuals
1456
+ // a second time — every `byId.set` below then also keeps
1457
+ // idx.individualsById correct for upsertSource/recomputeSourceReliability's
1458
+ // later lookups in this same mutation, with no extra write.
1459
+ const idx = memoryIndexOf(payload);
1460
+ const byId = idx ? idx.individualsById : new Map(payload.individuals.map((i) => [i?.id, i]));
1345
1461
  const touched = [];
1346
1462
  const seen = new Set();
1347
1463
  const trustOptsById = new Map();
@@ -1370,10 +1486,15 @@ export async function appendFacts(dir, facts) {
1370
1486
  ...(f.justification && f.justification.length ? [{ prop: "mgx:factJustification", key: "justification", value: f.justification.join(" ") }] : []),
1371
1487
  ],
1372
1488
  };
1373
- // Upsert into BOTH the array (replace-in-place keeps order) and the index.
1374
- if (prior) payload.individuals[payload.individuals.indexOf(prior)] = ind;
1375
- else payload.individuals.push(ind);
1376
- byId.set(f.id, ind);
1489
+ // Upsert via the shared helper O(1) via the index (Object.assign in
1490
+ // place when `prior` exists, push+index when it's new), same as every
1491
+ // other upsert path now. Previously this did its own inline
1492
+ // `payload.individuals.indexOf(prior)` array scan on a re-assert within
1493
+ // the same batch — an O(n) fallback that could still blow up a batch
1494
+ // heavy with within-file duplicate triples; upsertIndividual has no
1495
+ // such case left.
1496
+ const stored = upsertIndividual(payload, ind);
1497
+ byId.set(f.id, stored);
1377
1498
  ids.push(f.id);
1378
1499
  if (!seen.has(f.id)) { seen.add(f.id); touched.push(f.id); }
1379
1500
  // Last-prepared-row-wins per id for the trust hook opts (mirrors the
package/src/viz.mjs CHANGED
@@ -207,9 +207,11 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle, memoryA
207
207
  #wrap { position: relative; width: 100vw; height: 100vh; overflow: hidden; }
208
208
  canvas { display: block; width: 100%; height: 100%; cursor: grab; touch-action: none; }
209
209
  canvas.grabbing { cursor: grabbing; }
210
- #hud { position: absolute; top: 12px; left: 12px; max-width: 42ch; background: rgba(20,22,30,0.82); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; line-height: 1.45; pointer-events: none; }
210
+ #hud { position: absolute; top: 12px; left: 12px; max-width: 34ch; background: rgba(20,22,30,0.82); border: 1px solid rgba(255,255,255,0.12); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; line-height: 1.45; pointer-events: none; }
211
211
  #hud b { color: #fff; }
212
- #hud .muted { color: #9aa1b0; }
212
+ #hud .muted { color: #9aa1b0; display: block; margin: 4px 0 8px; }
213
+ #hud button { pointer-events: auto; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #e7e9ee; border-radius: 5px; padding: 3px 9px; font-size: 12px; cursor: pointer; }
214
+ #hud button:hover { background: rgba(255,255,255,0.18); }
213
215
  #controls { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 10px; align-items: center; background: rgba(20,22,30,0.88); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 7px 12px; font-size: 12.5px; flex-wrap: wrap; max-width: min(86vw, 900px); }
214
216
  #controls .grp { display: flex; align-items: center; gap: 5px; }
215
217
  #controls button { background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #e7e9ee; border-radius: 5px; width: 22px; height: 22px; line-height: 1; cursor: pointer; font-size: 13px; }
@@ -223,14 +225,14 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle, memoryA
223
225
  #controls input[type="number"] { width: 3.6em; }
224
226
  #controls input[type="text"].search { width: 9em; }
225
227
  #controls .sep { width: 1px; align-self: stretch; background: rgba(255,255,255,0.14); margin: 0 2px; }
226
- #legend { position: absolute; top: 58px; left: 50%; transform: translateX(-50%); display: none; flex-wrap: wrap; gap: 6px; align-items: center; background: rgba(20,22,30,0.88); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 6px 10px; font-size: 11.5px; max-width: min(86vw, 900px); }
228
+ #legend { position: absolute; bottom: 12px; left: 12px; display: none; flex-wrap: wrap; gap: 6px; align-items: center; background: rgba(20,22,30,0.88); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 6px 10px; font-size: 11.5px; max-width: min(60vw, 640px); }
227
229
  #legend.show { display: flex; }
228
230
  #legend select { background: #14161e; color: #e7e9ee; border: 1px solid #2a2e42; border-radius: 5px; padding: 1px 4px; font: inherit; font-size: 11px; }
229
231
  #legend .chip { display: flex; align-items: center; gap: 4px; cursor: pointer; padding: 2px 6px; border-radius: 10px; border: 1px solid rgba(255,255,255,0.16); }
230
232
  #legend .chip.off { opacity: 0.4; }
231
233
  #legend .chip .swatch { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
232
234
  #legend .chip .n { color: #9aa1b0; }
233
- #panel { position: absolute; top: 12px; right: 12px; width: 280px; max-width: calc(100vw - 24px); background: rgba(20,22,30,0.92); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 12px 14px; font-size: 13px; line-height: 1.5; display: none; }
235
+ #panel { position: absolute; top: 12px; right: 404px; width: 280px; max-width: calc(100vw - 428px); background: rgba(20,22,30,0.92); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 12px 14px; font-size: 13px; line-height: 1.5; display: none; }
234
236
  #panel.show { display: block; }
235
237
  #panel h2 { margin: 0 0 6px; font-size: 14px; word-break: break-word; }
236
238
  #panel dl { margin: 8px 0 0; }
@@ -245,26 +247,26 @@ export function renderVizHtml({ nodes, edges, focus, payload, askBundle, memoryA
245
247
  #empty.show { display: flex; }
246
248
  #empty div { max-width: 46ch; color: #9aa1b0; }
247
249
  #empty b { color: #e7e9ee; }
248
- #ask { position: absolute; bottom: 12px; right: 12px; width: 340px; max-width: calc(100vw - 24px); background: rgba(20,22,30,0.92); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; }
249
- #ask h3 { margin: 0 0 6px; font-size: 12.5px; color: #9aa1b0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
250
- #ask .row { display: flex; gap: 6px; }
250
+ #ask { position: absolute; top: 12px; bottom: 12px; right: 12px; width: 380px; max-width: calc(100vw - 24px); background: rgba(20,22,30,0.92); border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; padding: 10px 12px; font-size: 12.5px; display: flex; flex-direction: column; }
251
+ #ask h3 { margin: 0 0 6px; font-size: 12.5px; color: #9aa1b0; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; flex: 0 0 auto; }
252
+ #ask .row { display: flex; gap: 6px; flex: 0 0 auto; }
251
253
  #askq { flex: 1; min-width: 0; background: #14161e; color: #e7e9ee; border: 1px solid #2a2e42; border-radius: 5px; padding: 6px 9px; font: inherit; font-size: 12.5px; }
252
254
  #askq:focus { outline: none; border-color: #7aa2f7; }
253
255
  #askq:disabled { opacity: 0.6; }
254
256
  #asksubmit { background: rgba(122,162,247,0.18); border: 1px solid #7aa2f7; color: #cfe0ff; border-radius: 5px; padding: 6px 12px; font-size: 12.5px; cursor: pointer; }
255
257
  #asksubmit:hover { background: rgba(122,162,247,0.3); }
256
- #askresult { margin-top: 8px; max-height: 32vh; overflow: auto; line-height: 1.55; color: #c0caf5; white-space: pre-wrap; }
258
+ #askresult { margin-top: 8px; flex: 1 1 auto; min-height: 0; overflow: auto; line-height: 1.55; color: #c0caf5; white-space: pre-wrap; }
257
259
  #askresult .q { color: #565f89; font-style: normal; margin-bottom: 3px; }
258
260
  #askresult.miss { color: #a9b1d6; font-style: italic; }
259
261
  #askresult .canon { margin-top: 6px; color: #6b7189; font-size: 11px; font-style: normal; border-top: 1px dashed rgba(255,255,255,0.1); padding-top: 5px; }
260
262
  #askresult .src { margin-top: 4px; color: #565f89; font-size: 10.5px; }
261
- #ask .hint { color: #6b7189; font-size: 11px; }
263
+ #ask .hint { color: #6b7189; font-size: 11px; flex: 0 0 auto; }
262
264
  </style>
263
265
  </head>
264
266
  <body>
265
267
  <div id="wrap">
266
268
  <canvas id="c"></canvas>
267
- <div id="hud"><b>tmct viz</b><br><span class="muted">drag to pan &middot; scroll to zoom &middot; click a node for details &middot; double-click to re-centre</span></div>
269
+ <div id="hud"><b>tmct viz</b><span class="muted">drag to pan &middot; scroll to zoom &middot; click a node for details &middot; double-click to re-centre</span><button id="resetview" title="reset pan/zoom to fit the current view">reset view</button></div>
268
270
  <div id="controls">
269
271
  <span class="grp"><span class="muted">depth</span><button id="depthdown" title="shallower">&minus;</button><b class="depthval" id="depthval"></b><button id="depthup" title="deeper">+</button></span>
270
272
  <span class="grp" id="typefilters"></span>
@@ -579,6 +581,10 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
579
581
  var dpr = window.devicePixelRatio || 1;
580
582
  var view = { scale: 1, x: 0, y: 0 };
581
583
  var selectedId = null;
584
+ // The full set of node ids a query answer actually resolved to (not just
585
+ // the single "primary" selectedId) — draw() rings every one of them so a
586
+ // multi-fact answer shows ALL the nodes it came from, not just one.
587
+ var highlightIds = new Set();
582
588
 
583
589
  function resize() {
584
590
  canvas.width = Math.floor(canvas.clientWidth * dpr);
@@ -667,6 +673,14 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
667
673
  ctx.lineWidth = Math.max(1.5, 2 * dpr); ctx.strokeStyle = "#e0af68";
668
674
  ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r) + 5 * dpr, 0, Math.PI * 2); ctx.stroke();
669
675
  }
676
+ // Every node the last "ask the graph" answer actually resolved to
677
+ // (frameQueryResult below) — a distinct green ring so a multi-fact
678
+ // answer's whole result set reads as one highlighted group, not just
679
+ // the single primary node selectedId/focus already mark.
680
+ if (highlightIds.has(n.id) && n.id !== selectedId) {
681
+ ctx.lineWidth = Math.max(1.5, 2 * dpr); ctx.strokeStyle = "#9ece6a";
682
+ ctx.beginPath(); ctx.arc(sp.x, sp.y, Math.max(1.5, r) + 4 * dpr, 0, Math.PI * 2); ctx.stroke();
683
+ }
670
684
  var showLabel = view.scale > 0.55 && labelMode !== "none" && (
671
685
  labelMode !== "smart"
672
686
  || n.id === selectedId || n.id === GRAPH.focus || n.id === hoverId
@@ -710,15 +724,28 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
710
724
  draw();
711
725
  }, { passive: false });
712
726
 
713
- function fitToVisible() {
714
- var vis = Array.from(visibleNodeIds()).map(function (id) { return pos.get(id); }).filter(Boolean);
715
- if (!vis.length) return;
716
- var minX = Math.min.apply(null, vis.map(function (p) { return p.x; })), maxX = Math.max.apply(null, vis.map(function (p) { return p.x; }));
717
- var minY = Math.min.apply(null, vis.map(function (p) { return p.y; })), maxY = Math.max.apply(null, vis.map(function (p) { return p.y; }));
727
+ // Shared bounding-box fit — pan/zoom so every position in "points" is framed
728
+ // with padding. fitToVisible/fitToIds are both thin wrappers naming WHICH
729
+ // positions to fit; the math itself lives here once.
730
+ function fitToPositions(points) {
731
+ if (!points.length) return;
732
+ var minX = Math.min.apply(null, points.map(function (p) { return p.x; })), maxX = Math.max.apply(null, points.map(function (p) { return p.x; }));
733
+ var minY = Math.min.apply(null, points.map(function (p) { return p.y; })), maxY = Math.max.apply(null, points.map(function (p) { return p.y; }));
718
734
  var w = Math.max(1, maxX - minX), h = Math.max(1, maxY - minY);
719
735
  view.scale = Math.min(4, Math.max(0.1, Math.min(canvas.width / dpr / (w + 160), canvas.height / dpr / (h + 160))));
720
736
  view.x = -(minX + maxX) / 2 * view.scale; view.y = -(minY + maxY) / 2 * view.scale;
721
737
  }
738
+ function fitToVisible() {
739
+ fitToPositions(Array.from(visibleNodeIds()).map(function (id) { return pos.get(id); }).filter(Boolean));
740
+ }
741
+ // Fit specifically to a query answer's own result set (not just "whatever
742
+ // recentre's re-walk happened to make visible") — a multi-fact answer's
743
+ // nodes can be spread wider than the default depth/nodeLimit view, so this
744
+ // is the precision framing step frameQueryResult calls after recentre.
745
+ function fitToIds(ids) {
746
+ fitToPositions(ids.map(function (id) { return pos.get(id); }).filter(Boolean));
747
+ }
748
+ document.getElementById("resetview").addEventListener("click", function () { fitToVisible(); draw(); });
722
749
 
723
750
  // ---- recentre: RE-WALK the FULL graph (via TERM_GRAPH — Bug 2's augmented
724
751
  // view, so a recentre reaches real concept-relation edges the same way
@@ -750,6 +777,24 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
750
777
  fitToVisible();
751
778
  return true;
752
779
  }
780
+
781
+ // Focus the graph on a QUERY ANSWER's own result set — every node it
782
+ // actually resolved to, not just one. Re-walks from the first valid id
783
+ // (recentre's existing mechanism, which already reaches most/all closely
784
+ // related result nodes), then fitToIds() precisely frames the full
785
+ // requested set — any id recentre's walk didn't reach simply has no
786
+ // position and drops out of the fit, an honest degrade, never a guess.
787
+ // Sets highlightIds so draw() rings every result node, not just the
788
+ // primary one selectedId/GRAPH.focus already mark.
789
+ function frameQueryResult(ids) {
790
+ var real = (ids || []).filter(function (id) { return walkGraph().byId.has(id); });
791
+ if (!real.length) return false;
792
+ if (!recentre(real[0])) return false;
793
+ fitToIds(real);
794
+ highlightIds = new Set(real);
795
+ selectedId = real[0];
796
+ return true;
797
+ }
753
798
  document.getElementById("edgekind").addEventListener("change", function (ev) {
754
799
  edgeKindMode = ev.target.value;
755
800
  var seed = GRAPH.focus;
@@ -866,22 +911,53 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
866
911
  var memHandle = hasMemEngine ? tmctMemoryAsk.createInMemoryStore() : null;
867
912
  if (memHandle) memHandle.payload = PAYLOAD;
868
913
 
869
- // Light, best-effort focus-follow for a memory-engine hit: factAnswer
870
- // returns rendered TEXT, not a resolved entity id (unlike ask.mjs's
871
- // envelope/matches) strip a leading question-word crust and try the
872
- // remainder as a term id. An honest "don't recentre" on no match, never a
873
- // wrong guess.
874
- function guessTermIdFromQuery(query) {
875
- if (!hasEngine || !hasMemEngine) return null;
914
+ // Placeholder is a real term from THIS graph, picked once per page load, so
915
+ // the hint stays honest ("what is X" where X actually resolves here) instead
916
+ // of a static example that may not exist in a given repo's graph. Native
917
+ // <input placeholder> behaviour (disappears on focus/typing, reappears when
918
+ // blank) is untouched — this only changes what text it starts with.
919
+ if (hasEngine && FULL_GRAPH) {
920
+ var termLabels = [];
921
+ walkGraph().byId.forEach(function (ind, id) {
922
+ if (id.indexOf("term:") === 0 && ind.label) termLabels.push(ind.label);
923
+ });
924
+ if (termLabels.length) {
925
+ askInput.placeholder = 'what is ' + termLabels[Math.floor(Math.random() * termLabels.length)];
926
+ }
927
+ }
928
+
929
+ // Best-effort focus-follow for a memory-engine hit: factAnswer returns
930
+ // rendered TEXT, not a list of resolved entity ids (unlike ask.mjs's
931
+ // envelope/matches). Two passes, both real-graph-checked, never a guessed
932
+ // id that doesn't exist:
933
+ // 1. every term node whose label appears in the ANSWER text — this is
934
+ // "the nodes that come back," e.g. "dog is a kind of animal" surfaces
935
+ // BOTH term:dog and term:animal, not just the one the question asked
936
+ // about, so a multi-fact answer highlights its whole result set.
937
+ // 2. if that finds nothing (e.g. a phrasing that doesn't echo a bare term
938
+ // label), fall back to stripping the QUESTION's own crust and trying
939
+ // the remainder as a single term id — the previous behaviour, kept as
940
+ // a fallback rather than replaced.
941
+ function findAnsweredTermIds(query, answerText) {
942
+ if (!hasEngine || !hasMemEngine) return [];
943
+ var hay = " " + String(answerText).toLowerCase() + " ";
944
+ var found = [];
945
+ walkGraph().byId.forEach(function (ind, id) {
946
+ if (id.indexOf("term:") !== 0) return;
947
+ var label = String(ind.label || "").toLowerCase();
948
+ if (label.length < 3) return; // skip too-short/noisy labels (dedupe/precision, not a real cap)
949
+ if (hay.indexOf(" " + label) !== -1 || hay.indexOf(label + " ") !== -1) found.push(id);
950
+ });
951
+ if (found.length) return found;
876
952
  var stripped = String(query).toLowerCase()
877
953
  .replace(/^(what|where|who|which|does|do|is|are)\b/, "")
878
954
  .replace(/\b(is|are|used for|do|does|mean|means|a|an|the)\b/g, " ")
879
955
  .replace(/[?.!]+$/, "")
880
956
  .replace(/\s+/g, " ")
881
957
  .trim();
882
- if (!stripped) return null;
958
+ if (!stripped) return [];
883
959
  var id = "term:" + tmctMemoryAsk.normFactTerm(stripped);
884
- return walkGraph().byId.has(id) ? id : null;
960
+ return walkGraph().byId.has(id) ? [id] : [];
885
961
  }
886
962
 
887
963
  function runAsk(query) {
@@ -893,8 +969,7 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
893
969
  if (fact && fact.text) {
894
970
  askOut.innerHTML = '<div class="q">&quot;' + esc(query) + '&quot;</div>' + esc(fact.text)
895
971
  + '<div class="src">answered from the full embedded memory graph (not just what\\'s currently drawn)</div>';
896
- var termId = guessTermIdFromQuery(query);
897
- if (termId && recentre(termId)) selectedId = termId;
972
+ frameQueryResult(findAnsweredTermIds(query, fact.text));
898
973
  draw();
899
974
  return;
900
975
  }
@@ -911,13 +986,13 @@ ${hasMemChat ? `<script>\n${memoryAskBundle}\n</script>` : ""}
911
986
  ? '<div class="canon">read as: ' + esc(envelope.canonical.english) + "</div>"
912
987
  : "";
913
988
  askOut.innerHTML = '<div class="q">&quot;' + esc(query) + '&quot;</div>' + esc(t.content) + canon;
914
- // Focus-follows-answer: prefer the resolved objMatch (the term the
915
- // question was actually ABOUT), else the first real match either way,
916
- // only if it's a genuine individual in the graph, never a guess.
917
- var targetId = (envelope.parsed && envelope.parsed.object && (envelope.matches || [])[0] && envelope.matches[0].id)
918
- || (envelope.matches && envelope.matches[0] && envelope.matches[0].id)
919
- || null;
920
- if (targetId && recentre(targetId)) { selectedId = targetId; }
989
+ // Focus-follows-answer: frame EVERY real match this answer resolved to
990
+ // (envelope.matches is already the full candidate list ask.mjs itself
991
+ // ranked previously only matches[0] recentred, silently dropping the
992
+ // rest of a multi-match answer's own result set), never a guess beyond
993
+ // what the engine itself actually returned.
994
+ var targetIds = (envelope.matches || []).map(function (m) { return m.id; }).filter(Boolean);
995
+ frameQueryResult(targetIds);
921
996
  draw();
922
997
  })();
923
998
  }