@polycode-projects/the-mechanical-code-talker 3.0.5 → 3.0.6

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 (53) hide show
  1. package/README.md +6 -6
  2. package/corpus/child/README.md +2 -2
  3. package/corpus/child/index.json.gz +0 -0
  4. package/corpus/child/manifest.json +74 -74
  5. package/corpus/child/shards/child-00.jsonl.gz +0 -0
  6. package/corpus/child/shards/child-01.jsonl.gz +0 -0
  7. package/corpus/child/shards/child-02.jsonl.gz +0 -0
  8. package/corpus/child/shards/child-03.jsonl.gz +0 -0
  9. package/corpus/child/shards/child-04.jsonl.gz +0 -0
  10. package/corpus/child/shards/child-05.jsonl.gz +0 -0
  11. package/corpus/child/shards/child-06.jsonl.gz +0 -0
  12. package/corpus/child/shards/child-07.jsonl.gz +0 -0
  13. package/corpus/child/shards/child-08.jsonl.gz +0 -0
  14. package/corpus/child/shards/child-09.jsonl.gz +0 -0
  15. package/corpus/child/shards/child-0a.jsonl.gz +0 -0
  16. package/corpus/child/shards/child-0b.jsonl.gz +0 -0
  17. package/corpus/child/shards/child-0c.jsonl.gz +0 -0
  18. package/corpus/child/shards/child-0d.jsonl.gz +0 -0
  19. package/corpus/child/shards/child-0e.jsonl.gz +0 -0
  20. package/corpus/child/shards/child-0f.jsonl.gz +0 -0
  21. package/corpus/child/shards/child-10.jsonl.gz +0 -0
  22. package/corpus/child/shards/child-11.jsonl.gz +0 -0
  23. package/corpus/child/shards/child-12.jsonl.gz +0 -0
  24. package/corpus/child/shards/child-13.jsonl.gz +0 -0
  25. package/corpus/child/shards/child-14.jsonl.gz +0 -0
  26. package/corpus/child/shards/child-15.jsonl.gz +0 -0
  27. package/corpus/child/shards/child-16.jsonl.gz +0 -0
  28. package/corpus/child/shards/child-17.jsonl.gz +0 -0
  29. package/corpus/child/shards/child-18.jsonl.gz +0 -0
  30. package/corpus/child/shards/child-19.jsonl.gz +0 -0
  31. package/corpus/child/shards/child-1a.jsonl.gz +0 -0
  32. package/corpus/child/shards/child-1b.jsonl.gz +0 -0
  33. package/corpus/child/shards/child-1c.jsonl.gz +0 -0
  34. package/corpus/child/shards/child-1d.jsonl.gz +0 -0
  35. package/corpus/child/shards/child-1e.jsonl.gz +0 -0
  36. package/corpus/child/shards/child-1f.jsonl.gz +0 -0
  37. package/corpus/conceptnet/quality-filter.mjs +28 -4
  38. package/corpus/tier2/generate.mjs +4 -8
  39. package/corpus/tier2/human-large.jsonl +0 -2
  40. package/corpus/tier2/human-medium.jsonl +0 -2
  41. package/corpus/tier2/manifest.json +6 -6
  42. package/data/templates/constructions/digest-sentence-structures.toml +146 -1
  43. package/package.json +1 -1
  44. package/src/adapters/corpus/digest-bank.mjs +15 -3
  45. package/src/domain/digest/compose.mjs +142 -10
  46. package/src/domain/digest/config.json +5 -0
  47. package/src/domain/digest/structures.mjs +39 -14
  48. package/src/services/chat-page-viz.mjs +15 -4
  49. package/src/services/chat.mjs +118 -49
  50. package/src/services/extract-facts.mjs +46 -4
  51. package/src/services/research.mjs +80 -3
  52. package/src/surfaces/web/chat-browser-entry.mjs +23 -3
  53. package/src/surfaces/web/memory-ask-browser.bundle.js +108 -108
@@ -273,9 +273,12 @@ function withNarration(result, trace, fallbackGoal) {
273
273
  * above. Appending (not prepending) keeps the many tests that pin composed
274
274
  * answers with a start-anchored regex intact.
275
275
  *
276
- * `result.goal` is set by runAsk and by runCommand's own mk()
277
- * (GOAL_BY_COMMAND, below); a plain count or a teach confirmation never
278
- * carries the field, so this is a no-op for those turn types by
276
+ * `result.goal` is set by runAsk, by runCommand's own mk()
277
+ * (GOAL_BY_COMMAND, below), and by the plainTurn call sites that already
278
+ * hold a precise goal string (a teach confirmation, a taxonomy/SKOS or
279
+ * memory-store lookup, a plan-lane step, and runAsk's own honest-decline
280
+ * early returns). A plain numeric count is the one turn type that stays
281
+ * silent — it never sets the field, so this is a no-op there by
279
282
  * construction. Also a no-op when `result.goal` is null/empty, so an
280
283
  * unclear turn never grows a "Goal (inferred): unclear" line.
281
284
  *
@@ -917,7 +920,7 @@ async function answerMemoryClassQuery(memoryDir, query) {
917
920
  let mem;
918
921
  try { mem = await loadMemory(memoryDir); } catch { return null; }
919
922
  const inds = (mem.individuals || []).filter((i) => (i.class || "") === cls);
920
- if (countM) return { text: `${inds.length} ${inds.length === 1 ? plural.replace(/s$/, "") : plural}.` };
923
+ if (countM) return { text: `${inds.length} ${inds.length === 1 ? plural.replace(/s$/, "") : plural}.`, kind: "count" };
921
924
  if (!inds.length) return { text: `I don't have any ${plural} stored yet.`, miss: true };
922
925
  const factByLabel = cls === "Fact" ? new Map(readFactRows(mem).map((r) => [r.id, r])) : new Map();
923
926
  const lines = inds.map((ind) => memoryClassLine(cls, ind, factByLabel));
@@ -2746,6 +2749,20 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object, qua
2746
2749
  // are Ys" recall. Deliberately NARROW: only the SUBJECT gets a free pass past
2747
2750
  // parseAce's closed lexicon-noun gate, never the OBJECT. ----
2748
2751
 
2752
+ /** Singular nouns that already END in a bare sibilant "-s" and so form their
2753
+ * plural with "-es" (bus -> buses, gas -> gases, lens -> lenses). When a
2754
+ * "-ses" word strips two letters onto one of these, the "-es" was the plural
2755
+ * marker and the stem is correct; every OTHER "-ses" word kept a silent "-e"
2756
+ * and singularizes by stripping one letter (see singularizeSurface). Doubled
2757
+ * "-ss" stems (class, glass, boss) are caught by pattern, so only single-"s"
2758
+ * stems need listing here. */
2759
+ const BARE_SIBILANT_S_NOUNS = new Set([
2760
+ "bus", "gas", "plus", "minus", "surplus", "lens", "bias", "atlas", "canvas",
2761
+ "virus", "bonus", "campus", "census", "chorus", "circus", "focus", "status",
2762
+ "iris", "genius", "sinus", "walrus", "cactus", "fungus", "radius", "nucleus",
2763
+ "alias", "crocus", "abacus", "syllabus", "octopus", "platypus",
2764
+ ]);
2765
+
2749
2766
  /** Naive plural → singular fold for the "some/a few Xs are Ys" surface forms
2750
2767
  * (mirrors factTermVariants' own naive -es/-s stripping, below, but returns
2751
2768
  * ONE canonical spelling to STORE rather than a lookup Set of candidates to
@@ -2763,11 +2780,27 @@ async function teachFact(memoryDir, sessionId, { subject, predicate, object, qua
2763
2780
  function singularizeSurface(word) {
2764
2781
  const w = String(word || "").trim();
2765
2782
  if (/[a-z]ies$/i.test(w)) return `${w.slice(0, -3)}y`;
2766
- if (/(ses|xes|zes|ches|shes)$/i.test(w)) return w.slice(0, -2);
2783
+ if (/[a-z]ses$/i.test(w)) {
2784
+ // "-ses" is genuinely ambiguous. A base already ending in "-se" just adds
2785
+ // "-s" (collapse -> collapses, cause -> causes, rose -> roses), so the
2786
+ // singular strips ONE letter. A base ending in a bare sibilant "-s" adds
2787
+ // "-es" (bus -> buses, class -> classes, lens -> lenses), so the singular
2788
+ // strips TWO. Spelling alone can't separate every pair, so strip two only
2789
+ // when the two-char strip lands on a doubled "-ss" or a known bare-sibilant
2790
+ // "-s" noun; otherwise the base kept its silent "-e" and one letter comes
2791
+ // off. The other "-es" endings (-xes/-zes/-ches/-shes) keep the plain
2792
+ // two-char strip below.
2793
+ const stem = w.slice(0, -2);
2794
+ if (/ss$/i.test(stem) || BARE_SIBILANT_S_NOUNS.has(stem.toLowerCase())) return stem;
2795
+ return w.slice(0, -1);
2796
+ }
2797
+ if (/(xes|zes|ches|shes)$/i.test(w)) return w.slice(0, -2);
2767
2798
  if (/[a-z]s$/i.test(w) && !/(?:ss|ous)$/i.test(w)) return w.slice(0, -1);
2768
2799
  return w;
2769
2800
  }
2770
2801
 
2802
+ export { singularizeSurface };
2803
+
2771
2804
  /** "(every|each|all|a|an )?X is/are (a|an )?Y" — the shape the unknown-subject
2772
2805
  * fallback recognizes (group 2 = X, group 4 = Y); group 1 (when present)
2773
2806
  * names the determiner, so the caller can tell a genuine "every" universal
@@ -2862,22 +2895,27 @@ async function isGroundedByFact(term, memoryDir, cache = null) {
2862
2895
  }
2863
2896
 
2864
2897
  /** Shared "is this term grounded in ANY sense" aggregate — a static lexicon
2865
- * word (any part of speech, via `classify`), a GENERIC_ANCHOR_NOUNS root, OR
2866
- * a term already anchored by a previously taught isa-family fact
2867
- * (isGroundedByFact, above). Used by unknownObjectFallback's subject/object
2868
- * groundedness checks below, where no part-of-speech branching follows
2869
- * just "known or not". (unknownSubjectFallback's own object-known check,
2870
- * above/below, stays narrower and NOUN-specific see its own comment so
2871
- * an object that's merely a known ADJECTIVE doesn't get misrouted into the
2872
- * class/subClassOf branch instead of the property branch.) */
2873
- async function isGroundedTerm(term, lex, memoryDir, cache = null) {
2898
+ * word (any part of speech, via `classify`), a GENERIC_ANCHOR_NOUNS root, a
2899
+ * term already anchored by a previously taught isa-family fact
2900
+ * (isGroundedByFact, above), OR when a code graph is supplied — a symbol
2901
+ * the code graph itself resolves (resolveSymbol, codegraph.mjs the SAME
2902
+ * resolver /describe/`/members`/`/subclasses` already use, so "known to
2903
+ * describe" and "known to teach" can never disagree). Used by
2904
+ * unknownObjectFallback's subject/object groundedness checks below, where no
2905
+ * part-of-speech branching follows just "known or not". (unknownSubjectFallback's
2906
+ * own object-known check, above/below, stays narrower and NOUN-specific — see
2907
+ * its own comment — so an object that's merely a known ADJECTIVE doesn't get
2908
+ * misrouted into the class/subClassOf branch instead of the property branch.) */
2909
+ async function isGroundedTerm(term, lex, memoryDir, cache = null, graph = null) {
2874
2910
  const raw = String(term ?? "").trim();
2875
2911
  if (!raw) return false;
2876
2912
  if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
2877
2913
  const { classify } = await import("../domain/grammar/lexicon.mjs");
2878
2914
  if (classify(raw, lex)) return true;
2915
+ if (graph && resolveSymbol(graph, raw)?.match) return true;
2879
2916
  return isGroundedByFact(raw, memoryDir, cache);
2880
2917
  }
2918
+ export { isGroundedTerm };
2881
2919
 
2882
2920
  /** The "both sides ungrounded" grounding NUDGE: reuses teachSuggestion's own
2883
2921
  * "compute a hint, APPEND it to the existing honest-miss message, never
@@ -2899,15 +2937,15 @@ async function isGroundedTerm(term, lex, memoryDir, cache = null) {
2899
2937
  * unchanged) whenever the payload doesn't fit the shape, or at least one
2900
2938
  * side IS already grounded — a DIFFERENT, more specific reason it declined,
2901
2939
  * where this nudge would be actively unhelpful noise. */
2902
- async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null) {
2940
+ async function ungroundedPairHint(payload, lexicon, memoryDir, cache = null, graph = null) {
2903
2941
  if (!memoryDir) return "";
2904
2942
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
2905
2943
  if (!m) return "";
2906
2944
  const [, , subjectRaw, , objectRaw] = m;
2907
2945
  const { loadLexicon } = await import("../domain/grammar/lexicon.mjs");
2908
2946
  const lex = lexicon || loadLexicon();
2909
- if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache)) return "";
2910
- if (await isGroundedTerm(objectRaw, lex, memoryDir, cache)) return "";
2947
+ if (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph)) return "";
2948
+ if (await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph)) return "";
2911
2949
  // Chaining the second term UNDER the first's now-grounded proper name
2912
2950
  // ("every man is a john") is technically accepted by the grammar (once
2913
2951
  // "john" is grounded, ANY term can be taught as a kind of it), but reads as
@@ -3070,7 +3108,7 @@ async function objectReadsAsNonNoun(word) {
3070
3108
  return false;
3071
3109
  }
3072
3110
  }
3073
- async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent = false }, cache = null) {
3111
+ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent = false, graph = null }, cache = null) {
3074
3112
  if (!memoryDir) return null;
3075
3113
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3076
3114
  if (!m) return null;
@@ -3084,9 +3122,9 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
3084
3122
  if (!/^(?:every|each|all|any)$/i.test((det || "").trim()) && !classIntent) return null; // class-level mint needs a universal quantifier or an explicit kind-of infix
3085
3123
  const { loadLexicon, lookupNoun } = await import("../domain/grammar/lexicon.mjs");
3086
3124
  const lex = lexicon || loadLexicon();
3087
- const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache);
3125
+ const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph);
3088
3126
  if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
3089
- const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir, cache);
3127
+ const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir, cache, graph);
3090
3128
  if (objectGrounded) return null; // object already known — nothing to mint
3091
3129
  if (await objectReadsAsNonNoun(objectRaw)) return null; // reads like an adjective/verb, not a class noun — defer to unknownAdjectiveFallback
3092
3130
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
@@ -3168,7 +3206,7 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, c
3168
3206
  * otherwise provide. "the cache is bespoke" and "Mary is female" both carry
3169
3207
  * one of those signals (the leading "the", and capitalization,
3170
3208
  * respectively); "module is banana" carries none. */
3171
- async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache = null) {
3209
+ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon, graph = null }, cache = null) {
3172
3210
  if (!memoryDir) return null;
3173
3211
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
3174
3212
  if (!m) return null;
@@ -3201,7 +3239,7 @@ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon
3201
3239
  // a noun-shaped Y was already minted as a class upstream and never reaches
3202
3240
  // this point.
3203
3241
  const universalQuantifier = /^(?:every|each|all|any)$/i.test((det || "").trim());
3204
- if (universalQuantifier && (await isGroundedTerm(subjectRaw, lex, memoryDir, cache))
3242
+ if (universalQuantifier && (await isGroundedTerm(subjectRaw, lex, memoryDir, cache, graph))
3205
3243
  && (lookupAdjective(lex, objectRaw) || (await objectReadsAsNonNoun(objectRaw)))) {
3206
3244
  const classSubject = /^are$/i.test(verb)
3207
3245
  ? (lookupNoun(lex, subjectRaw)?.lemma || singularizeSurface(subjectRaw))
@@ -3868,8 +3906,27 @@ const HABITUAL_VERB_EXCLUDE = new Set([
3868
3906
  "please", "thanks", "kindly", "anyway", "though", "indeed", "maybe",
3869
3907
  "perhaps", "still", "too", "also", "instead", "now", "then", "here", "there",
3870
3908
  ]);
3909
+ /** Sentence-initial ordinal/temporal discourse adverbs — the "First", "Then",
3910
+ * "Next" … that thread a narrative across sentences without belonging to the
3911
+ * clause they lead. A closed set: a connective in this slot carries sequence,
3912
+ * not content, so stripping it lets "First a cell grows." read as the same
3913
+ * capability teach the bare "a cell grows." already does. */
3914
+ const LEADING_DISCOURSE_ADVERBS = [
3915
+ "first", "second", "third", "then", "next", "finally",
3916
+ "later", "meanwhile", "afterward", "afterwards",
3917
+ ];
3918
+ const LEADING_DISCOURSE_ADVERB_RE = new RegExp(
3919
+ `^(?:${LEADING_DISCOURSE_ADVERBS.join("|")})\\b\\s*,?\\s+`, "i",
3920
+ );
3921
+ /** Strip one leading ordinal/temporal discourse adverb (case-insensitive,
3922
+ * optional trailing comma) from the start of `text`, leaving the clause that
3923
+ * followed it. A word not in the closed set, or one with no clause after it,
3924
+ * is left untouched. */
3925
+ export function stripLeadingDiscourseAdverb(text) {
3926
+ return String(text || "").trim().replace(LEADING_DISCOURSE_ADVERB_RE, "");
3927
+ }
3871
3928
  function matchBareHabitualTeach(text) {
3872
- const t = String(text || "").trim();
3929
+ const t = stripLeadingDiscourseAdverb(String(text || "").trim());
3873
3930
  const plural = t.match(/^(?:all\s+|every\s+)?([\w-]+s)\s+([a-z][\w-]*)[.!?]*$/i);
3874
3931
  if (plural && !STRUCT_WORDS.has(plural[2].toLowerCase()) && !HABITUAL_VERB_EXCLUDE.has(plural[2].toLowerCase()) && !/[^s]s$/i.test(plural[2])) {
3875
3932
  const subject = singularizeSurface(plural[1].toLowerCase());
@@ -4360,7 +4417,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
4360
4417
  if (memoryDir && !QUESTION_LEAD_RE.test(conjSrc) && /\s+and\s+/i.test(conjSrc)
4361
4418
  && !(await hasMidSentenceInterrogative(conjSrc))) {
4362
4419
  const rewrap = (half) => (wrapped != null ? `remember that ${half}` : half);
4363
- const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder, gameConfig });
4420
+ const recurse = (half) => teachLane(rewrap(half), { memoryDir, sessionId, lexicon, cache, planHolder, graph, gameConfig });
4364
4421
  const stripNoted = (t) => String(t).replace(/^noted — remembered(?:\s+\d+\s+facts?)?:\s*/i, "").trim();
4365
4422
  const shared = conjSrc.match(/^(.+?)\s+and\s+((?:is|are|has|have|can)\b.+)$/i);
4366
4423
  const sharedSubject = shared ? shared[1].match(/^(.+?)\s+(?:is|are|has|have|can)\b/i)?.[1]?.trim() : null;
@@ -5145,7 +5202,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5145
5202
  if (canShape && canSingular !== canShape.subject) {
5146
5203
  let canLex = lexicon;
5147
5204
  if (!canLex) { const { loadLexicon } = await import("../domain/grammar/lexicon.mjs"); canLex = loadLexicon(); }
5148
- if (await isGroundedTerm(canSingular, canLex, memoryDir, cache)) {
5205
+ if (await isGroundedTerm(canSingular, canLex, memoryDir, cache, graph)) {
5149
5206
  const stored = await teachFact(memoryDir, sessionId, {
5150
5207
  subject: canSingular, predicate: await capabilityPredicate(canShape.negated), object: canShape.verb,
5151
5208
  });
@@ -5273,7 +5330,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5273
5330
  // every query-side variant fold) uses; a proper noun that only looks
5274
5331
  // plural ("redis") falls back to its own spelling.
5275
5332
  for (const subj of new Set([singularizeSurface(habitualTeach.subject), habitualTeach.subject])) {
5276
- if (await isGroundedTerm(subj, habLex, memoryDir, cache)) {
5333
+ if (await isGroundedTerm(subj, habLex, memoryDir, cache, graph)) {
5277
5334
  const stored = await teachFact(memoryDir, sessionId, {
5278
5335
  subject: subj, predicate: await capabilityPredicate(habitualTeach.negated), object: habitualTeach.verb,
5279
5336
  });
@@ -5294,7 +5351,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5294
5351
  // STATIC lexicon (or a prior taught fact) already grounds can mint a
5295
5352
  // brand-new object term. See unknownObjectFallback's own docblock for the
5296
5353
  // exact narrowing rules (the "both sides ungrounded" safety guard, etc.).
5297
- const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent: kindOfClassIntent }, cache);
5354
+ const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon, classIntent: kindOfClassIntent, graph }, cache);
5298
5355
  if (objectFallback) return objectFallback;
5299
5356
  // ADJECTIVE-MINT fallback: tried right after unknownObjectFallback
5300
5357
  // declines, so a grounded subject (static
@@ -5303,7 +5360,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5303
5360
  // docblock for the exact narrowing rules (the "both sides ungrounded"
5304
5361
  // safety guard, and why this must be a standalone function rather than
5305
5362
  // nested inside unknownSubjectFallback).
5306
- const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }, cache);
5363
+ const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon, graph }, cache);
5307
5364
  if (adjectiveFallback) return adjectiveFallback;
5308
5365
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
5309
5366
  // (a bare "X is deprecated" is never silently reified), and only after the
@@ -5366,7 +5423,7 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null, cac
5366
5423
  // Grounding NUDGE: APPENDED, never a replacement, exactly like "did" above
5367
5424
  // — see ungroundedPairHint's own docblock for why this is scoped to the
5368
5425
  // "both sides ungrounded, fits the X is/are Y shape" case only.
5369
- const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache);
5426
+ const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir, cache, graph);
5370
5427
  return {
5371
5428
  text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
5372
5429
  + `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
@@ -11992,10 +12049,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
11992
12049
  if (cmp) {
11993
12050
  const [, form, cmpOp, clauseSubject, participle] = cmp;
11994
12051
  const verb = participle.toLowerCase();
12052
+ const temporalGoal = "compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)";
11995
12053
  const refMiss = (text) => {
11996
- note(trace, "goal: compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)");
12054
+ note(trace, `goal: ${temporalGoal}`);
11997
12055
  note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" could not compose a comparison; a specific miss names why, never the teach-offer cascade`);
11998
- return plainTurn(query, text, { via: "miss", miss: true, focus });
12056
+ return plainTurn(query, text, { via: "miss", miss: true, focus, goal: temporalGoal });
11999
12057
  };
12000
12058
  const bound = discourseHolder ? bindDiscourseForm(discourseHolder.record, form) : null;
12001
12059
  if (!bound?.referent) {
@@ -12021,9 +12079,9 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12021
12079
  const holds = cmpOp.toLowerCase() === "before" ? refDay < clauseDay : refDay > clauseDay;
12022
12080
  const relation = refDay < clauseDay ? "came before" : refDay > clauseDay ? "came after" : "landed on the same day as";
12023
12081
  const text = `${holds ? "Yes" : "No"} — ${bound.referent.label} (${refDay}) ${relation} ${clauseSubject} was last ${verb} (${freshCommit.label}, ${clauseDay}).`;
12024
- note(trace, "goal: compare a prior answer's dated referent against a freshly read event (cross-turn temporal composition)");
12082
+ note(trace, `goal: ${temporalGoal}`);
12025
12083
  note(trace, `lane: TEMPORAL_COMPARISON_RE — "${form}" bound ${bound.referent.label} (${refDay}) through the discourse record; the embedded clause re-ran as its own when-question`);
12026
- const turn = plainTurn(query, text, { via: "composed", miss: false, focus });
12084
+ const turn = plainTurn(query, text, { via: "composed", miss: false, focus, goal: temporalGoal });
12027
12085
  const cited = [graph.byId?.get?.(bound.referent.ids[0]), freshCommit].filter(Boolean);
12028
12086
  turn.detail = { traversal: `discourse ${bound.referent.ref} (${refDay}) vs last-${verb} of ${clauseSubject} (${clauseDay})`, matches: cited };
12029
12087
  return turn;
@@ -12043,7 +12101,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12043
12101
  note(trace, "goal: recover a name history the index does not record (honest decline)");
12044
12102
  note(trace, "lane: RENAME_HISTORY_RE — the index carries no rename data, so the calls-relation misread is refused by name");
12045
12103
  return plainTurn(query, `I can't say what ${ent ? ent.label : `"${term}"`} was called before — this index records current names only, no rename history. ${named}${ent ? ` here; "who touched ${ent.label}" lists its recorded commits` : ""}.`, {
12046
- via: "miss", miss: true, focus,
12104
+ via: "miss", miss: true, focus, goal: "recover a name history the index does not record (honest decline)",
12047
12105
  });
12048
12106
  }
12049
12107
  }
@@ -12055,10 +12113,11 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12055
12113
  {
12056
12114
  const wikiTerm = wikipediaAskTerm(query);
12057
12115
  if (wikiTerm) {
12058
- note(trace, "goal: read what Wikipedia says about a named term (explicit source request)");
12116
+ const wikiGoal = "read what Wikipedia says about a named term (explicit source request)";
12117
+ note(trace, `goal: ${wikiGoal}`);
12059
12118
  if (!liveReference) {
12060
12119
  note(trace, "lane: WIKIPEDIA ASK — the explicit request needs the network opt-in; live Wikipedia is off");
12061
- return plainTurn(query, `live Wikipedia is off, so I won't reach the network. Turn it on with /wiki on (it fetches from en.wikipedia.org), then ask again.`, { via: "miss", miss: true, focus });
12120
+ return plainTurn(query, `live Wikipedia is off, so I won't reach the network. Turn it on with /wiki on (it fetches from en.wikipedia.org), then ask again.`, { via: "miss", miss: true, focus, goal: wikiGoal });
12062
12121
  }
12063
12122
  let liveKey = null;
12064
12123
  try { liveKey = cleanMissLiveTerm(wikiTerm, lexicon ?? undefined); } catch { liveKey = null; }
@@ -12066,10 +12125,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12066
12125
  if (live) {
12067
12126
  await ingestReferenceArticle(memoryDir, live.key, live.article, cache, liveProvenanceTag, lexicon, synthesisBudget);
12068
12127
  note(trace, `lane: WIKIPEDIA ASK — answered from a live en.wikipedia.org lookup, cited (article "${live.article.title}", revid ${live.article.revid})`);
12069
- return plainTurn(query, live.text, { via: "reference", miss: false, focus });
12128
+ return plainTurn(query, live.text, { via: "reference", miss: false, focus, goal: wikiGoal });
12070
12129
  }
12071
12130
  note(trace, "lane: WIKIPEDIA ASK — no matching live article (no title, timeout, throttle, or drift-guard reject)");
12072
- return plainTurn(query, `I couldn't reach a matching Wikipedia article for "${wikiTerm}" just now.`, { via: "miss", miss: true, focus });
12131
+ return plainTurn(query, `I couldn't reach a matching Wikipedia article for "${wikiTerm}" just now.`, { via: "miss", miss: true, focus, goal: wikiGoal });
12073
12132
  }
12074
12133
  }
12075
12134
  // COLLECTIVE PLURAL SUBJECT — see COLLECTIVE_FORWARD_RE. Members are the
@@ -12095,9 +12154,10 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12095
12154
  const text = labels.length
12096
12155
  ? `the ${stem} here are ${memberList} — together they ${verb}: ${joinList(labels)}.`
12097
12156
  : `the ${stem} here are ${memberList} — none of them has ${verb} edges in the index.`;
12098
- note(trace, `goal: read a forward relation over a module GROUP (${members.length} members), unioned with the set disclosed`);
12157
+ const groupGoal = `read a forward relation over a module GROUP (${members.length} members), unioned with the set disclosed`;
12158
+ note(trace, `goal: ${groupGoal}`);
12099
12159
  note(trace, `lane: COLLECTIVE_FORWARD_RE — "${stem}" resolved to ${members.length} modules; answered the union, never a silent single best-match`);
12100
- const turn = plainTurn(query, text, { via: "composed", miss: !labels.length, focus });
12160
+ const turn = plainTurn(query, text, { via: "composed", miss: !labels.length, focus, goal: groupGoal });
12101
12161
  turn.detail = { traversal: `${verb} edges unioned over ${memberList}`, matches: [...union.keys()].map((id) => graph.byId?.get?.(id)).filter(Boolean) };
12102
12162
  return turn;
12103
12163
  }
@@ -12122,7 +12182,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12122
12182
  note(trace, "goal: recover a move history the index does not record (premise denied, current location cited)");
12123
12183
  note(trace, "lane: MOVE_HISTORY_RE — no move data exists; the current location answers with the premise named");
12124
12184
  return plainTurn(query, `this index records current locations only, so I can't confirm ${ent.label} moved anywhere.${located}`, {
12125
- via: "composed", miss: false, focus,
12185
+ via: "composed", miss: false, focus, goal: "recover a move history the index does not record (premise denied, current location cited)",
12126
12186
  });
12127
12187
  }
12128
12188
  }
@@ -12140,7 +12200,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12140
12200
  note(trace, `lane: DECISION_RECALL_RE — routed to the folded-session recall surface, ${recalled ? "a relevant block answered" : "nothing relevant folded (honest miss)"}`);
12141
12201
  return plainTurn(query, recalled
12142
12202
  ?? `I don't have a recorded decision about "${term}" — I keep facts and session transcripts, and nothing folded mentions deciding on it. "what did i ask before" lists the last session's questions.`, {
12143
- via: recalled ? "recall" : "miss", miss: !recalled, focus,
12203
+ via: recalled ? "recall" : "miss", miss: !recalled, focus, goal: "recall a decision from the conversation record (session-recall surface)",
12144
12204
  });
12145
12205
  }
12146
12206
  }
@@ -12152,7 +12212,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
12152
12212
  const summary = await recallSummary(memoryDir);
12153
12213
  note(trace, summary ? "source: memory/fold.mjs recallSummary" : "intermediate: no folded session blocks yet — nothing to recall");
12154
12214
  return plainTurn(query, summary ?? "nothing to recall yet — no earlier session has been folded into memory.", {
12155
- via: "recall", miss: !summary, focus,
12215
+ via: "recall", miss: !summary, focus, goal: "recall what was discussed earlier (explicit recall phrasing)",
12156
12216
  });
12157
12217
  }
12158
12218
  // An explicit "this file"/"that module" kind-noun scope signal is collapsed
@@ -13400,7 +13460,7 @@ async function runAsk(query, { config, source, graph, focus, last, templates, me
13400
13460
 
13401
13461
  /** A non-ask, non-dispatch chat turn (count answer, /stats) — the same
13402
13462
  * { answer, logLines, record, focus } shape, recorded like any other turn. */
13403
- function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null, canonical = null } = {}) {
13463
+ function plainTurn(query, answer, { command, via = "composed", miss = false, focus = null, canonical = null, goal = null } = {}) {
13404
13464
  const ts = new Date().toISOString();
13405
13465
  return {
13406
13466
  answer,
@@ -13412,6 +13472,10 @@ function plainTurn(query, answer, { command, via = "composed", miss = false, foc
13412
13472
  canonical,
13413
13473
  },
13414
13474
  focus,
13475
+ // Absent by default, so an untouched plainTurn call stays trailer-free.
13476
+ // A caller that already holds a precise goal string passes it here, and
13477
+ // withGoalLine appends the "Goal (inferred): …" line for that turn.
13478
+ ...(goal ? { goal } : {}),
13415
13479
  };
13416
13480
  }
13417
13481
 
@@ -13895,7 +13959,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
13895
13959
  .map((t) => `fact(${JSON.stringify(normFactTerm(t.subject))}, ${JSON.stringify(t.predicate)}, ${JSON.stringify(normFactTerm(t.object))})`)
13896
13960
  .join(", ")).join(" | "),
13897
13961
  };
13898
- return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical });
13962
+ return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical, goal: "teach/remember a new fact" });
13899
13963
  }
13900
13964
  const parse = parseAce(line, lex);
13901
13965
  if (!parse || !parse.triples?.length || parse.residue?.length) return null;
@@ -13972,7 +14036,7 @@ async function assertTurn(line, { memoryDir, sessionId, focus, lexicon = null, c
13972
14036
  .map((t) => `fact(${JSON.stringify(normFactTerm(t.subject))}, ${JSON.stringify(t.predicate)}, ${JSON.stringify(normFactTerm(t.object))})`)
13973
14037
  .join(", "),
13974
14038
  };
13975
- return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical });
14039
+ return plainTurn(line, answer, { command: "assert", via: "assert", focus, canonical, goal: "teach/remember a new fact" });
13976
14040
  } catch {
13977
14041
  return null; // grammar unavailable / write failed — fall through to the engine
13978
14042
  }
@@ -14878,7 +14942,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14878
14942
  const step = await executePlanStep(planHolder, { memoryDir, sessionId, gameConfig: resolvedGameConfig });
14879
14943
  note(trace, `goal: ${step.deduced}`);
14880
14944
  note(trace, "lane: PLAN NEXT — executed the active plan's next move as an @stepK snapshot write");
14881
- const stepTurn = plainTurn(workingLine, step.text, { via: "plan", focus });
14945
+ const stepTurn = plainTurn(workingLine, step.text, { via: "plan", focus, goal: step.deduced });
14882
14946
  stepTurn.lane = "imperative";
14883
14947
  const rec = withLast(stepTurn, step.deduced);
14884
14948
  rec.planState = planHolder.state;
@@ -14899,7 +14963,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
14899
14963
  if (follow) {
14900
14964
  note(trace, `goal: ${follow.deduced}`);
14901
14965
  note(trace, `lane: ${follow.note}`);
14902
- const rec = withLast(plainTurn(workingLine, follow.text, { via: "plan", focus }), follow.deduced);
14966
+ const rec = withLast(plainTurn(workingLine, follow.text, { via: "plan", focus, goal: follow.deduced }), follow.deduced);
14903
14967
  rec.planState = planHolder.state;
14904
14968
  return rec;
14905
14969
  }
@@ -15021,7 +15085,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
15021
15085
  note(trace, "goal: teach/remember a new fact (bare declarative taxonomy)");
15022
15086
  note(trace, "lane: bareTaxonomyTeach — hyphenated-instance or article-led kind-of declarative, stored before the ask engine could parse it as a question");
15023
15087
  const taxonomyTurn = plainTurn(workingLine, taxonomy.text, { via: taxonomy.via, miss: taxonomy.miss, focus });
15024
- if (!taxonomy.miss) taxonomyTurn.lane = "teach";
15088
+ if (!taxonomy.miss) { taxonomyTurn.lane = "teach"; taxonomyTurn.goal = "teach/remember a new fact"; }
15025
15089
  return withLast(taxonomyTurn, "teach/remember a new fact");
15026
15090
  }
15027
15091
  }
@@ -15066,6 +15130,10 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
15066
15130
  note(trace, `goal: ${goal}`);
15067
15131
  note(trace, "lane: answerMemoryClassQuery — matched a memory-store class noun, answered off the .tmct/memory store's own individuals");
15068
15132
  const turn = plainTurn(workingLine, memClass.text, { via: memClass.miss ? "miss" : "fact", miss: !!memClass.miss, focus });
15133
+ // A bare numeric count ("1 source.") stays silent, matching every other
15134
+ // count lane's tested contract — only a real list enumeration gets the
15135
+ // trailer. answerMemoryClassQuery serves both shapes through one lane.
15136
+ if (!memClass.miss && memClass.kind !== "count") turn.goal = goal;
15069
15137
  if (memClass.pending) turn.detail = { traversal: null, matches: [], pending: memClass.pending };
15070
15138
  return withLast(turn, goal);
15071
15139
  }
@@ -15091,6 +15159,7 @@ export async function runTurn(input, { config, source = defaultSource, graph = n
15091
15159
  note(trace, `goal: ${goal}`);
15092
15160
  note(trace, "lane: answerMembershipList — matched a bare 'list <noun>' over taught isa-facts whose OBJECT is that class");
15093
15161
  const turn = plainTurn(workingLine, memberList.text, { via: memberList.miss ? "miss" : "fact", miss: !!memberList.miss, focus });
15162
+ if (!memberList.miss) turn.goal = goal;
15094
15163
  if (memberList.pending) turn.detail = { traversal: null, matches: [], pending: memberList.pending };
15095
15164
  return withLast(turn, goal);
15096
15165
  }
@@ -53,7 +53,8 @@ import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises";
53
53
  import { tmpdir } from "node:os";
54
54
  import { basename, join, resolve } from "node:path";
55
55
 
56
- import { runTurn, uuidv7 } from "./chat.mjs";
56
+ import { runTurn, uuidv7, stripLeadingDiscourseAdverb } from "./chat.mjs";
57
+ import { beginsWithVowelSound, grammarRules } from "./finish.mjs";
57
58
  import { splitSentencesPreservingPaths, stripCitationResidue } from "./sentences.mjs";
58
59
  import { loadMemory, readFactRows, appendFact } from "../adapters/memory/core.mjs";
59
60
  import { loadConfig } from "../adapters/config.mjs";
@@ -125,6 +126,12 @@ const RELATIVE_PRONOUNS = new Set(["that", "which", "who", "whom", "whose"]);
125
126
  // At most this many triples from one sentence — a bound so a run-on can never
126
127
  // shatter into noise, not a first-wins cap.
127
128
  const MAX_TRIPLES_PER_SENTENCE = 4;
129
+ // How far a copula object scan walks past an attributive-adjective compound
130
+ // (wink tokenizes "medium-sized" as NOUN + "-" + VERB and never re-fuses it) to
131
+ // reach the real head noun through a coordinate modifier list
132
+ // (", burrowing, nocturnal mammal"). A small, explicit bound: past it the object
133
+ // abstains rather than guess, so a long noun pile never mints a stray class.
134
+ const ATTRIBUTIVE_CHAIN_MAX_HOPS = 8;
128
135
 
129
136
  /** Fold an entity surface to its stored key: a lexicon noun's lemma, else the
130
137
  * word's own normFactTerm (the optimistic tier mints unlisted content nouns
@@ -242,6 +249,25 @@ function optimisticTriplesPos(sentence, lexicon, nlp) {
242
249
  if (!isNounish(j)) continue;
243
250
  let hi = j;
244
251
  while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
252
+ // A NOUN immediately followed by "-" then a VERB or ADJ is the left half of
253
+ // an attributive-adjective compound wink never re-fused ("medium-sized"),
254
+ // not the class. Walk forward through the coordinate modifier list (hyphens,
255
+ // commas, "and", further ADJ/VERB tokens) to the real head noun and re-point
256
+ // there; abstain if none appears within the bound, never mint the modifier.
257
+ if (values[hi + 1] === "-" && (pos[hi + 2] === "VERB" || pos[hi + 2] === "ADJ")) {
258
+ let head = null;
259
+ let k = hi + 1;
260
+ for (let hop = 0; hop < ATTRIBUTIVE_CHAIN_MAX_HOPS && k < values.length; hop += 1, k += 1) {
261
+ if (isNounish(k)) { head = k; break; }
262
+ const w = values[k]?.toLowerCase();
263
+ if (w === "-" || w === "," || w === "and" || pos[k] === "ADJ" || pos[k] === "VERB" || pos[k] === "CCONJ") continue;
264
+ break;
265
+ }
266
+ if (head === null) return null;
267
+ j = head;
268
+ hi = head;
269
+ while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
270
+ }
245
271
  const headWord = String(values[hi]).toLowerCase();
246
272
  const nextIsOf = values[hi + 1]?.toLowerCase() === "of";
247
273
  if (!nextIsOf) return { label: entityRunAt(j), hi };
@@ -434,6 +460,17 @@ export function clauseCandidates(sentence, { nlp } = {}) {
434
460
  // live focus, never a stale paragraph carry.
435
461
  const PRONOUN_LEAD_RE = /^(?:they|it|these|those|this)\b\s*/i;
436
462
 
463
+ /** Re-article a bare carried subject so the retried sentence is a grammatical
464
+ * habitual surface the recognizer accepts: "cell" → "a cell", "orbit" → "an
465
+ * orbit". Uses the same vowel-sound-aware article rule (grammar-rules.toml)
466
+ * the chat recognizer's own capability rewrite uses, rather than a hardcoded
467
+ * "a". */
468
+ function articledSubject(subject) {
469
+ const articleRule = grammarRules().find((r) => r.kind === "article");
470
+ const article = articleRule && beginsWithVowelSound(subject, articleRule) ? "an" : "a";
471
+ return `${article} ${subject}`;
472
+ }
473
+
437
474
  /** A readable predicate for canonical output: the local part of an rdfs:/ace:
438
475
  * CURIE, otherwise the predicate verbatim. */
439
476
  const readablePredicate = (predicate) => String(predicate).replace(/^[a-z]+:/i, "");
@@ -532,9 +569,14 @@ export async function ingestText(text, {
532
569
  }
533
570
  // Bounded pronoun carry: a "they/it/these/those/this …" sentence the
534
571
  // recognizer skipped is retried once with the paragraph's last grounded
535
- // subject in the pronoun's place. Never a chat turn — ingest only.
536
- if (!rows && carrySubject && PRONOUN_LEAD_RE.test(cleaned)) {
537
- rows = await strictRows(cleaned.replace(PRONOUN_LEAD_RE, `${carrySubject} `));
572
+ // subject in the pronoun's place. A leading ordinal/temporal discourse
573
+ // adverb ("Then it splits.") is stripped first so the pronoun reaches
574
+ // the sentence front; the carried subject is re-articled ("a cell") so
575
+ // the retry is a grammatical habitual surface. Never a chat turn —
576
+ // ingest only.
577
+ const threaded = stripLeadingDiscourseAdverb(cleaned);
578
+ if (!rows && carrySubject && PRONOUN_LEAD_RE.test(threaded)) {
579
+ rows = await strictRows(threaded.replace(PRONOUN_LEAD_RE, `${articledSubject(carrySubject)} `));
538
580
  }
539
581
  if (rows) {
540
582
  recognizedSentences += 1;
@@ -25,6 +25,7 @@
25
25
 
26
26
  import { normFactTerm } from "../domain/hash.mjs";
27
27
  import { loadLexicon, lookupNoun } from "../domain/grammar/lexicon.mjs";
28
+ import { defaultNlp } from "../domain/interpret/nlp-registry.mjs";
28
29
 
29
30
  /** The search key a topic folds to: normFactTerm, then the lexicon lemma
30
31
  * when the noun is known ("owls" → "owl") — the same fold the live
@@ -210,12 +211,87 @@ function queuedFolds(state) {
210
211
  return seen;
211
212
  }
212
213
 
214
+ // The lexical-token split a fan-out candidate and the seed topic both fold
215
+ // through before comparison — lowercased, split on anything that isn't a
216
+ // letter or digit, empty pieces dropped.
217
+ const LEXICAL_SPLIT_RE = /[^a-z0-9]+/i;
218
+ function lexicalTokens(text) {
219
+ return String(text || "").toLowerCase().split(LEXICAL_SPLIT_RE).filter(Boolean);
220
+ }
221
+ function sharesLexicalToken(title, seedTokens) {
222
+ if (!seedTokens.size) return false;
223
+ for (const tok of lexicalTokens(title)) if (seedTokens.has(tok)) return true;
224
+ return false;
225
+ }
226
+
227
+ // A citation-shaped candidate: an ISBN/DOI prefix, a bare year, or a
228
+ // "Nth century" phrase — the shape raw Wikipedia link lists carry for their
229
+ // source apparatus rather than a kin article.
230
+ const CITATION_PREFIX_RE = /^(isbn|doi)[\s:.-]/i;
231
+ const CITATION_BARE_YEAR_RE = /^\d{3,4}$/;
232
+ const CITATION_CENTURY_RE = /\b\d{1,2}(?:st|nd|rd|th)\s+century\b/i;
233
+ function isCitationShaped(title) {
234
+ const t = String(title || "").trim();
235
+ if (!t) return false;
236
+ return CITATION_PREFIX_RE.test(t) || CITATION_BARE_YEAR_RE.test(t) || CITATION_CENTURY_RE.test(t);
237
+ }
238
+
239
+ const SINGLE_WORD_TITLE_RE = /^[A-Za-z][A-Za-z'-]*$/;
240
+ // A short, neutral sentence to tag a bare candidate word inside — wink-nlp
241
+ // reads an isolated capitalized word as PROPN with no sentence context to
242
+ // tell it otherwise, so the word is lowercased and read at this fixed slot
243
+ // inside real subject/verb/object context instead.
244
+ const NOUN_CARRIER_PREFIX = ["this", "is", "about", "the"];
245
+ const NOUN_CARRIER_SUFFIX = ["and", "its", "history"];
246
+ const NOUN_CARRIER_WORD_INDEX = NOUN_CARRIER_PREFIX.length;
247
+
248
+ /** True when `title` is a single word a general-English POS tagger reads as
249
+ * a common noun (hub articles like "Earth"/"Geology") rather than a proper
250
+ * noun (kin articles like "Hawaii") — false whenever `nlp` is unavailable,
251
+ * never a throw. */
252
+ function readsAsCommonNoun(title, nlp) {
253
+ if (!nlp || typeof nlp.posTags !== "function") return false;
254
+ const t = String(title || "").trim();
255
+ if (!SINGLE_WORD_TITLE_RE.test(t)) return false;
256
+ const carrier = [...NOUN_CARRIER_PREFIX, t.toLowerCase(), ...NOUN_CARRIER_SUFFIX];
257
+ let tags;
258
+ try { tags = nlp.posTags(carrier); } catch { return false; }
259
+ return Array.isArray(tags) && tags[NOUN_CARRIER_WORD_INDEX] === "NOUN";
260
+ }
261
+
262
+ /** Stable-sorts fan-out candidates into relevance tiers — never reorders
263
+ * within a tier, only reprioritizes between them — from information the
264
+ * runtime actually has (title strings, the seed topic, an optional POS
265
+ * tagger), never bench-only ground truth:
266
+ * 0. shares a lexical token with `seedTopic` ("Active volcano" ~ "Volcano");
267
+ * 1. everything else, in original document order (the fallback tier);
268
+ * 2. a single-word title a POS tagger reads as a common noun rather than a
269
+ * proper noun — the hub-article signal ("Earth", "Geology");
270
+ * 3. a citation-shaped title (ISBN/DOI prefix, bare year, "Nth century").
271
+ * With no `nlp` adapter registered, tier 2 never fires (everything that
272
+ * would have landed there stays in tier 1) — degrades gracefully, never
273
+ * throws, never drops a candidate. */
274
+ export function relevanceOrder(titles, seedTopic, nlp = null) {
275
+ const list = Array.isArray(titles) ? titles : [];
276
+ const seedTokens = new Set(lexicalTokens(seedTopic));
277
+ const tiers = [[], [], [], []];
278
+ for (const title of list) {
279
+ if (sharesLexicalToken(title, seedTokens)) { tiers[0].push(title); continue; }
280
+ if (isCitationShaped(title)) { tiers[3].push(title); continue; }
281
+ if (readsAsCommonNoun(title, nlp)) { tiers[2].push(title); continue; }
282
+ tiers[1].push(title);
283
+ }
284
+ return [...tiers[0], ...tiers[1], ...tiers[2], ...tiers[3]];
285
+ }
286
+
213
287
  /** Queue `article`'s lead-section links at `fromDepth + 1`, subject to the run's
214
288
  * depth ceiling, its per-fan-out cap and — crucially — its TOTAL node budget:
215
289
  * the number added never pushes grounded+pending past `maxTopics`. Sets
216
290
  * `state.nodeCapReached` when the budget (not the depth, not a lack of links)
217
- * is what stopped the fan-out, so the progress line can say so. Returns the
218
- * titles it enqueued. */
291
+ * is what stopped the fan-out, so the progress line can say so. Candidates are
292
+ * relevance-ordered (relevanceOrder) before the fan-out cap truncates them, so
293
+ * a capped fetch keeps kin articles over generic hubs. Returns the titles it
294
+ * enqueued. */
219
295
  async function enqueueFrom(state, article, fromDepth, provider) {
220
296
  const childDepth = fromDepth + 1;
221
297
  if (childDepth > runMaxDepth(state)) return [];
@@ -226,10 +302,11 @@ async function enqueueFrom(state, article, fromDepth, provider) {
226
302
  if (want <= 0) { state.nodeCapReached = true; return []; }
227
303
  let linked = null;
228
304
  try { linked = await provider.linkedTitles(article.title, { limit: want + 2 }); } catch { linked = null; }
305
+ const ordered = relevanceOrder(linked || [], article.title, defaultNlp());
229
306
  const seen = queuedFolds(state);
230
307
  if (!state.depths) state.depths = {};
231
308
  const added = [];
232
- for (const title of linked || []) {
309
+ for (const title of ordered) {
233
310
  const folded = normFactTerm(title);
234
311
  if (!folded || seen.has(folded)) continue;
235
312
  seen.add(folded);