@polycode-projects/the-mechanical-code-talker 1.8.14 → 1.8.16

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.
Files changed (3) hide show
  1. package/README.md +34 -2
  2. package/package.json +1 -1
  3. package/src/chat.mjs +150 -23
package/README.md CHANGED
@@ -79,6 +79,12 @@ the connecting sentence itself, citing both sources. The `source: ace:chat:…`
79
79
  part is a real provenance receipt. Every fact tmct stores records where it
80
80
  came from and when (more on that below).
81
81
 
82
+ The first `tell()` call above replies too: `noted — remembered 1 fact:
83
+ controller rdfs:subClassOf handler (controller is a type of handler)`. The
84
+ part in parentheses is a paraphrase. tmct generates it and checks it against
85
+ its own inference rules before showing it, so it never just guesses at
86
+ prose.
87
+
82
88
  ```
83
89
  $ tmct
84
90
  tmct> what talks to the payment module?
@@ -188,6 +194,23 @@ exactly. It checks the type itself and its subclasses first. Only if nothing
188
194
  matches there does it widen to a related type, and when it does, it says so
189
195
  plainly rather than presenting the looser match as exact.
190
196
 
197
+ **Comparing two things.** "compare TaskController and UserController" or "how
198
+ is TaskController different from UserController" lines up both entities'
199
+ shared and differing edges side by side: *"Comparing TaskController and
200
+ UserController (both Class): inherits [seon:hasSuperType]: TaskController (1)
201
+ -> Controller; UserController (1) -> Controller"*. Every row is a real edge
202
+ or attribute from the graph, never a hand-written diff.
203
+
204
+ **Up-refining to a containing module.** A class rarely has its own
205
+ symbol-precise commit or import record. "who touched TaskController" answers
206
+ from its containing module's real history instead of a confident-looking but
207
+ wrong "nothing touched it".
208
+
209
+ **Following a list.** tmct remembers the last list it gave you. After
210
+ "which modules import src/core/model.mjs", "which of those are tested" or
211
+ "how many of those" resolves "those"/"them" against that list, not a fresh,
212
+ unresolved pronoun.
213
+
191
214
  **Synonyms and everyday phrasing.** tmct matches many of the words people
192
215
  actually use for the same idea, from a curated synonym list plus a filtered
193
216
  ConceptNet slice. A slightly different word for the same concept still
@@ -242,6 +265,11 @@ longer, multi-sentence account instead of one line. Every sentence in it is
242
265
  lifted from a real graph edge, attribute, or taught fact — never generated
243
266
  free text.
244
267
 
268
+ The wording varies a little too. A small, curated, deterministic pool swaps a
269
+ handful of connector words, like "defined in", "located in", or "found in".
270
+ The same fact doesn't read identically for every entity, but the same
271
+ question against the same entity always renders the same way.
272
+
245
273
  ```
246
274
  $ node bin/tmct.mjs chat --repo examples/mini-webapp --ephemeral
247
275
  tmct> give me a detailed overview of how the Store works
@@ -314,8 +342,8 @@ from the environment. There's no CLI flag yet — this is a library-level
314
342
  option for now, newer and less exercised than the default backend.
315
343
 
316
344
  Teaching isn't limited to the ACE grammar's fixed shapes. Tell tmct an
317
- arbitrary fact, like "margo eats ribs", and it mints a fact you can later ask
318
- about directly: "what does margo eat", or "does margo eat ribs".
345
+ arbitrary fact, like "margo really eats ribs", and it mints a fact you can
346
+ later ask about directly: "what does margo eat", or "does margo eat ribs".
319
347
 
320
348
  New vocabulary compounds as you teach it. "redis is a cache" mints "redis" as
321
349
  a class-level concept even though it was never in the built-in lexicon, and a
@@ -326,6 +354,10 @@ to ground one side first. Quantified teaching works too: "some functions are
326
354
  risky" stores the quantifier, and a later "how many functions are risky"
327
355
  answers "A few."
328
356
 
357
+ Once you've taught a few facts, "how many facts are there" counts them back.
358
+ That's the same count phrasing a code graph answers "how many classes are
359
+ there" with, just now reading tmct's own memory.
360
+
329
361
  ### Provenance and trust
330
362
 
331
363
  Every fact and text block records **where it came from and when**. Sources are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.8.14",
3
+ "version": "1.8.16",
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/chat.mjs CHANGED
@@ -1977,23 +1977,33 @@ function singularizeSurface(word) {
1977
1977
  const SOME_A_FEW_RE = /^(some|a few)\s+([\w-]+)\s+are\s+([\w-]+)$/i;
1978
1978
 
1979
1979
  /** "(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)
1980
+ * fallback recognizes (group 2 = X, group 4 = Y); group 1 (when present)
1981
1981
  * names the determiner, so the caller can tell a genuine "every" universal
1982
1982
  * apart from a singular/specific-entity "a"/bare reading (only "every" gets a
1983
1983
  * recorded quantifier here — this function's OWN caller passes it through to
1984
1984
  * 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;
1985
+ * independently for the pre-existing ACE-success path). Group 3 is the
1986
+ * copula itself (is/are) CAPTURED (not just matched) so a caller can tell
1987
+ * a genuinely PLURAL subject phrasing ("all men ARE mortal") apart from a
1988
+ * singular one ("redis IS a cache"): singularizing the subject before
1989
+ * storage is only ever correct for the former (see unknownSubjectFallback's
1990
+ * and unknownObjectFallback's own docblocks this is the "men"->"man" fix's
1991
+ * own safety gate, added after singularizing unconditionally was found live
1992
+ * to corrupt the pre-existing "redis is a cache" pinned case: "redis", a
1993
+ * proper noun that happens to end in "s", naively strips to "redi" if
1994
+ * singularized on an "is" sentence, where no such fold was ever needed).
1995
+ * Y (the object) is a single token, same as parseAce's own copula fragments;
1996
+ * X (the subject) is ONE OR TWO tokens (Tier-5 playtest fix: "vulcan gizmo
1997
+ * is a tool"/"remember vulcan gizmo is a tool" fell straight to a "teach me"
1998
+ * nudge that offered THIS EXACT phrasing as the fix, then itself failed when
1999
+ * tried — a single-token-only subject was too narrow for a natural 2-word
2000
+ * noun phrase, the same class of gap OWNS_TEACH_RE's own object had before
2001
+ * its own Tier-5 widening, above). The greedy quantifier tries the longer
2002
+ * 2-word subject first, backtracking to 1 word only if the tail doesn't then
2003
+ * start with is/are — the "is/are" anchor immediately after the subject
2004
+ * removes the ambiguity a fully free-form multi-word subject would
2005
+ * otherwise have. */
2006
+ 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
2007
 
1998
2008
  /** ISA-family predicates (mirrors the private ISA_PREDICATES set defined near
1999
2009
  * memoryFacts, below, at module scope — both are simple top-level consts
@@ -2092,7 +2102,7 @@ async function ungroundedPairHint(payload, lexicon, memoryDir) {
2092
2102
  if (!memoryDir) return "";
2093
2103
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2094
2104
  if (!m) return "";
2095
- const [, , subjectRaw, objectRaw] = m;
2105
+ const [, , subjectRaw, , objectRaw] = m;
2096
2106
  const { loadLexicon } = await import("./grammar/lexicon.mjs");
2097
2107
  const lex = lexicon || loadLexicon();
2098
2108
  if (await isGroundedTerm(subjectRaw, lex, memoryDir)) return "";
@@ -2144,12 +2154,29 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2144
2154
  if (!memoryDir) return null;
2145
2155
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2146
2156
  if (!m) return null;
2147
- const [, det, subjectRaw, objectRaw] = m;
2157
+ const [, det, subjectRaw, verb, objectRaw] = m;
2148
2158
  const { loadLexicon, lookupNoun, lookupAdjective, classify } = await import("./grammar/lexicon.mjs");
2149
2159
  const lex = lexicon || loadLexicon();
2150
2160
  // A known X's own ACE miss is a real miss — never silently reinterpreted here.
2151
2161
  if (classify(subjectRaw, lex)) return null;
2152
2162
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
2163
+ // Singularize the SUBJECT before storage, but ONLY on a genuinely PLURAL
2164
+ // phrasing ("all men ARE mortal", verb "are") — mirrors SOME_A_FEW_RE's own
2165
+ // singularizeSurface() call (above), which is safe unconditionally there
2166
+ // only because that shape's own regex requires "are" by construction. This
2167
+ // shape (UNKNOWN_SUBJECT_RE) also matches singular "is" sentences ("redis
2168
+ // is a cache"), where singularizing must NEVER run — "redis" naively folds
2169
+ // to "redi" under the same naive -s-strip, a real regression caught live by
2170
+ // the pinned "redis is a cache" tests when this fix first applied
2171
+ // unconditionally. So "all men are mortal" stores under "man" (matching
2172
+ // whatever "john is a man" already typed John as), not the raw plural
2173
+ // "men", while "redis is a cache" stores "redis" untouched. Without this,
2174
+ // findIsaChain's 2-hop proof (john->man, man->mortal) can never join, since
2175
+ // the second fact was keyed on a different string ("men") than the first
2176
+ // fact's object ("man"). classify(subjectRaw, lex) above already folds
2177
+ // plurals for the "is this a real miss" check, so singularizing only the
2178
+ // STORED value here is safe and doesn't change that check's behavior.
2179
+ const subject = /^are$/i.test(verb) ? singularizeSurface(subjectRaw) : subjectRaw;
2153
2180
  // Point 2 (mint-extension): a PRIOR turn's minted term, or a
2154
2181
  // GENERIC_ANCHOR_NOUNS root, grounds Y just as legitimately as a static
2155
2182
  // lexicon noun — both are always treated as class-level (never property),
@@ -2157,14 +2184,14 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
2157
2184
  if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
2158
2185
  || (await isGroundedByFact(objectRaw, memoryDir))) {
2159
2186
  return teachFact(memoryDir, sessionId, {
2160
- subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
2187
+ subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
2161
2188
  });
2162
2189
  }
2163
2190
  if (lookupAdjective(lex, objectRaw)) {
2164
2191
  // property assertions are about ONE specific entity — never a quantifier,
2165
2192
  // even when phrased with "every" (point 3).
2166
2193
  return teachFact(memoryDir, sessionId, {
2167
- subject: subjectRaw, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw,
2194
+ subject, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw,
2168
2195
  });
2169
2196
  }
2170
2197
  return null; // Y unknown too — decline honestly, never guess
@@ -2245,9 +2272,9 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
2245
2272
  if (!memoryDir) return null;
2246
2273
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2247
2274
  if (!m) return null;
2248
- const [, det, subjectRaw, objectRaw] = m;
2275
+ const [, det, subjectRaw, verb, objectRaw] = m;
2249
2276
  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");
2277
+ const { loadLexicon, lookupNoun } = await import("./grammar/lexicon.mjs");
2251
2278
  const lex = lexicon || loadLexicon();
2252
2279
  const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir);
2253
2280
  if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
@@ -2255,8 +2282,32 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
2255
2282
  if (objectGrounded) return null; // object already known — nothing to mint
2256
2283
  if (await objectReadsAsNonNoun(objectRaw)) return null; // reads like an adjective/verb, not a class noun — defer to unknownAdjectiveFallback
2257
2284
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
2285
+ // Singularize the SUBJECT before storage, ONLY on a genuinely PLURAL
2286
+ // phrasing (verb "are") — same bug class and same "is"-vs-"are" safety gate
2287
+ // as unknownSubjectFallback, above (see UNKNOWN_SUBJECT_RE's own docblock).
2288
+ // This is the fallback the canonical "all men are mortal" sentence
2289
+ // ACTUALLY goes through: "men" is grounded here via classify's own
2290
+ // IRREGULAR plural-fold (lexicon-core.json declares "man"/plural "men"), so
2291
+ // unknownSubjectFallback itself already declines for it (its own
2292
+ // classify(subjectRaw) check reads "men" as the known noun "man") and hands
2293
+ // off to this mirror fallback instead. A naive suffix-strip
2294
+ // (singularizeSurface — SOME_A_FEW_RE's own tool, reused as the fallback for
2295
+ // a genuinely novel REGULAR plural not in the lexicon, e.g. "zorps") can't
2296
+ // undo an IRREGULAR plural like "men" -> "man" — only the lexicon's own
2297
+ // noun table (lookupNoun, already resolved by isGroundedTerm/classify to
2298
+ // decide this subject counts as grounded in the first place) carries that
2299
+ // mapping, so storage must consult the SAME source of truth: prefer the
2300
+ // lexicon lemma, falling back to singularizeSurface for a subject grounded
2301
+ // only via a PRIOR taught fact (isGroundedByFact) or a regular-plural
2302
+ // lexicon fold. Gated on verb "are" for the identical reason
2303
+ // unknownSubjectFallback gates it: an already-singular grounded subject
2304
+ // that happens to end in "s" (e.g. a fact-grounded "gas") must never be
2305
+ // naively stripped on an "is" sentence ("every gas is a chemical").
2306
+ const subject = /^are$/i.test(verb)
2307
+ ? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
2308
+ : subjectRaw;
2258
2309
  return teachFact(memoryDir, sessionId, {
2259
- subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
2310
+ subject, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
2260
2311
  });
2261
2312
  }
2262
2313
 
@@ -2323,7 +2374,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
2323
2374
  if (!memoryDir) return null;
2324
2375
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2325
2376
  if (!m) return null;
2326
- const [, , subjectRaw, objectRaw] = m;
2377
+ const [, , subjectRaw, , objectRaw] = m;
2327
2378
  const { loadLexicon, lookupNoun, classify } = await import("./grammar/lexicon.mjs");
2328
2379
  const lex = lexicon || loadLexicon();
2329
2380
  // Y already a known NOUN or a fact-grounded CLASS term — a genuine class-
@@ -4879,6 +4930,34 @@ const HAS_METHOD_OPEN_RE = /^what\s+methods\s+does\s+([\w'-]+)\s+have[?.!\s]*$/i
4879
4930
  * cascade/orientation nudge that already handles it. */
4880
4931
  const IS_ADJECTIVE_YESNO_RE = /^(?:is|are|was|were)\s+(.+?)\s+([A-Za-z][\w-]*)[?.!\s]*$/i;
4881
4932
  const IS_ADJECTIVE_PRONOUN_RE = /^(?:it|this|that)$/i;
4933
+ /** BENCHMARK_CONVERSATION_1.8.14.md persona-sweep (2026-07-12), highest cross-
4934
+ * persona signal in the run (4 independent personas): IS_ADJECTIVE_YESNO_RE's
4935
+ * subject capture is unbounded/unrestricted (see its own docblock above), so
4936
+ * a pronoun-subject IDENTITY question ("are you happy", "are you like
4937
+ * chatgpt", "are you secretly ChatGPT or GPT-4") backtracks the pronoun
4938
+ * itself (plus any trailing filler word up to the last token) into the
4939
+ * SUBJECT capture — factReadBack then treats "you"/"you like"/"you secretly
4940
+ * chatgpt or" as a literal fact subject and offers to teach a fact ABOUT the
4941
+ * pronoun ("remember that you is happy"), exactly the grammatical category
4942
+ * error TEACH_PRONOUN_RE (above, chat.mjs:2648) was already built to reject
4943
+ * on the teach-lane side. Same pronoun set (you|i|they|he|she|we) reused
4944
+ * here, checked at the START of the subject capture only (not anchored to
4945
+ * the whole capture — a pronoun subject can carry trailing words, "you
4946
+ * like"/"you secretly … or", the same way TEACH_PRONOUN_RE's own `\s+\S+`
4947
+ * tail allows). "it" is deliberately EXCLUDED from this set, unlike
4948
+ * TEACH_PRONOUN_RE — IS_ADJECTIVE_PRONOUN_RE (just above) already gives "it"
4949
+ * its own correct, wanted behavior (anaphoric resolution against the
4950
+ * session's current FOCUS, "is it deprecated" → resolves off focusLabel),
4951
+ * which this guard must not shadow. Every call site below is expected to
4952
+ * test rawSubject (post-trim, pre-lowercasing) against this BEFORE treating
4953
+ * the match as a fact-subject candidate, and to fall through (never offer
4954
+ * unknownAdjectiveOffer, never attempt a fact lookup) on a hit — the same
4955
+ * "decline, don't misroute" discipline the rest of this reader already
4956
+ * follows for an honest miss, letting the query continue to whatever
4957
+ * handles identity/small-talk questions (isConversational's IDENTITY_PHRASES/
4958
+ * AI_IDENTITY_PHRASES/FEELINGS_PHRASES closed sets, or its own ≤3-word
4959
+ * catch-all) instead. */
4960
+ const IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE = /^(?:you|i|they|he|she|we)\b/i;
4882
4961
  /** The TEACH-OFFER for a subject IS_ADJECTIVE_YESNO_RE resolved but has no
4883
4962
  * fact about at all (Tier-5 playtest, cycle 2) — the offered "remember that
4884
4963
  * X is Y" phrasing is verified in-state: TEACH_PROPERTY_RE's own subject
@@ -5050,7 +5129,17 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5050
5129
  // already stood; "is the checkout flow deprecated" (this branch's own
5051
5130
  // ORIGINAL T8 target — "deprecated" has no structural meaning at all)
5052
5131
  // has no envelope.parsed to defer to, so it is untouched.
5053
- if (subject && !/^there\b/i.test(subject) && !envelope?.parsed) {
5132
+ // Pronoun-subject guard (BENCHMARK_CONVERSATION_1.8.14.md persona-sweep,
5133
+ // 2026-07-12) — see IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE's own docblock
5134
+ // above IS_ADJECTIVE_YESNO_RE: "are you happy"/"are you like chatgpt"
5135
+ // backtrack a pronoun subject in here exactly like any other adjective
5136
+ // subject, so without this check unknownAdjectiveOffer would wrongly
5137
+ // offer to teach a fact about the literal pronoun. Checked on rawSubject
5138
+ // (before the IS_ADJECTIVE_PRONOUN_RE focus-resolution swap above, which
5139
+ // only ever fires for "it"/"this"/"that" — never a personal pronoun like
5140
+ // "you", so `subject` itself would already carry the pronoun verbatim).
5141
+ if (subject && !/^there\b/i.test(subject) && !envelope?.parsed
5142
+ && !IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject)) {
5054
5143
  return unknownAdjectiveOffer(subject, emptyIsAdj[2].trim().toLowerCase());
5055
5144
  }
5056
5145
  }
@@ -5816,7 +5905,18 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5816
5905
  const isAdj = qHedge.match(IS_ADJECTIVE_YESNO_RE);
5817
5906
  if (isAdj) {
5818
5907
  const rawSubject = isAdj[1].trim();
5819
- const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
5908
+ // Pronoun-subject guard (BENCHMARK_CONVERSATION_1.8.14.md persona-sweep,
5909
+ // 2026-07-12) — see IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE's own docblock
5910
+ // above IS_ADJECTIVE_YESNO_RE: "are you happy"/"are you like chatgpt"/
5911
+ // "are you secretly ChatGPT or GPT-4" backtrack a personal-pronoun
5912
+ // subject in here just like any other adjective subject. Forcing
5913
+ // `subject` to null (the SAME "nothing to resolve" shape a bare "it"/
5914
+ // "this"/"that" with no standing focus already produces just below) lets
5915
+ // this whole reader decline HONESTLY — no fact lookup, no teach-offer —
5916
+ // and fall through to whatever handles identity/small-talk questions
5917
+ // instead, rather than special-casing a return here.
5918
+ const subject = IS_ADJECTIVE_YESNO_PRONOUN_SUBJECT_RE.test(rawSubject) ? null
5919
+ : IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
5820
5920
  const adjective = isAdj[2].trim().toLowerCase();
5821
5921
  if (subject) {
5822
5922
  const subjVariants = factTermVariants(normFactTerm, subject);
@@ -5867,6 +5967,33 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
5867
5967
  replace: true,
5868
5968
  };
5869
5969
  }
5970
+ // No property hit — but a bare "is X Y" (no article) is exactly the
5971
+ // same claim as "is X a Y" would have been had the user included the
5972
+ // article (ISA_ASK_RE's own territory, above): SKILL_BENCHMARK_CONVERSATION.md's
5973
+ // canonical syllogism ("john is a man" / "all men are mortal" / "is
5974
+ // john mortal") is asked this bare way, and "mortal" was taught as a
5975
+ // CLASS (rdfs:subClassOf), not a property — so it can only ever be
5976
+ // found by the SAME 2-hop TAUGHT-only findIsaChain proof-chase isaAsk
5977
+ // uses above, never by propertyMatch. Tried here as an ADDITIONAL
5978
+ // attempt, never a replacement: on no chain either, this falls through
5979
+ // to the ordinary property-miss handling just below, unchanged.
5980
+ {
5981
+ const { findIsaChain: chaseAdj, SUBCLASS_PREDICATE: SC_PREDICATE_ADJ, TYPE_PREDICATE: TYPE_PREDICATE_ADJ } = await import("./syllogise.mjs");
5982
+ const isTaughtAdj = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
5983
+ const chainSubClassRowsAdj = rows.filter((f) => f.predicate === SC_PREDICATE_ADJ && isTaughtAdj(f));
5984
+ const chainTypeRowsAdj = rows.filter((f) => f.predicate === TYPE_PREDICATE_ADJ && isTaughtAdj(f));
5985
+ const chainSubClassEdgesAdj = chainSubClassRowsAdj.map((f) => [f.subject, f.object]);
5986
+ const chainTypeEdgesAdj = chainTypeRowsAdj.map((f) => [f.subject, f.object]);
5987
+ const factForStepAdj = (step) => (step.predicate === SC_PREDICATE_ADJ ? chainSubClassRowsAdj : chainTypeRowsAdj)
5988
+ .find((f) => f.subject === step.subject && f.object === step.object);
5989
+ const adjObjVariants = factTermVariants(normFactTerm, adjective);
5990
+ for (const subj of subjVariants) {
5991
+ const chain = chaseAdj(subj, adjObjVariants, chainTypeEdgesAdj, chainSubClassEdgesAdj, { maxHops: 2 });
5992
+ if (!chain) continue;
5993
+ const premises = chain.map(factForStepAdj);
5994
+ if (premises.every(Boolean)) return { text: `yes — ${renderIsaChain(premises)}`, replace: true };
5995
+ }
5996
+ }
5870
5997
  // no hit on THIS property — never a guessed "no" (see
5871
5998
  // IS_ADJECTIVE_YESNO_RE's own docblock for why this stays silent on a
5872
5999
  // truth claim, unlike its ownership/general-verb siblings above). But a