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

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/README.md CHANGED
@@ -392,21 +392,6 @@ once automatically after seeding and on demand; the entailed facts are
392
392
  **low-trust and retractable** (never outranking a stated fact) and this never runs
393
393
  on the chat's hot path.
394
394
 
395
- ## What tmct deliberately is NOT
396
-
397
- - **It is not an indexer.** tmct keeps no codebase index of its own. It
398
- consumes a graph via a provider seam (`fetchEntities` and friends) — building
399
- that graph is a different tool's job. tmct's job is the *conversation*.
400
- - **It is not a reasoning model.** Where it "reasons", it does so by
401
- *calculation* surfaced as prose ("there are a lot of tests for a codebase of
402
- that size"). It is deterministic, explainable, and cheap. Even its forward-chaining
403
- entailment (`tmct syllogise`) is mechanical OWL rule materialization applied
404
- offline, rule-by-rule and retractable, not an LLM. There is **no LLM anywhere
405
- in the product**. (An LLM-as-judge exists only in the offline eval harness
406
- that tunes tmct, see `SKILL_BENCHMARK_CEFR_ENGLISH.md`, never in the product path.)
407
- - **It never guesses silently.** When it cannot resolve your question it says
408
- so and nudges you toward a query it *can* answer.
409
-
410
395
  ## Install & use
411
396
 
412
397
  ```bash
@@ -111,8 +111,7 @@ This directory is **data only**; wiring it into seeding is the coordinator's
111
111
  the seon tier the ordering only affects the ConceptNet tail.
112
112
  3. **Provenance caveat:** `toFacts` currently hard-codes the provenance string
113
113
  `corpus:conceptnet <rel>`. If the coordinator wants seon facts tagged as their own
114
- source (`corpus:seon`), that is a one-line parametrisation of `toFacts` /
115
- `seedMemory` — out of scope for this data-only directory.
114
+ source (`corpus:seon`), that is a one-line parametrisation of `toFacts`
116
115
 
117
116
  ## Regenerating / extending
118
117
 
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.2",
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" : "";
@@ -2377,9 +2377,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
2377
2377
  * Sits strictly UPSTREAM of the pre-existing TEACH_PROPERTY_RE gap (the
2378
2378
  * wrapped-only surface that mints ANY bare complement word with zero
2379
2379
  * grounding check at all, e.g. "remember that zorp is florpy" —
2380
- * Verification finding 3, PLAN_TAUGHT_RELATIONS.md): this fallback does not
2381
- * close that gap (out of scope, a deliberate separate operator decision),
2382
- * only adds a properly-grounded alternative ahead of it.
2380
+ * Verification finding 3, PLAN_TAUGHT_RELATIONS.md): .
2383
2381
  *
2384
2382
  * IMPLEMENTATION ADJUSTMENT found live (not in the original plan text): a
2385
2383
  * bare "module is banana" (a KNOWN lexicon-noun subject, NO article, NO
@@ -2399,7 +2397,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
2399
2397
  * otherwise provide. "the cache is bespoke" and "Mary is female" both carry
2400
2398
  * one of those signals (the leading "the", and capitalization,
2401
2399
  * respectively); "module is banana" carries none. */
2402
- async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }) {
2400
+ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
2403
2401
  if (!memoryDir) return null;
2404
2402
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2405
2403
  if (!m) return null;
@@ -2410,7 +2408,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2410
2408
  // membership sentence, unknownSubjectFallback/unknownObjectFallback's own
2411
2409
  // territory (already had first refusal on it) — never misread as a property.
2412
2410
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
2413
- || (await isGroundedByFact(objectRaw, memoryDir))) return null;
2411
+ || (await isGroundedByFact(objectRaw, memoryDir, cache))) return null;
2414
2412
  // Subject-side groundedness — strip a leading "the"/"a"/"an" first
2415
2413
  // (normFactTerm's own article-strip, mirrored here) so "the cache" checks
2416
2414
  // groundedness under its real head noun "cache", the same spelling
@@ -2418,7 +2416,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2418
2416
  const bareSubject = subjectRaw.replace(/^(?:the|an?)\s+/i, "").trim() || subjectRaw;
2419
2417
  const hadArticle = bareSubject !== subjectRaw;
2420
2418
  const capitalized = /^[A-Z]/.test(bareSubject);
2421
- const factGrounded = await isGroundedByFact(bareSubject, memoryDir);
2419
+ const factGrounded = await isGroundedByFact(bareSubject, memoryDir, cache);
2422
2420
  const genericAnchor = GENERIC_ANCHOR_NOUNS.has(bareSubject.toLowerCase());
2423
2421
  // A bare (no article, no capitalization) subject grounded ONLY via the
2424
2422
  // static lexicon is exactly the pinned "module is banana" shape — see this
@@ -2745,7 +2743,7 @@ const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)
2745
2743
  * is tried against the remember-wrapped surface too. */
2746
2744
  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
2745
 
2748
- async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2746
+ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cache = null }) {
2749
2747
  // Tier 6 playtest: this lane read the raw, un-normalized query, so a closed
2750
2748
  // discourse-marker preamble ahead of a teach sentence ("howdy pardner,
2751
2749
  // remember that TaskController is fragile") corrupted TEACH_RE's own match —
@@ -3174,7 +3172,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3174
3172
  // assertTurn ITSELF records the "every" quantifier (point 3) on a plain
3175
3173
  // universal success, so every caller (this loop AND the top-level
3176
3174
  // declarative-sentence dispatch in runTurn) gets it uniformly.
3177
- const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon });
3175
+ const stored = await assertTurn(cand, { memoryDir, sessionId, focus: null, lexicon, cache });
3178
3176
  if (stored) return { text: stored.answer, via: "assert", miss: false };
3179
3177
  }
3180
3178
  // BUG "redis" fix (Feature A point 1): the real ACE grammar just declined
@@ -3183,7 +3181,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3183
3181
  // Covers BOTH the bare and the wrapped surface (payload is already
3184
3182
  // unwrapped either way) — see unknownSubjectFallback's own docblock for
3185
3183
  // the exact narrowing rules (object must still be known, etc.).
3186
- const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon });
3184
+ const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
3187
3185
  if (fallback) return fallback;
3188
3186
  // MIRROR mint fallback (Feature A, 2026-07-09 operator-authorized vocabulary-
3189
3187
  // growth extension): the known-subject/unknown-object asymmetry — tried
@@ -3191,7 +3189,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3191
3189
  // lexicon (or a prior taught fact) already grounds can mint a brand-new
3192
3190
  // object term. See unknownObjectFallback's own docblock for the exact
3193
3191
  // narrowing rules (the "both sides ungrounded" safety guard, etc.).
3194
- const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon });
3192
+ const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }, cache);
3195
3193
  if (objectFallback) return objectFallback;
3196
3194
  // ADJECTIVE-MINT fallback (PLAN_TAUGHT_RELATIONS.md Item 5, Phase 1): tried
3197
3195
  // right after unknownObjectFallback declines, so a grounded subject (static
@@ -3200,7 +3198,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3200
3198
  // docblock for the exact narrowing rules (the "both sides ungrounded"
3201
3199
  // safety guard, and why this must be a standalone function rather than
3202
3200
  // nested inside unknownSubjectFallback).
3203
- const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon });
3201
+ const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache);
3204
3202
  if (adjectiveFallback) return adjectiveFallback;
3205
3203
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
3206
3204
  // (a bare "X is deprecated" is never silently reified), and only after the
@@ -3271,7 +3269,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
3271
3269
  // replacement, exactly like "did" above — see ungroundedPairHint's own
3272
3270
  // docblock for why this is scoped to the "both sides ungrounded, fits the
3273
3271
  // X is/are Y shape" case only.
3274
- const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir);
3272
+ const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache);
3275
3273
  return {
3276
3274
  text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
3277
3275
  + `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
@@ -4205,11 +4203,27 @@ async function memoryFacts(memoryDir) {
4205
4203
  /** Load memory once and resolve every reified Fact into a TRUST-BEARING row
4206
4204
  * ({subject,predicate,object,provenance,trust,sourceTypes,…}) via core's
4207
4205
  * 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) {
4206
+ * graph shape (Wave-A memory/core.mjs). Lazy + failure-tolerated: no memory → [].
4207
+ *
4208
+ * `cache` (PLAN_GRAPH_SCAN.md "Query side: memoize the per-turn reload"): an
4209
+ * optional, caller-owned plain object (`{ rows: null }`, e.g. one runTurn call's
4210
+ * own `factRowsCache`) — when `cache.rows` is already populated, it's returned
4211
+ * directly, skipping loadMemory/readFactRows entirely; otherwise the result is
4212
+ * computed as before and stashed onto `cache.rows` for the next caller sharing
4213
+ * the same cache this turn. Absent/null (the default) reproduces today's
4214
+ * behavior exactly — a fresh, uncached reload every call — so every caller that
4215
+ * doesn't pass one is byte-for-byte unaffected. Never shared across turns or
4216
+ * with mutateMemory (see the plan doc for why a global cache was rejected).
4217
+ * `cache.reloads` is bumped once per REAL loadMemory/readFactRows call (never on
4218
+ * a cache hit) purely so a test can assert "computed once per turn" by call
4219
+ * count instead of wall-clock — see test/chat-factrows-cache.test.mjs. */
4220
+ async function factRows(memoryDir, cache = null) {
4221
+ if (cache?.rows) return cache.rows;
4210
4222
  try {
4211
4223
  const { loadMemory, readFactRows } = await import("./memory/core.mjs");
4212
- return readFactRows(await loadMemory(memoryDir));
4224
+ const rows = readFactRows(await loadMemory(memoryDir));
4225
+ if (cache) { cache.rows = rows; cache.reloads = (cache.reloads || 0) + 1; }
4226
+ return rows;
4213
4227
  } catch {
4214
4228
  return [];
4215
4229
  }
@@ -4612,7 +4626,7 @@ function uniqueFacts(rows) {
4612
4626
  * returns the handle's `payload` directly with ZERO fs calls — so a caller
4613
4627
  * that hands this a handle already carrying the embedded page's full graph
4614
4628
  * gets a pure, disk-free traversal, no bundle-time module shimming needed. */
4615
- export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4629
+ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}, cache = null) {
4616
4630
  let normFactTerm;
4617
4631
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
4618
4632
  const q = String(query).trim();
@@ -4632,7 +4646,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4632
4646
  const usedForQ = q.match(WHAT_USED_FOR_RE);
4633
4647
  if (usedForQ) {
4634
4648
  const variants = factTermVariants(normFactTerm, usedForQ[1]);
4635
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
4649
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:usedFor" && variants.has(f.object));
4636
4650
  if (hits.length) {
4637
4651
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4638
4652
  const lines = ranked.map(renderFactLine);
@@ -4653,7 +4667,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4653
4667
  const m = q.match(re);
4654
4668
  if (!m) continue;
4655
4669
  const variants = factTermVariants(normFactTerm, m[1]);
4656
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === predicate && variants.has(f.object));
4670
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === predicate && variants.has(f.object));
4657
4671
  if (!hits.length) continue; // try the next candidate marker, don't give up yet
4658
4672
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4659
4673
  const lines = ranked.map(renderFactLine);
@@ -4703,7 +4717,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4703
4717
  // factRows (trust+sourceIds-bearing), not the plain memoryFacts shape — the
4704
4718
  // bias-weighted ranking below needs each hit's sourceIds to resolve which
4705
4719
  // bundle it came from (memory/bias.mjs's biasForRow).
4706
- const subjectHits = (await factRows(memoryDir)).filter((f) => variants.has(f.subject));
4720
+ const subjectHits = (await factRows(memoryDir, cache)).filter((f) => variants.has(f.subject));
4707
4721
  let hits = predicate ? subjectHits.filter((f) => f.predicate === predicate) : subjectHits;
4708
4722
  if (!hits.length) {
4709
4723
  // The subject itself is known, but not under this specific relation —
@@ -4763,7 +4777,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4763
4777
  const canDo = q.match(WHAT_CAN_DO_RE);
4764
4778
  if (canDo) {
4765
4779
  const variants = factTermVariants(normFactTerm, canDo[1]);
4766
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
4780
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:capableOf" && variants.has(f.subject));
4767
4781
  if (!hits.length) return null;
4768
4782
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4769
4783
  const lines = ranked.map(renderFactLine);
@@ -4782,7 +4796,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4782
4796
  const hasQ = q.match(WHAT_HAS_RE);
4783
4797
  if (hasQ && !HAS_TEMPORAL_TAIL.has(hasQ[1].trim().split(/\s+/)[0]?.toLowerCase())) {
4784
4798
  const variants = factTermVariants(normFactTerm, hasQ[1]);
4785
- const hits = (await factRows(memoryDir)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
4799
+ const hits = (await factRows(memoryDir, cache)).filter((f) => f.predicate === "mgx:hasA" && variants.has(f.object));
4786
4800
  if (!hits.length) return null;
4787
4801
  const ranked = rankByBiasThenTrust(uniqueFacts(hits), biasByBundle);
4788
4802
  const lines = ranked.map(renderFactLine);
@@ -4819,7 +4833,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4819
4833
  : inheritsQ?.[1];
4820
4834
  if (inheritsObj) {
4821
4835
  const variants = factTermVariants(normFactTerm, inheritsObj);
4822
- const hits = (await factRows(memoryDir)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
4836
+ const hits = (await factRows(memoryDir, cache)).filter((f) => ISA_PREDICATES.has(f.predicate) && variants.has(f.object));
4823
4837
  // Only diverts on a REAL hit — same discipline every other reader in this
4824
4838
  // cascade follows (CAN_ASK_RE/WHAT_CAN_DO_RE/WHAT_HAS_RE above all `return
4825
4839
  // null` on zero hits too). A zero-hit case here must NOT invent its own
@@ -4847,7 +4861,7 @@ export async function factAnswer(memoryDir, query, envelope, miss, biasByBundle
4847
4861
  const know = q.match(KNOW_ABOUT_RE);
4848
4862
  if (know) {
4849
4863
  const variants = factTermVariants(normFactTerm, know[1]);
4850
- const rows = await factRows(memoryDir);
4864
+ const rows = await factRows(memoryDir, cache);
4851
4865
  // Bug E subtype walk (operator follow-up request, this session): a
4852
4866
  // cycle-safe BFS DOWNWARD over isa-family facts from the term's own
4853
4867
  // variants — every fact whose OBJECT is in the current frontier
@@ -5283,7 +5297,7 @@ function inheritsChain(graph, startId) {
5283
5297
  * "what kind of thing is an X" reports X's own type (subject-side first).
5284
5298
  * Miss-only and run AFTER factAnswer returns null, so it never shadows the
5285
5299
  * 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 = {}) {
5300
+ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focusLabel = null, biasByBundle = {}, cache = null) {
5287
5301
  if (!miss) return null;
5288
5302
  let normFactTerm;
5289
5303
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
@@ -5330,7 +5344,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5330
5344
  // fragile" is the SAME one-word-out-of-alignment problem the hedge adverbs
5331
5345
  // above were fixed for, just a dialect opener instead of a hedge adverb.
5332
5346
  const qHedge = q.replace(/^(?:actually|really|honestly|yeah\s+nah)\s*,?\s+/i, "");
5333
- const rows = await factRows(memoryDir);
5347
+ const rows = await factRows(memoryDir, cache);
5334
5348
  if (!rows.length) {
5335
5349
  // Tier-5 playtest fix (cycle 2), found live: with TRULY zero facts
5336
5350
  // remembered yet (a fresh session, nothing taught at all), the early
@@ -6411,10 +6425,10 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
6411
6425
  * only (a `/describe` names ONE code entity as the subject of its own facts,
6412
6426
  * not every fact that merely mentions it in passing) — null when memory holds
6413
6427
  * nothing about this subject. */
6414
- async function describedFacts(memoryDir, label, biasByBundle = {}) {
6428
+ async function describedFacts(memoryDir, label, biasByBundle = {}, cache = null) {
6415
6429
  let normFactTerm;
6416
6430
  try { ({ normFactTerm } = await import("./memory/core.mjs")); } catch { return null; }
6417
- const rows = await factRows(memoryDir);
6431
+ const rows = await factRows(memoryDir, cache);
6418
6432
  if (!rows.length) return null;
6419
6433
  const variants = factTermVariants(normFactTerm, label);
6420
6434
  const hits = rankByBiasThenTrust(rows.filter((f) => variants.has(f.subject)), biasByBundle);
@@ -7349,7 +7363,7 @@ async function relationForceAnswer(query, envelope, { graph, config, source, tem
7349
7363
  * shipped corpus/seon file (seonDefinitions), so it works without per-repo memory
7350
7364
  * seeding; the memory fact rows only ADD remembered "A is a X" examples when present.
7351
7365
  * Lazy + failure-tolerated throughout (chat.mjs ethos). Returns { text, instances }. */
7352
- async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates }) {
7366
+ async function conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates, cache = null }) {
7353
7367
  const rawTerm = conceptTermOf(query, envelope);
7354
7368
  if (!rawTerm) return null;
7355
7369
  let normFactTerm; let composeConcept; let CONCEPT_CLASS;
@@ -7369,7 +7383,7 @@ async function conceptForceAnswer(query, envelope, { graph, config, source, memo
7369
7383
  try { g = parseEntities(await source.fetchEntities(config)); } catch { g = null; }
7370
7384
  }
7371
7385
  if (!g) return null;
7372
- const rows = memoryDir ? await factRows(memoryDir) : [];
7386
+ const rows = memoryDir ? await factRows(memoryDir, cache) : [];
7373
7387
  let composed;
7374
7388
  try { composed = composeConcept(g, term, { definition, factRows: rows }); }
7375
7389
  catch { return null; }
@@ -7425,7 +7439,7 @@ async function entityOfKindInText(graph, expectedClass, answerText) {
7425
7439
  * otherwise the unchanged dispatchTool path (which also yields the no-graph error).
7426
7440
  * A hit updates the focus to the resolved object. Grammar miss / ToolError → a
7427
7441
  * 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 = {} }) {
7442
+ async function runAsk(query, { config, source, graph, focus, last, templates, memoryDir, sessionId = "", lexicon = null, env, trace, vocabHint = null, tel = null, biasByBundle = {}, cache = null }) {
7429
7443
  const ts = new Date().toISOString();
7430
7444
  // DISCOURSE ANAPHORA (CHATBENCH_006 levers 1+2): a follow-up like "which of those
7431
7445
  // are tested" / "how many of those" / "count them" filters or counts the PREVIOUS
@@ -7949,8 +7963,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7949
7963
  let bareMetaHit = null;
7950
7964
  if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape || reversePredicateShape)) {
7951
7965
  if (memoryDir) {
7952
- bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle))
7953
- ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle));
7966
+ bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle, cache))
7967
+ ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
7954
7968
  // HANDOVER.md 2026-07-10 item 10 (dropped-article gap): a bare "what is X"
7955
7969
  // with NO taught fact but a KNOWN curated corpus term ("what is cache", no
7956
7970
  // article) used to lose this exact same isConversationalCandidate race —
@@ -8042,8 +8056,8 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8042
8056
  // reified fact is stronger evidence than a transcript echo. Subject-side facts
8043
8057
  // first (factAnswer), then the reverse-membership read-back (factReadBack) so an
8044
8058
  // 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));
8059
+ const fact = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle, cache))
8060
+ ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle, cache));
8047
8061
  if (fact) {
8048
8062
  answer = fact.replace ? fact.text : `${answer}\n${fact.text}`;
8049
8063
  via = "fact";
@@ -8117,7 +8131,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8117
8131
  let conceptAllIds = null;
8118
8132
  let conceptPending = null;
8119
8133
  if (via === "composed" || via === "corpus/seon") {
8120
- const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates });
8134
+ const concept = await conceptForceAnswer(query, envelope, { graph, config, source, memoryDir, templates, cache });
8121
8135
  if (concept) {
8122
8136
  answer = concept.text; via = "corpus/seon"; recordMiss = false;
8123
8137
  conceptInstances = concept.instances;
@@ -8174,7 +8188,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
8174
8188
  // (4) #2 TEACH lane — a teach-shaped would-miss nothing above answered: route to
8175
8189
  // memory, or say what CAN be remembered (LOUD), never the wall / a silent drop.
8176
8190
  if (miss && recordMiss && via === "composed") {
8177
- const taught = await teachLane(query, { memoryDir, sessionId, lexicon });
8191
+ const taught = await teachLane(query, { memoryDir, sessionId, lexicon, cache });
8178
8192
  if (taught) {
8179
8193
  answer = taught.text; via = taught.via; recordMiss = taught.miss;
8180
8194
  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 +8554,7 @@ const GOAL_BY_COMMAND = {
8540
8554
  * field now (Bug F point 5) — mirrors runAsk's own `goal` field so
8541
8555
  * withGoalLine's short "Goal (inferred): …" line fires for command
8542
8556
  * dispatches too, not just ask()-parsed queries. */
8543
- async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {} }) {
8557
+ async function runCommand(line, { config, source, graph, focus, memoryDir, trace, narrate = false, tel = null, biasByBundle = {}, cache = null }) {
8544
8558
  const ts = new Date().toISOString();
8545
8559
  const sp = line.indexOf(" ");
8546
8560
  const name = (sp === -1 ? line.slice(1) : line.slice(1, sp)).toLowerCase();
@@ -8651,7 +8665,7 @@ async function runCommand(line, { config, source, graph, focus, memoryDir, trace
8651
8665
  // matching taught facts (subject === the resolved entity, trust-ranked)
8652
8666
  // under the code-map answer, mirroring the ask-path's fact-append pattern.
8653
8667
  if (name === "describe" && memoryDir) {
8654
- const facts = await describedFacts(memoryDir, ent.label, biasByBundle);
8668
+ const facts = await describedFacts(memoryDir, ent.label, biasByBundle, cache);
8655
8669
  if (facts) { answer = `${answer}\n${facts}`; note(trace, "source: memory facts (describedFacts) appended to the code-map answer"); }
8656
8670
  }
8657
8671
  return mk(answer, { resolvedIds: [ent.id], newFocus: nextFocus(graph, focus, ent) });
@@ -8699,7 +8713,7 @@ function renderAmbiguousAssert(line, ambiguous, normFactTerm) {
8699
8713
  * relation-shaped with 0-1 surviving readings), so this adds exactly one
8700
8714
  * cheap check ahead of the EXISTING, unchanged parseAce path below — every
8701
8715
  * single-reading sentence renders byte-identically to before. */
8702
- async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null }) {
8716
+ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, cache = null }) {
8703
8717
  try {
8704
8718
  const { parseAce, parseAceAmbiguous } = await import("./grammar/ace.mjs");
8705
8719
  // A session handle carries its own loaded lexicon (createSession loads it once);
@@ -8778,7 +8792,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null })
8778
8792
  const newSubj = normFactTerm(res.triples[0].subject);
8779
8793
  const newObj = normFactTerm(res.triples[0].object);
8780
8794
  const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
8781
- const priorEdges = (await factRows(memoryDir))
8795
+ const priorEdges = (await factRows(memoryDir, cache))
8782
8796
  .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f)
8783
8797
  && !(normFactTerm(f.subject) === newSubj && normFactTerm(f.object) === newObj))
8784
8798
  .map((f) => [normFactTerm(f.subject), normFactTerm(f.object)]);
@@ -8930,8 +8944,22 @@ function rewriteUsesAsBaseFrame(text) {
8930
8944
  return null;
8931
8945
  }
8932
8946
 
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 = {} } = {}) {
8947
+ 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
8948
  const line = String(input ?? "").trim();
8949
+ // PLAN_GRAPH_SCAN.md "Query side: memoize the per-turn reload": ONE fresh,
8950
+ // empty cache for this turn only — every factRows() reader reached from this
8951
+ // call (factAnswer, factReadBack, describedFacts, countFromFacts,
8952
+ // answerQuantifierRecall, assertTurn, teachLane's grounding fallbacks,
8953
+ // conceptForceAnswer, …) shares it via `ctx`/an explicit trailing arg, so the
8954
+ // first reader to run computes loadMemory+readFactRows once and every later
8955
+ // reader THIS TURN reuses that same result instead of reloading from disk.
8956
+ // Never persisted, never shared across turns or with mutateMemory (a global
8957
+ // cache was explicitly rejected — see the plan doc's own reasoning: a reader
8958
+ // could observe a mutator's half-written object). `injectedFactRowsCache` is a
8959
+ // TEST-ONLY escape hatch (default null, so every real caller gets a fresh one
8960
+ // exactly as before) — passing one in lets a test observe `.reloads` after the
8961
+ // call to assert the real load path ran exactly once this turn.
8962
+ const factRowsCache = injectedFactRowsCache ?? { rows: null };
8935
8963
  // The captured residue is used for RECOGNITION at every dispatch site below
8936
8964
  // (asBareCommand, conversationalTurn, assertTurn, the count lanes, runAsk);
8937
8965
  // the ORIGINAL `line` survives untouched for record.query/logLines fidelity
@@ -8959,7 +8987,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
8959
8987
  // pass one gets it computed here instead, so "try this vocabulary example" is
8960
8988
  // never wrong regardless of caller.
8961
8989
  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 };
8990
+ const ctx = { config, source, graph, focus, last, memoryDir, sessionId, templates, env, lexicon, trace, narrate, vocabHint: resolvedVocabHint, tel, biasByBundle, cache: factRowsCache };
8963
8991
  // A DISPATCHED turn (count / slash-command / ask) becomes the new "last answer"
8964
8992
  // that why/say-more re-renders; a conversational turn does not (it preserves it).
8965
8993
  // FINISH SEAM (PLAN_RESPONSE_FINISHING §"Where it lives"): every dispatched turn's
@@ -9051,7 +9079,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
9051
9079
  // authority gate declines (returns null) for anything answerCount should own,
9052
9080
  // so ordinary structural counts fall through completely unaffected.
9053
9081
  if (memoryDir) {
9054
- const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle);
9082
+ const quantifierRecall = await answerQuantifierRecall(memoryDir, workingLine, biasByBundle, factRowsCache);
9055
9083
  if (quantifierRecall != null) {
9056
9084
  note(trace, 'goal: recall a taught quantifier for a class-membership pair ("how many Xs are Ys")');
9057
9085
  note(trace, "lane: answerQuantifierRecall — matched HOW_MANY_ARE_RE with a subject tmct has facts about; literal recall, never real counting");
@@ -9079,7 +9107,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
9079
9107
  // ASSERTED vocabulary fact ("every class is a type" → "how many types" = the
9080
9108
  // class count). countFromFacts declines on a real graph kind, so ordinary
9081
9109
  // counts are unaffected; it only speaks for a remembered object noun.
9082
- const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle) : null;
9110
+ const viaFact = memoryDir ? await countFromFacts(graph, memoryDir, workingLine, biasByBundle, factRowsCache) : null;
9083
9111
  if (viaFact != null) {
9084
9112
  note(trace, 'goal: get a count of an asserted-vocabulary kind ("every X is a Y" inherited cardinality)');
9085
9113
  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
  }