@polycode-projects/the-mechanical-code-talker 1.8.15 → 1.8.17

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.8.15",
3
+ "version": "1.8.17",
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.",
package/src/ask-vocab.mjs CHANGED
@@ -644,6 +644,15 @@ export const FILLER_WORDS = Object.freeze([
644
644
  "please", "could you", "can you", "would you", "tell me", "i wonder",
645
645
  "just wondering", "quickly", "real quick", "kinda", "sorta",
646
646
  "btw", "by the way", "you",
647
+ // "quick q" (BENCHMARK_CONVERSATION_1.8.14.md item 11): the casual abbreviated
648
+ // sibling of GREETING_PREAMBLE_RE's own "quick question" clause (normalize.mjs)
649
+ // — that frame requires a delimiter immediately after the greeting word
650
+ // ("hey, quick question - …"), so it never matches "hey quick q, …" (no
651
+ // delimiter between "hey" and "quick q"). Filler-stripping instead — this
652
+ // list is matched word-boundary-anywhere, not anchored — closes the gap
653
+ // without needing GREETING_PREAMBLE_RE's own stricter delimiter-position
654
+ // shape.
655
+ "quick q",
647
656
  ]);
648
657
 
649
658
  /** Deictic/pronoun terms that refer to a context entity rather than naming one
package/src/ask.mjs CHANGED
@@ -3196,7 +3196,23 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3196
3196
  if (shape === "ask") {
3197
3197
  const subj = resolveTermOrContext(graph, parsed.subject, contextId);
3198
3198
  const obj = resolveTermOrContext(graph, parsed.object, contextId);
3199
- if (!subj.match || !obj.match) {
3199
+ // BENCHMARK_CONVERSATION_1.8.14.md item 7(b): a term that resolved only via a
3200
+ // TIED, ambiguous fuzzy/prose match (e.g. a garbled object phrase like "handler
3201
+ // I think" — trailing hedge noise a plain declarative leaked into the object
3202
+ // slot) used to fall straight through as if `obj`/`subj` were a confident
3203
+ // single match, so a randomly-first-picked TIED candidate (once, live, a raw
3204
+ // Commit whose "label" IS its short hash, "c3d4e5f6a1b2") rode straight into
3205
+ // the Yes/No render's "No — no <kind> edge found from A to B" sentence — an
3206
+ // internal id leaking into user-facing text off the back of an unresolved tie,
3207
+ // not a real resolution. Every OTHER shape in this file already declines
3208
+ // rather than guessing on a tie (resolveObject's own "never a guess" contract,
3209
+ // its docblock above); this shape is the one place that never checked the
3210
+ // `ambiguous` flag its own resolver already computed. Declining here (leaving
3211
+ // subjMatch unset, same as the `!subj.match || !obj.match` miss just below)
3212
+ // reaches the SAME "couldn't resolve one of the terms in this question" honest
3213
+ // miss render() already uses — never a confident wrong answer, and never a
3214
+ // raw internal id surfacing off a coin-flip pick among tied candidates.
3215
+ if (!subj.match || !obj.match || subj.ambiguous || obj.ambiguous) {
3200
3216
  return {
3201
3217
  matches: [], objMatch: obj.match, candidates: obj.candidates, traversal: null, ambiguous: false, answer: null,
3202
3218
  unresolvedPronoun: !!(subj.unresolvedPronoun || obj.unresolvedPronoun),
package/src/chat.mjs CHANGED
@@ -813,6 +813,13 @@ const CAPABILITY_PHRASES = [
813
813
  // and fall to the raw grammar wall instead of orientationAnswer — the same
814
814
  // question in every way that matters, just phrased with emphasis.
815
815
  /^(?:so,?\s+)?what can (?:you|u)(?:\s+(?:actually|really))? do\??$/i, /^(?:so,?\s+)?what do you(?:\s+(?:actually|really))? do\??$/i,
816
+ // BENCHMARK_CONVERSATION_1.8.14.md item 12 (pure-small-talk persona): "what
817
+ // can you actually help with" — the natural pivot from small talk into a
818
+ // capability question — used to miss every entry above, since none of them
819
+ // accept "help (me)? with" as a synonym tail for "do" (only bare "help" /
820
+ // "help me" without an object, the entry just below, was covered). Same
821
+ // "so,"/"actually"/"really" optional lead-in as the "do" pair above.
822
+ /^(?:so,?\s+)?what can (?:you|u)(?:\s+(?:actually|really))? help(?:\s+me)?\s+with\??$/i,
816
823
  /^help( me)?\??$/i, /^\?+$/,
817
824
  /^how do (i|you) work\??$/i, /^how does (this|it) work\??$/i,
818
825
  // unix-habit openers typed inside the REPL out of muscle memory — argv-only
@@ -938,6 +945,20 @@ const FEELINGS_PHRASES = [
938
945
  /^are you (?:sentient|conscious|self[- ]aware)\??$/i,
939
946
  /^can you feel(?:\s+(?:things|emotions|anything))?\??$/i,
940
947
  /^do you (?:feel|think|dream)\??$/i,
948
+ // BENCHMARK_CONVERSATION_1.8.14.md item 12 (pure-small-talk persona): direct
949
+ // personal questions ("how are you doing today", "what's your favorite
950
+ // color", "do you get bored") — the SAME family this closed set already
951
+ // exists for (a personal-life question about tmct, not a code-graph query)
952
+ // — mostly fell through to the bare grammar wall instead of this honest,
953
+ // on-brand decline, because none of them happened to match the four
954
+ // narrower entries above. Additive, same discipline as CAPABILITY_PHRASES'
955
+ // own "keeps growing as new natural phrasings surface" precedent.
956
+ /^how (?:are|r) (?:you|u) doing(?:\s+today)?\??$/i,
957
+ /^what(?:'s|s|\s+is) your (?:favou?rite\s+(?:colou?r|food|movie|book|band|song|number)|name)\??$/i,
958
+ /^do you (?:get|ever get) bored\??$/i,
959
+ /^what do you do for fun\??$/i,
960
+ /^can you (?:tell|make)\s+(?:me\s+)?(?:a\s+)?jokes?\??$/i,
961
+ /^do you (?:know|know anything|know much)\s+about\s+(?:movies?|sports?|music|tv|television)(?:\s+or\s+(?:movies?|sports?|music|tv|television))?\??$/i,
941
962
  ];
942
963
  /** The structural verbs/nouns that mark a near-miss code question (→ keep the
943
964
  * precise grammar hint, not the friendly nudge). */
@@ -1687,6 +1708,14 @@ const BARE_DECLARATIVE_RE = /^(?:every |each |all |a |an )?[\w-]+ (?:is|are) (?:
1687
1708
  /** Interrogative / auxiliary leads that make an "X is a Y"-shaped line a QUESTION
1688
1709
  * ("what is a cache", "is a module a component"), never a teach declarative. */
1689
1710
  const QUESTION_LEAD_RE = /^(?:what|who|which|where|when|why|how|is|are|do|does|did|can|could|should|would|will|has|have)\b/i;
1711
+ /** A plain declarative "X is a kind of Y" / "X is a Y" shape (subject-first, no
1712
+ * question lead — paired with QUESTION_LEAD_RE at every call site), tolerating
1713
+ * an infix "kind of"/"type of" (teachLane's own stripKindOf handles this same
1714
+ * infix ahead of its narrower recognizers — this is a cheap TRIGGER check only,
1715
+ * not a storage decision). Used by runAsk's relaxedTeachCollision guard (below)
1716
+ * to recognize when a query the ask engine "answered" via relaxation was
1717
+ * actually a teach-shaped sentence, not a real question. */
1718
+ const DECLARATIVE_KIND_OF_RE = /^(?:every\s+|each\s+|all\s+|a\s+|an\s+)?[\w-]+(?:\s+[\w-]+)?\s+(?:is|are)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?[\w-]+[.!]*$/i;
1690
1719
  /** A bare wh-word token, tested one word at a time against `hasMidSentenceInterrogative`'s
1691
1720
  * own tokenization below — never re-anchored, so it matches at ANY word position. */
1692
1721
  const MID_SENTENCE_WH_RE = /^(?:which|who|what|where|when|why|how)$/i;
@@ -1977,23 +2006,33 @@ function singularizeSurface(word) {
1977
2006
  const SOME_A_FEW_RE = /^(some|a few)\s+([\w-]+)\s+are\s+([\w-]+)$/i;
1978
2007
 
1979
2008
  /** "(every|each|all|a|an )?X is/are (a|an )?Y" — the shape the unknown-subject
1980
- * fallback recognizes (group 2 = X, group 3 = Y); group 1 (when present)
2009
+ * fallback recognizes (group 2 = X, group 4 = Y); group 1 (when present)
1981
2010
  * names the determiner, so the caller can tell a genuine "every" universal
1982
2011
  * apart from a singular/specific-entity "a"/bare reading (only "every" gets a
1983
2012
  * recorded quantifier here — this function's OWN caller passes it through to
1984
2013
  * teachFact; assertTurn, below, records the same "every" quantifier
1985
- * independently for the pre-existing ACE-success path). Y (the object) is a
1986
- * single token, same as parseAce's own copula fragments; X (the subject) is
1987
- * ONE OR TWO tokens (Tier-5 playtest fix: "vulcan gizmo is a tool"/"remember
1988
- * vulcan gizmo is a tool" fell straight to a "teach me" nudge that offered
1989
- * THIS EXACT phrasing as the fix, then itself failed when tried — a
1990
- * single-token-only subject was too narrow for a natural 2-word noun phrase,
1991
- * the same class of gap OWNS_TEACH_RE's own object had before its own
1992
- * Tier-5 widening, above). The greedy quantifier tries the longer 2-word
1993
- * subject first, backtracking to 1 word only if the tail doesn't then start
1994
- * with is/are the "is/are" anchor immediately after the subject removes
1995
- * the ambiguity a fully free-form multi-word subject would otherwise have. */
1996
- const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?([\w-]+)$/i;
2014
+ * independently for the pre-existing ACE-success path). Group 3 is the
2015
+ * copula itself (is/are) CAPTURED (not just matched) so a caller can tell
2016
+ * a genuinely PLURAL subject phrasing ("all men ARE mortal") apart from a
2017
+ * singular one ("redis IS a cache"): singularizing the subject before
2018
+ * storage is only ever correct for the former (see unknownSubjectFallback's
2019
+ * and unknownObjectFallback's own docblocks this is the "men"->"man" fix's
2020
+ * own safety gate, added after singularizing unconditionally was found live
2021
+ * to corrupt the pre-existing "redis is a cache" pinned case: "redis", a
2022
+ * proper noun that happens to end in "s", naively strips to "redi" if
2023
+ * singularized on an "is" sentence, where no such fold was ever needed).
2024
+ * Y (the object) is a single token, same as parseAce's own copula fragments;
2025
+ * X (the subject) is ONE OR TWO tokens (Tier-5 playtest fix: "vulcan gizmo
2026
+ * is a tool"/"remember vulcan gizmo is a tool" fell straight to a "teach me"
2027
+ * nudge that offered THIS EXACT phrasing as the fix, then itself failed when
2028
+ * tried — a single-token-only subject was too narrow for a natural 2-word
2029
+ * noun phrase, the same class of gap OWNS_TEACH_RE's own object had before
2030
+ * its own Tier-5 widening, above). The greedy quantifier tries the longer
2031
+ * 2-word subject first, backtracking to 1 word only if the tail doesn't then
2032
+ * start with is/are — the "is/are" anchor immediately after the subject
2033
+ * removes the ambiguity a fully free-form multi-word subject would
2034
+ * otherwise have. */
2035
+ const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(is|are)\s+(?:an?\s+)?([\w-]+)$/i;
1997
2036
 
1998
2037
  /** ISA-family predicates (mirrors the private ISA_PREDICATES set defined near
1999
2038
  * memoryFacts, below, at module scope — both are simple top-level consts
@@ -2092,7 +2131,7 @@ async function ungroundedPairHint(payload, lexicon, memoryDir) {
2092
2131
  if (!memoryDir) return "";
2093
2132
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2094
2133
  if (!m) return "";
2095
- const [, , subjectRaw, objectRaw] = m;
2134
+ const [, , subjectRaw, , objectRaw] = m;
2096
2135
  const { loadLexicon } = await import("./grammar/lexicon.mjs");
2097
2136
  const lex = lexicon || loadLexicon();
2098
2137
  if (await isGroundedTerm(subjectRaw, lex, memoryDir)) return "";
@@ -2144,12 +2183,29 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2144
2183
  if (!memoryDir) return null;
2145
2184
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2146
2185
  if (!m) return null;
2147
- const [, det, subjectRaw, objectRaw] = m;
2186
+ const [, det, subjectRaw, verb, objectRaw] = m;
2148
2187
  const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("./grammar/lexicon.mjs");
2149
2188
  const lex = lexicon || loadLexicon();
2150
2189
  // A known X's own ACE miss is a real miss — never silently reinterpreted here.
2151
2190
  if (classify(subjectRaw, lex)) return null;
2152
2191
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
2192
+ // Singularize the SUBJECT before storage, but ONLY on a genuinely PLURAL
2193
+ // phrasing ("all men ARE mortal", verb "are") — mirrors SOME_A_FEW_RE's own
2194
+ // singularizeSurface() call (above), which is safe unconditionally there
2195
+ // only because that shape's own regex requires "are" by construction. This
2196
+ // shape (UNKNOWN_SUBJECT_RE) also matches singular "is" sentences ("redis
2197
+ // is a cache"), where singularizing must NEVER run — "redis" naively folds
2198
+ // to "redi" under the same naive -s-strip, a real regression caught live by
2199
+ // the pinned "redis is a cache" tests when this fix first applied
2200
+ // unconditionally. So "all men are mortal" stores under "man" (matching
2201
+ // whatever "john is a man" already typed John as), not the raw plural
2202
+ // "men", while "redis is a cache" stores "redis" untouched. Without this,
2203
+ // findIsaChain's 2-hop proof (john->man, man->mortal) can never join, since
2204
+ // the second fact was keyed on a different string ("men") than the first
2205
+ // fact's object ("man"). classify(subjectRaw, lex) above already folds
2206
+ // plurals for the "is this a real miss" check, so singularizing only the
2207
+ // STORED value here is safe and doesn't change that check's behavior.
2208
+ const subject = /^are$/i.test(verb) ? singularizeSurface(subjectRaw) : subjectRaw;
2153
2209
  // Point 2 (mint-extension): a PRIOR turn's minted term, or a
2154
2210
  // GENERIC_ANCHOR_NOUNS root, grounds Y just as legitimately as a static
2155
2211
  // lexicon noun — both are always treated as class-level (never property),
@@ -2157,14 +2213,14 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2157
2213
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
2158
2214
  || (await isGroundedByFact(objectRaw, memoryDir))) {
2159
2215
  return teachFact(memoryDir, sessionId, {
2160
- subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
2216
+ subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
2161
2217
  });
2162
2218
  }
2163
2219
  if (lookupAdjective(lex, objectRaw)) {
2164
2220
  // property assertions are about ONE specific entity — never a quantifier,
2165
2221
  // even when phrased with "every" (point 3).
2166
2222
  return teachFact(memoryDir, sessionId, {
2167
- subject: subjectRaw, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw,
2223
+ subject, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw,
2168
2224
  });
2169
2225
  }
2170
2226
  return null; // Y unknown too — decline honestly, never guess
@@ -2245,9 +2301,9 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
2245
2301
  if (!memoryDir) return null;
2246
2302
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2247
2303
  if (!m) return null;
2248
- const [, det, subjectRaw, objectRaw] = m;
2304
+ const [, det, subjectRaw, verb, objectRaw] = m;
2249
2305
  if (!/^(?:every|each|all)$/i.test((det || "").trim())) return null; // class-level mint needs a real universal quantifier
2250
- const { loadLexicon } = await import("./grammar/lexicon.mjs");
2306
+ const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
2251
2307
  const lex = lexicon || loadLexicon();
2252
2308
  const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir);
2253
2309
  if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
@@ -2255,8 +2311,32 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
2255
2311
  if (objectGrounded) return null; // object already known — nothing to mint
2256
2312
  if (await objectReadsAsNonNoun(objectRaw)) return null; // reads like an adjective/verb, not a class noun — defer to unknownAdjectiveFallback
2257
2313
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
2314
+ // Singularize the SUBJECT before storage, ONLY on a genuinely PLURAL
2315
+ // phrasing (verb "are") — same bug class and same "is"-vs-"are" safety gate
2316
+ // as unknownSubjectFallback, above (see UNKNOWN_SUBJECT_RE's own docblock).
2317
+ // This is the fallback the canonical "all men are mortal" sentence
2318
+ // ACTUALLY goes through: "men" is grounded here via classify's own
2319
+ // IRREGULAR plural-fold (lexicon-core.json declares "man"/plural "men"), so
2320
+ // unknownSubjectFallback itself already declines for it (its own
2321
+ // classify(subjectRaw) check reads "men" as the known noun "man") and hands
2322
+ // off to this mirror fallback instead. A naive suffix-strip
2323
+ // (singularizeSurface — SOME_A_FEW_RE's own tool, reused as the fallback for
2324
+ // a genuinely novel REGULAR plural not in the lexicon, e.g. "zorps") can't
2325
+ // undo an IRREGULAR plural like "men" -> "man" — only the lexicon's own
2326
+ // noun table (lookupNoun, already resolved by isGroundedTerm/classify to
2327
+ // decide this subject counts as grounded in the first place) carries that
2328
+ // mapping, so storage must consult the SAME source of truth: prefer the
2329
+ // lexicon lemma, falling back to singularizeSurface for a subject grounded
2330
+ // only via a PRIOR taught fact (isGroundedByFact) or a regular-plural
2331
+ // lexicon fold. Gated on verb "are" for the identical reason
2332
+ // unknownSubjectFallback gates it: an already-singular grounded subject
2333
+ // that happens to end in "s" (e.g. a fact-grounded "gas") must never be
2334
+ // naively stripped on an "is" sentence ("every gas is a chemical").
2335
+ const subject = /^are$/i.test(verb)
2336
+ ? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
2337
+ : subjectRaw;
2258
2338
  return teachFact(memoryDir, sessionId, {
2259
- subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
2339
+ subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
2260
2340
  });
2261
2341
  }
2262
2342
 
@@ -2323,7 +2403,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2323
2403
  if (!memoryDir) return null;
2324
2404
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2325
2405
  if (!m) return null;
2326
- const [, , subjectRaw, objectRaw] = m;
2406
+ const [, , subjectRaw, , objectRaw] = m;
2327
2407
  const { loadLexicon, lookupNoun, classify } = await import("./grammar/lexicon.mjs");
2328
2408
  const lex = lexicon || loadLexicon();
2329
2409
  // Y already a known NOUN or a fact-grounded CLASS term — a genuine class-
@@ -2647,6 +2727,24 @@ function teachSuggestion(payload) {
2647
2727
  * way it already stood ahead of teachSuggestion/unknownSubjectFallback. */
2648
2728
  const TEACH_PRONOUN_RE = /^(?:every\s+|each\s+|all\s+|some\s+|a few\s+|a\s+|an\s+)?(you|i|it|they|he|she|we)\s+\S+/i;
2649
2729
 
2730
+ /** RETRACTION / NEGATION of an already-taught subClassOf fact (PLAN_SYLLOGIST.md
2731
+ * §3 finding 1 fix): "X is not a Y" (tolerating the same "kind/type of" infix
2732
+ * every other teach shape in this lane already tolerates — the "is/are"
2733
+ * variant, plus the "isn't"/"aren't" contractions). Deliberately narrow — the
2734
+ * SAFEST, most unambiguous negation phrasing only, matching a scoped 2-token
2735
+ * subject (mirrors UNKNOWN_SUBJECT_RE's own subject width), never a general
2736
+ * negation grammar. See the RETRACTION block's own comment in teachLane
2737
+ * (below) for why a regex match here is only a TRIGGER, never itself proof a
2738
+ * fact existed to retract — retractSubClassOf (src/syllogise.mjs) is the
2739
+ * actual authority. */
2740
+ const RETRACT_NOT_A_RE = /^(?:a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:(?:is|are)\s+not|isn't|aren't)\s+(?:an?\s+)?(?:(?:kind|type)\s+of\s+)?([\w-]+)$/i;
2741
+ /** "forget (that) X is a Y" — the second closed retraction phrasing (PLAN_SYLLOGIST.md
2742
+ * §3 finding 1). Never wrapped by TEACH_RE ("forget" isn't one of its
2743
+ * recognized lead verbs — remember/note/keep in mind/…), so this is matched
2744
+ * against the RAW (unwrapped) sentence, unlike RETRACT_NOT_A_RE above which
2745
+ * is tried against the remember-wrapped surface too. */
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
+
2650
2748
  async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2651
2749
  // Tier 6 playtest: this lane read the raw, un-normalized query, so a closed
2652
2750
  // discourse-marker preamble ahead of a teach sentence ("howdy pardner,
@@ -2733,6 +2831,61 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2733
2831
  };
2734
2832
  }
2735
2833
 
2834
+ // RETRACTION — "X is not a Y" / "forget that X is a Y" (PLAN_SYLLOGIST.md §3
2835
+ // finding 1 fix, 2026-07-12): the data-layer retraction primitive
2836
+ // (retractSubClassOf, src/syllogise.mjs, PLAN_SYLLOGIST.md §3, commit
2837
+ // f7b3644) shipped with no chat-level phrasing ever calling it — confirmed
2838
+ // live, 5 attempts across 2 playtest personas, 0 successes
2839
+ // (BENCHMARK_CONVERSATION_1.8.14.md). Tried here, right after the pronoun
2840
+ // guard, so a pronoun subject ("it is not an animal") still falls to that
2841
+ // guard's own decline first (TEACH_PRONOUN_RE matches ANY verb after the
2842
+ // pronoun, including "is not"), never reaching this block.
2843
+ //
2844
+ // TRIGGER, never itself the authority: RETRACT_NOT_A_RE/RETRACT_FORGET_RE
2845
+ // only recognize the SHAPE of a negation/retraction sentence — they say
2846
+ // nothing about whether subject⊑object was ever actually taught.
2847
+ // retractSubClassOf is asked for real and is the only thing that decides:
2848
+ // - found:true → a real stored (or entailed) fact existed and was
2849
+ // retracted (with its dependency-directed cascade) — confirmed here.
2850
+ // - found:false → subject⊑object was never a stored fact. This is left
2851
+ // to FALL THROUGH to the rest of teachLane's ordinary cascade below,
2852
+ // deliberately NOT answered with a bespoke "nothing to forget" message
2853
+ // — RETRACT_NOT_A_RE's shape also incidentally matches a NEGATED
2854
+ // PROPERTY claim ("the logger is not deprecated" — never subClassOf-
2855
+ // shaped at all, a pinned "genuine ceiling" case elsewhere in this
2856
+ // codebase, test/chatflow-tier5.test.mjs, that must keep its own
2857
+ // "I couldn't store that —" decline verbatim), so a bare "nothing
2858
+ // found" here must never claim a specific, possibly-wrong reason —
2859
+ // falling through preserves whatever honest response that OTHER shape
2860
+ // already gets, byte-identical, while still fully closing the real gap
2861
+ // (an already-taught fact's retraction, which now always succeeds).
2862
+ const retractSrc = (wrapped ?? raw).replace(/[.!?]+\s*$/, "");
2863
+ const retractSrcMidQuestion = memoryDir && !QUESTION_LEAD_RE.test(retractSrc)
2864
+ ? await hasMidSentenceInterrogative(retractSrc) : false;
2865
+ const retractNotMatch = memoryDir && !QUESTION_LEAD_RE.test(retractSrc) && !retractSrcMidQuestion
2866
+ ? retractSrc.match(RETRACT_NOT_A_RE) : null;
2867
+ const forgetSrc = raw.replace(/[.!?]+\s*$/, "");
2868
+ const retractForgetMatch = !retractNotMatch && memoryDir && !QUESTION_LEAD_RE.test(forgetSrc)
2869
+ ? forgetSrc.match(RETRACT_FORGET_RE) : null;
2870
+ const retractMatch = retractNotMatch || retractForgetMatch;
2871
+ if (retractMatch) {
2872
+ const retractSubject = retractMatch[1].trim();
2873
+ const retractObject = retractMatch[2].trim();
2874
+ const { retractSubClassOf } = await import("./syllogise.mjs");
2875
+ const result = await retractSubClassOf(memoryDir, retractSubject, retractObject);
2876
+ if (result.found) {
2877
+ const extra = result.count - 1; // beyond the target fact itself
2878
+ return {
2879
+ text: `noted — forgotten: "${retractSubject} is a kind of ${retractObject}" is no longer stored`
2880
+ + (extra > 0 ? ` (${extra} entailed fact${extra === 1 ? "" : "s"} that depended on it went too)` : "")
2881
+ + (result.truncated ? " — this cascade may not be complete (a lot depended on it); ask again if something still looks stale" : "")
2882
+ + ".",
2883
+ via: "retract", miss: false,
2884
+ };
2885
+ }
2886
+ // found:false — fall through to the rest of the cascade (see docblock above).
2887
+ }
2888
+
2736
2889
  // OWNERSHIP — "<Name> owns/maintains <X>", bare or remember-wrapped. The bare
2737
2890
  // form is double-gated: no interrogative lead, PLUS either side spelling a
2738
2891
  // Capitalized token — so the "who owns <X>" READ question and ordinary
@@ -4266,6 +4419,37 @@ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([
4266
4419
  const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
4267
4420
  const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
4268
4421
 
4422
+ /** "why is TaskController a handler" / "explain how you know TaskController is
4423
+ * a handler" — BENCHMARK_CONVERSATION_1.8.14.md item 9 (skeptical-power-user
4424
+ * persona): the syllogise-verified proof render (the isaAsk block below,
4425
+ * which cites the graph inherits-bridge / taught-fact chase / entailed
4426
+ * closure) only ever fired on the bare "is X a Y" yes/no form — a "why"/
4427
+ * "explain how you know" wrapper around the EXACT SAME question hit the bare
4428
+ * wall instead, even though the underlying answer is real and sourced.
4429
+ * Deliberately NOT a new answer path — a pure text rewrite back onto
4430
+ * ISA_ASK_RE's own "is X a Y" shape, mirroring CONFIRM_TAG_RE's own
4431
+ * "rewrite the wrapper away and re-try ISA_ASK_RE" approach just below (and
4432
+ * reused at both this file's ISA_ASK_RE match sites, the memory-only isa
4433
+ * check and the graph-grounded proof-chase). "why is X a Y" already leads
4434
+ * with "is"/"are" (ISA_ASK_RE's own anchor) — only the "why " lead needs
4435
+ * stripping; "explain how you know X is Y" leads with the SUBJECT in plain
4436
+ * declarative order, so it's reordered into "is X a Y" the same way
4437
+ * CONFIRM_TAG_RE reorders its own "X is Y, right?" tag. Returns ISA_ASK_RE's
4438
+ * own match array (or null) — a caller never needs to know which of the two
4439
+ * shapes fired, same discipline as `isaAsk` already applies to CONFIRM_TAG_RE. */
4440
+ const WHY_ISA_LEAD_RE = /^why\s+(?=(?:is|are)\b)/i;
4441
+ const EXPLAIN_HOW_YOU_KNOW_RE = /^explain\s+how\s+you\s+know\s+(?:that\s+)?(.+?)\s+(?:is|are)\s+(?:an?\s+)?(.+?)[?.!\s]*$/i;
4442
+ function matchWhyIsa(q) {
4443
+ const stripped = String(q || "").replace(WHY_ISA_LEAD_RE, "");
4444
+ if (stripped !== q) {
4445
+ const m = stripped.match(ISA_ASK_RE);
4446
+ if (m) return m;
4447
+ }
4448
+ const ehyk = String(q || "").match(EXPLAIN_HOW_YOU_KNOW_RE);
4449
+ if (ehyk) return `is ${ehyk[1].trim()} a ${ehyk[2].trim()}`.match(ISA_ASK_RE);
4450
+ return null;
4451
+ }
4452
+
4269
4453
  // Live-caught 2026-07-11 (follow-up to the "what is a kind of X" ambiguousParse
4270
4454
  // fix, commit 5c858bf): RELATION_FACT_YESNO_RE/RELATION_WHO_ASK_RE both capture a
4271
4455
  // middle "role" word and treat it as an arbitrary user-taught relation/rule NAME
@@ -4432,7 +4616,8 @@ async function factAnswer(memoryDir, query, envelope, miss, biasByBundle = {}) {
4432
4616
  if (!miss) return null;
4433
4617
 
4434
4618
  // (b) "is a module a component" — yes iff a remembered isa-family fact says so.
4435
- const isa = q.match(ISA_ASK_RE);
4619
+ // Also accepts "why is X a Y" / "explain how you know X is Y" — see matchWhyIsa.
4620
+ const isa = q.match(ISA_ASK_RE) || matchWhyIsa(q);
4436
4621
  if (isa) {
4437
4622
  const subj = factTermVariants(normFactTerm, isa[1]);
4438
4623
  const obj = factTermVariants(normFactTerm, isa[2]);
@@ -5429,7 +5614,11 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5429
5614
  // plain "is X a Y" form and re-tried when the raw query itself doesn't match —
5430
5615
  // see CONFIRM_TAG_RE's own docblock.
5431
5616
  const confirmTag = q.match(CONFIRM_TAG_RE);
5432
- const isaAsk = q.match(ISA_ASK_RE) || (confirmTag && `is ${confirmTag[1].trim()} a ${confirmTag[2].trim()}`.match(ISA_ASK_RE));
5617
+ // "why is X a Y" / "explain how you know X is Y" (BENCHMARK_CONVERSATION_1.8.14.md
5618
+ // item 9) reach the SAME sourced/verified proof chase below via matchWhyIsa's
5619
+ // rewrite — see its own docblock.
5620
+ const isaAsk = q.match(ISA_ASK_RE) || matchWhyIsa(q)
5621
+ || (confirmTag && `is ${confirmTag[1].trim()} a ${confirmTag[2].trim()}`.match(ISA_ASK_RE));
5433
5622
  if (isaAsk) {
5434
5623
  // Playtest sprint round 1 (2026-07-10): "is TaskController a validator then"
5435
5624
  // — the same trailing bare discourse tag item 8 fixed for metaTermOf's bare
@@ -5916,6 +6105,33 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5916
6105
  replace: true,
5917
6106
  };
5918
6107
  }
6108
+ // No property hit — but a bare "is X Y" (no article) is exactly the
6109
+ // same claim as "is X a Y" would have been had the user included the
6110
+ // article (ISA_ASK_RE's own territory, above): SKILL_BENCHMARK_CONVERSATION.md's
6111
+ // canonical syllogism ("john is a man" / "all men are mortal" / "is
6112
+ // john mortal") is asked this bare way, and "mortal" was taught as a
6113
+ // CLASS (rdfs:subClassOf), not a property — so it can only ever be
6114
+ // found by the SAME 2-hop TAUGHT-only findIsaChain proof-chase isaAsk
6115
+ // uses above, never by propertyMatch. Tried here as an ADDITIONAL
6116
+ // attempt, never a replacement: on no chain either, this falls through
6117
+ // to the ordinary property-miss handling just below, unchanged.
6118
+ {
6119
+ const { findIsaChain: chaseAdj, SUBCLASS_PREDICATE: SC_PREDICATE_ADJ, TYPE_PREDICATE: TYPE_PREDICATE_ADJ } = await import("./syllogise.mjs");
6120
+ const isTaughtAdj = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
6121
+ const chainSubClassRowsAdj = rows.filter((f) => f.predicate === SC_PREDICATE_ADJ && isTaughtAdj(f));
6122
+ const chainTypeRowsAdj = rows.filter((f) => f.predicate === TYPE_PREDICATE_ADJ && isTaughtAdj(f));
6123
+ const chainSubClassEdgesAdj = chainSubClassRowsAdj.map((f) => [f.subject, f.object]);
6124
+ const chainTypeEdgesAdj = chainTypeRowsAdj.map((f) => [f.subject, f.object]);
6125
+ const factForStepAdj = (step) => (step.predicate === SC_PREDICATE_ADJ ? chainSubClassRowsAdj : chainTypeRowsAdj)
6126
+ .find((f) => f.subject === step.subject && f.object === step.object);
6127
+ const adjObjVariants = factTermVariants(normFactTerm, adjective);
6128
+ for (const subj of subjVariants) {
6129
+ const chain = chaseAdj(subj, adjObjVariants, chainTypeEdgesAdj, chainSubClassEdgesAdj, { maxHops: 2 });
6130
+ if (!chain) continue;
6131
+ const premises = chain.map(factForStepAdj);
6132
+ if (premises.every(Boolean)) return { text: `yes — ${renderIsaChain(premises)}`, replace: true };
6133
+ }
6134
+ }
5919
6135
  // no hit on THIS property — never a guessed "no" (see
5920
6136
  // IS_ADJECTIVE_YESNO_RE's own docblock for why this stays silent on a
5921
6137
  // truth claim, unlike its ownership/general-verb siblings above). But a
@@ -6776,20 +6992,33 @@ async function describeWrapperAnswer(query, { config, source, focus, graph, tel
6776
6992
 
6777
6993
  /** COMPARE (HANDOVER.md 2026-07-12 "no comparison capability" item) — a scoped
6778
6994
  * v1: "how is X different from Y", "how does X differ from Y", "compare X and
6779
- * Y"/"compare X with/to Y", "what's the difference between X and Y". Five
6780
- * closed patterns, same discipline as DESCRIBE_WRAPPER_RE/DETAILED_HOW_WORKS_RE
6995
+ * Y"/"compare X with/to Y", "what's the difference between X and Y". Closed
6996
+ * patterns, same discipline as DESCRIBE_WRAPPER_RE/DETAILED_HOW_WORKS_RE
6781
6997
  * above — curated anchors, never a general "any two nouns" catch-all. Named
6782
6998
  * capture groups (a/b) so compareAnswer doesn't need to know which pattern
6783
6999
  * fired. Tried as a LAST-RESORT rescue (same call-site discipline as (4d)/(4e)
6784
7000
  * below) since neither ask.mjs's compositional grammar nor any existing lane
6785
7001
  * recognizes a two-entity comparison at all — there is nothing for this to
6786
- * shadow. */
7002
+ * shadow.
7003
+ *
7004
+ * BENCHMARK_CONVERSATION_1.8.14.md item 8 (rushed-dev persona): "how is Task
7005
+ * diff from User" / "whats the diff between TaskController and
7006
+ * UserController" both hit the bare wall — "diff" is a common casual synonym
7007
+ * for "different"/"difference" this table never accepted. Folded in as an
7008
+ * additional alternation on the two patterns it naturally pairs with (never
7009
+ * a new standalone pattern — "diff" means exactly what "different"/
7010
+ * "difference" already mean here, so it rides the SAME two anchors). The
7011
+ * "what's the diff between" anchor also picks up the bare no-apostrophe
7012
+ * "whats" contraction spelling (`what(?:'s|s|\s+is)`) while here — the same
7013
+ * tolerance MODULE_ORIENT_RE/MODULE_PURPOSE_RE's own docblock already
7014
+ * documents elsewhere in this file — since the persona's own verbatim input
7015
+ * used exactly that spelling. */
6787
7016
  const COMPARE_PATTERNS = [
6788
- /^how\s+(?:is|are)\s+(?<a>.+?)\s+different\s+from\s+(?<b>.+?)$/i,
7017
+ /^how\s+(?:is|are)\s+(?<a>.+?)\s+(?:different|diff)\s+from\s+(?<b>.+?)$/i,
6789
7018
  /^how\s+do(?:es)?\s+(?<a>.+?)\s+differ\s+from\s+(?<b>.+?)$/i,
6790
7019
  /^how\s+are\s+(?<a>.+?)\s+and\s+(?<b>.+?)\s+different$/i,
6791
7020
  /^compare\s+(?<a>.+?)\s+(?:and|with|to)\s+(?<b>.+?)$/i,
6792
- /^(?:what(?:'s|\s+is)\s+the\s+difference\s+between|difference\s+between)\s+(?<a>.+?)\s+and\s+(?<b>.+?)$/i,
7021
+ /^(?:what(?:'s|s|\s+is)\s+the\s+(?:difference|diff)\s+between|(?:difference|diff)\s+between)\s+(?<a>.+?)\s+and\s+(?<b>.+?)$/i,
6793
7022
  ];
6794
7023
 
6795
7024
  /** Strip a leading article — resolveSymbol (codegraph.mjs) has no article
@@ -7313,7 +7542,41 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7313
7542
  }
7314
7543
  }
7315
7544
  const answeredIds = (envelope?.matches || []).map((m) => m?.id).filter(Boolean);
7316
- const miss = envelope ? !!envelope.miss : true;
7545
+ const askMiss = envelope ? !!envelope.miss : true;
7546
+ // GRAPH-FACT-VS-TAUGHT-FACT PRECEDENCE (PLAN_SYLLOGIST.md finding 2 fix,
7547
+ // 2026-07-12): a plain declarative "X is a kind of Y" sentence whose SUBJECT
7548
+ // already names a real graph entity could get silently "answered" by the ask
7549
+ // engine's own relaxation cascade instead of ever reaching the teach lane.
7550
+ // Root cause, verified live against examples/mini-webapp: relaxParse's
7551
+ // DROP-UNMATCHED layer (src/ask.mjs "Layer 2") drops an unresolvable trailing
7552
+ // content word ("animal" — not a graph entity) entirely, turning "a Task is a
7553
+ // kind of animal" into the DIFFERENT, valid elliptical question "a Task is a
7554
+ // kind of" (a forward "inherits" ask), which genuinely answers "Record" (Task
7555
+ // really does inherit Record in the code graph) — envelope.miss comes back
7556
+ // false, so the miss-gated TEACH lane (4) below never even ran. That graph
7557
+ // answer must keep working as a QUESTION ("what is Task a kind of" must still
7558
+ // say Record) — the actual bug is only that a DECLARATIVE sentence
7559
+ // (subject-first, no question lead) with a genuine content word dropped by
7560
+ // relaxation never got a chance to be taught at all. Detected narrowly (all
7561
+ // three must hold): the ask engine's answer came via envelope.relaxed
7562
+ // (something was dropped/repaired, not a clean direct hit), the raw query is
7563
+ // NOT phrased as a question (QUESTION_LEAD_RE, no trailing "?"), and it
7564
+ // independently fits the closed "X is a kind of Y" teach shape
7565
+ // (DECLARATIVE_KIND_OF_RE). When all three hold, this turn is treated as a
7566
+ // would-miss so the ordinary miss-gated cascade below (including the
7567
+ // existing TEACH lane (4)) gets a real turn at it — a genuinely stored taught
7568
+ // fact wins over a repaired-away graph reading, COEXISTING with it rather
7569
+ // than replacing it: the graph fact itself is never touched and stays
7570
+ // answerable through its own question phrasing. If nothing below actually
7571
+ // stores it (recordMiss still true once the whole would-miss cascade has
7572
+ // run), the ORIGINAL ask-engine answer is restored unchanged just before the
7573
+ // record is built (see "COLLISION RESTORE" below) — never worse than today.
7574
+ const trimmedQuery = String(query).trim();
7575
+ const relaxedTeachCollision = !!(envelope?.relaxed?.dropped?.length) && !askMiss && memoryDir
7576
+ && !QUESTION_LEAD_RE.test(trimmedQuery) && !/\?\s*$/.test(trimmedQuery)
7577
+ && DECLARATIVE_KIND_OF_RE.test(trimmedQuery);
7578
+ const preCollisionAnswer = relaxedTeachCollision ? answer : null;
7579
+ const miss = askMiss || relaxedTeachCollision;
7317
7580
  // Answer provenance (W1): "composed" is the ask engine's productive band; the
7318
7581
  // orientation swap below is template wording, so those turns carry via:"template".
7319
7582
  let via = "composed";
@@ -7360,6 +7623,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7360
7623
  // itself already uses for a normally-parsed relation query, so the two
7361
7624
  // never disagree on the cases where both would fire).
7362
7625
  let deduced = deduceGoalFromParsed(envelope?.parsed);
7626
+ // Paired with preCollisionAnswer above: the goal line that matched the
7627
+ // ORIGINAL ask-engine answer, restored alongside it (COLLISION RESTORE,
7628
+ // below) if the would-miss cascade below never actually stores anything —
7629
+ // so a restored graph answer never carries a stale "teach a new fact" goal.
7630
+ const preCollisionDeduced = relaxedTeachCollision ? deduced : null;
7363
7631
  note(trace, `goal: ${deduced ?? "unclear — the phrasing didn't resolve to a known query shape"}`);
7364
7632
  // MISS handling. The intent lanes + short-miss are RECOGNIZER-gated on the query
7365
7633
  // text AND only consulted on a would-miss, so a real graph query — a hit, an honest
@@ -7518,8 +7786,25 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7518
7786
  // factAnswer itself declines for this shape (no metaTerm), so the only
7519
7787
  // change in practice is that factReadBack's (a2c) property lane gets a
7520
7788
  // chance to run before the orientation card claims the turn.
7521
- const bareWhatisShape = BARE_WHATIS_RE.test(String(query).trim());
7522
- const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(String(query).trim());
7789
+ // BENCHMARK_CONVERSATION_1.8.14.md item 11: "hey quick q, what is TaskController"
7790
+ // (a leading filler clause) and "whats UserController" (a no-apostrophe
7791
+ // contraction) both used to test the RAW `query` text against BARE_WHATIS_RE/
7792
+ // IS_ADJECTIVE_YESNO_RE's own anchored `^what`/`^is` gate directly — so neither
7793
+ // ever reached this lane at all, even though normalizeQuery (already imported
7794
+ // here, same NO_FOCUS_WHATS_IN_HERE_RE precedent above in this file) already
7795
+ // resolves both: its CONTRACTIONS table expands "whats" -> "what is", and its
7796
+ // stripFillerWords pass (commit 282c010, now covering "quick q" too — see
7797
+ // FILLER_WORDS' own docblock, ask-vocab.mjs) peels the leading "hey quick q,"
7798
+ // clause. Composed from those two EXISTING mechanisms rather than a third:
7799
+ // `gateQuery` is normalizeQuery's output, used for the shape gate AND the
7800
+ // lookups it feeds (metaTermOf/factAnswer/factReadBack/curatedDefinitionAnswer)
7801
+ // so a filler-wrapped/no-apostrophe query resolves to the SAME real answer its
7802
+ // clean form already does — never a new answer path. Idempotent on already-
7803
+ // clean text (normalizeQuery's own contract), so the ordinary case is
7804
+ // byte-unchanged.
7805
+ const gateQuery = normalizeQuery(String(query));
7806
+ const bareWhatisShape = BARE_WHATIS_RE.test(gateQuery);
7807
+ const isAdjectiveShape = IS_ADJECTIVE_YESNO_RE.test(gateQuery);
7523
7808
  // HANDOVER.md 2026-07-12 CamelCase finding: `isBareCamelCaseMetaQuestion` (see
7524
7809
  // its own docblock, above isConversational) OR's in alongside
7525
7810
  // isConversationalCandidate for THIS lane only — a bare "what is TaskController"
@@ -7531,12 +7816,12 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7531
7816
  // alone: the `else if (isConversationalCandidate)` orientation-card fallback
7532
7817
  // further down is UNCHANGED, so a CamelCase term with no real hit still falls
7533
7818
  // through to its existing miss handling, never the generic orientation card.
7534
- const isBareCamelCaseWhatisCandidate = conversationalCandidateBaseGate && isBareCamelCaseMetaQuestion(query);
7819
+ const isBareCamelCaseWhatisCandidate = conversationalCandidateBaseGate && isBareCamelCaseMetaQuestion(gateQuery);
7535
7820
  let bareMetaHit = null;
7536
7821
  if ((isConversationalCandidate || isBareCamelCaseWhatisCandidate) && (bareWhatisShape || isAdjectiveShape)) {
7537
7822
  if (memoryDir) {
7538
- bareMetaHit = (await factAnswer(memoryDir, query, envelope, miss, biasByBundle))
7539
- ?? (await factReadBack(memoryDir, query, envelope, miss, graph, newFocus?.label, biasByBundle));
7823
+ bareMetaHit = (await factAnswer(memoryDir, gateQuery, envelope, miss, biasByBundle))
7824
+ ?? (await factReadBack(memoryDir, gateQuery, envelope, miss, graph, newFocus?.label, biasByBundle));
7540
7825
  // HANDOVER.md 2026-07-10 item 10 (dropped-article gap): a bare "what is X"
7541
7826
  // with NO taught fact but a KNOWN curated corpus term ("what is cache", no
7542
7827
  // article) used to lose this exact same isConversationalCandidate race —
@@ -7546,7 +7831,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7546
7831
  // lane — an unknown bare term still falls through to the ordinary
7547
7832
  // orientation card, exactly as before.
7548
7833
  if (!bareMetaHit) {
7549
- const def = await curatedDefinitionAnswer(query, envelope, { memoryDir, lexicon });
7834
+ const def = await curatedDefinitionAnswer(gateQuery, envelope, { memoryDir, lexicon });
7550
7835
  if (def) bareMetaHit = { text: def.text, replace: true };
7551
7836
  }
7552
7837
  }
@@ -7563,7 +7848,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7563
7848
  // graph but no memoryDir at all, so gating this on memoryDir too would
7564
7849
  // silently never fire in the one harness this fix specifically targets.
7565
7850
  if (!bareMetaHit && graph) {
7566
- const term = metaTermOf(query, envelope);
7851
+ const term = metaTermOf(gateQuery, envelope);
7567
7852
  if (term) {
7568
7853
  const { metaFallbackEntityAnswer } = await import("./ask.mjs");
7569
7854
  const fallback = metaFallbackEntityAnswer(graph, term);
@@ -7955,6 +8240,23 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
7955
8240
  }
7956
8241
  }
7957
8242
  }
8243
+ // COLLISION RESTORE (pairs with relaxedTeachCollision, above): the whole
8244
+ // would-miss cascade above (TEACH lane included) has now had its turn. If
8245
+ // nothing in it actually stored/answered anything (recordMiss is still
8246
+ // true), fall back to the ORIGINAL ask-engine answer computed before this
8247
+ // turn was forced into the would-miss cascade — a real graph answer beats
8248
+ // any generic miss/wall text, and this is never worse than the turn would
8249
+ // have rendered before this fix existed. If the TEACH lane (or any other
8250
+ // lane) DID answer for real, recordMiss is already false here and this is a
8251
+ // no-op — the taught fact's own confirmation stands, coexisting with the
8252
+ // (untouched) graph fact.
8253
+ if (relaxedTeachCollision && recordMiss) {
8254
+ answer = preCollisionAnswer;
8255
+ via = "composed";
8256
+ recordMiss = false;
8257
+ deduced = preCollisionDeduced;
8258
+ note(trace, "lane: COLLISION RESTORE — the teach-shaped declarative wasn't storable after all; the original ask-engine (graph) answer stands, untouched");
8259
+ }
7958
8260
  // W5 (flag-gated, default OFF): an unknown-term miss may consult the LOCAL
7959
8261
  // committed corpus slice — a hit APPENDS a grounded, licence-cited aside under
7960
8262
  // the honest miss (the miss itself stands; the aside is context, not an answer).