@polycode-projects/the-mechanical-code-talker 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ROADMAP.md CHANGED
@@ -70,6 +70,92 @@ next-session pickup material, not yet started.
70
70
  `chat.mjs` change, reusing the existing Source/trust pipeline unmodified. `npm test` 1361 → 1371.
71
71
  Phase 4 (compose2 query-side wiring) is next in that plan's build order.
72
72
 
73
+ **`PLAN_TAUGHT_RELATIONS.md` Phase 1 — DONE (2026-07-09)**: Item 1 (relational fact teach,
74
+ `RELATION_FACT_TEACH_RE` — "ahab is the father of john" mints an ordinary Fact via
75
+ `generalVerbPredicate`, reused verbatim) and Item 5 (adjective-mint, `unknownAdjectiveFallback` —
76
+ "the cache is bespoke" / "TaskController is bespoke" mint `mgx:hasProperty`) both landed in
77
+ `src/chat.mjs`. Query-side readback for Item 1 needed zero new machinery ("what do you know about
78
+ X" / "does X <role> Y" both already confirm it); Item 5's own groundedness guard needed tightening
79
+ beyond the original design to avoid reopening the pinned "module is banana" regression — see
80
+ `PLAN_TAUGHT_RELATIONS.md`'s "Phase 1 — DONE" note for the full adjustment, plus a sharper,
81
+ live-confirmed restatement of that doc's Verification finding 4 (`isConversational`'s ≤3-word gate
82
+ pre-empts the teach lane entirely for a short bare sentence, not just its decline text — flagged,
83
+ not fixed, still out of scope). `npm test` 1371 → 1377.
84
+
85
+ **`PLAN_TAUGHT_RELATIONS.md` Phase 6, KERNEL half — DONE (2026-07-09)**: `findReachableSet`
86
+ (`src/planning.mjs`), a sibling of `findActionPath` with no `isGoal` at all — every state reachable
87
+ from the start within `maxDepth` is a result, not just one goal. Shares only the frontier-seeding
88
+ step with `findActionPath`; the expand loops stayed independent (halting/accumulation semantics
89
+ differ enough to make a shared core more complex, not less). Proven against a toy graph with a real
90
+ cycle and a same-length two-path convergence. `test/planning.test.mjs`, 5 new tests. The WIRING half
91
+ (teach-shape recognizer + query-dispatcher branch, both in `chat.mjs`) is deliberately deferred,
92
+ kernel-only per this task's own scoping — see `PLAN_TAUGHT_RELATIONS.md`/`HANDOVER.md` for detail.
93
+
94
+ **`PLAN_TAUGHT_RELATIONS.md` Phase 2 — DONE (2026-07-09)**: closes Item 1's own live-found
95
+ query-side gap ("is ahab the father of john" now resolves directly) and Item 2 (relation alias/union
96
+ query-side chase — a taught "father ⊑ parent" alias lets "is ahab a parent of john" resolve off the
97
+ father fact). One new recognizer (`RELATION_FACT_YESNO_RE`) and one new local helper
98
+ (`relationFactsFor`) in `factReadBack`, tried BEFORE `ISA_ASK_RE` gets a chance at the overlapping
99
+ shape. The teach-side "kind of"/"type of" fix (`stripKindOf`) is a genuine one-liner.
100
+ `test/chat-taught-relations.test.mjs` (new file), 4 tests. `npm test` 1382 → 1386. Phase 4 (compose2
101
+ rule, next in this plan's build order) reuses `relationFactsFor` as its own per-hop edge lookup.
102
+
103
+ **`PLAN_TAUGHT_RELATIONS.md` Phase 4 — DONE (2026-07-09)**: Item 3 (fixed-hop `compose2` composition
104
+ rule — "a grandparent is a parent of a parent" teaches a Rule, and "is ahab a grandparent of
105
+ ishmael" resolves via a hop-counted `findActionPath` search over the taught father facts,
106
+ alias-chased through "parent" via Phase 2's own `relationFactsFor`). The hop-counting discipline
107
+ (`{ entity, hopsTaken }` state, `isGoal` requiring exactly 2 hops) is live-verified load-bearing: a
108
+ 1-hop and a 3-hop path through the SAME father/parent edges both correctly decline in the same store
109
+ where the genuine 2-hop pair resolves yes. Full family-tree chain (two father facts + the alias + the
110
+ compose2 rule) live-verified end-to-end via the piped CLI. `test/chat-taught-relations.test.mjs`
111
+ extended with 5 more tests (9 total). `npm test` 1386 → 1391.
112
+
113
+ **`PLAN_TAUGHT_RELATIONS.md` Phase 5 — DONE (2026-07-09)**: Item 4 (property-filtered composition
114
+ rule — "a grandfather is a grandparent who is male" teaches a `filter`-kind Rule). Required
115
+ refactoring Phase 2/4's `relAsk` dispatcher's three inline steps into one recursive closure,
116
+ `resolveRelationChase`, so a filter rule's base resolves GENERICALLY — the function calling itself —
117
+ whether the base is a plain taught relation or another Rule (e.g. compose2), never assuming which.
118
+ A hit requires both the base chase to resolve AND the subject to carry the taught property
119
+ (`mgx:hasProperty`); live-verified both failure modes separately (base fails outright vs. base holds
120
+ but the property filter correctly excludes the candidate) plus a filter whose base is a plain
121
+ relation (not a compose2 rule at all), proving the genericity. `test/chat-taught-relations.test.mjs`
122
+ extended with 4 more tests (13 total). `npm test` 1391 → 1395.
123
+
124
+ **`PLAN_TAUGHT_RELATIONS.md` Phase 6 — DONE (2026-07-09), WIRING half — the plan's build is now
125
+ COMPLETE, all six items.** Item 6 (recursive/reachability rule — "a descendant is a parent, or a
126
+ parent of a descendant" teaches a `recursive`-kind Rule; "list the descendants of ahab" enumerates
127
+ the full reachability set via `findReachableSet`, the kernel half already shipped, reused unchanged).
128
+ The query side is the one genuine kind-change among all six items (a reachability-SET enumeration,
129
+ not a yes/no chase), landed as a sibling of Phase 5's `resolveRelationChase` rather than a fourth
130
+ branch inside it — mirroring `findActionPath`/`findReachableSet`'s own sibling split at the kernel
131
+ level. Cycle safety (two individuals mutually taught as each other's parent) and a malformed
132
+ self-reference teach attempt (guarded for free by the teach regex's own backreference) both
133
+ live-verified. `test/chat-taught-relations.test.mjs` extended with 5 more tests, including one
134
+ comprehensive ALL-SIX-items integration test (18 total). `npm test` 1395 → 1400. **Nothing remains
135
+ outstanding from `PLAN_TAUGHT_RELATIONS.md`'s original six-item scope.**
136
+
137
+ **INFBENCH re-measured against 1.3.1** (measurement-only dispatch, 2026-07-09): `INFBENCH_1.3.1.md`
138
+ finds the ladder unchanged, byte-for-byte, since `1.3.0` — the four `PLAN_TAUGHT_RELATIONS.md`
139
+ phases that completed the plan (alias/union, `compose2`, property-filter, recursive/reachability
140
+ wiring) don't touch any band, confirmed by a zero-diff row comparison of both runs' raw product
141
+ files plus a direct check that no INFBENCH case's premises/query ever reach the new relational-teach
142
+ phrasing. Still gated at INF-B1 (33% completion), unchanged for a fourth consecutive measured
143
+ version — an honest, expected result given the new surface and the ladder measure different things.
144
+
145
+ **`PLAN_TAUGHT_RELATIONS.md` live-testing follow-up — DONE (2026-07-09)**: the operator live-tested
146
+ the full family-tree example end-to-end and found two real gaps, both fixed in `src/chat.mjs`. Gap 1:
147
+ a recognized-but-unsatisfied relational query used to fall to the GENERIC structural wall instead of
148
+ naming the relation — fixed by distinguishing "relation/rule name never taught" from "name known, this
149
+ pair's chase came up short" right in the `(a0)` block, each with its own specific decline text. Gap 2:
150
+ the REVERSE query shape ("who is the grandparent of john") didn't exist at all — new recognizer
151
+ `RELATION_WHO_ASK_RE` + a new `(a0.2)` block in `factReadBack`, re-deriving `resolveRelationChase`'s
152
+ same resolution logic (direct/alias/compose2/filter) walked backward from the object, reusing
153
+ `findReachableSet` unmodified for the compose2 reverse hop-chase. Live-verified the operator's own
154
+ repro exactly: "who is the grandparent of ishmael" → ahab (full 2-hop derivation cited); "who is the
155
+ grandparent of john" → an honest empty (never a wrong guess). `test/chat-taught-relations.test.mjs`
156
+ extended with 8 more tests (26 total). `npm test` 1400 → 1408, zero regressions. **Nothing remains
157
+ outstanding from `PLAN_TAUGHT_RELATIONS.md`.**
158
+
73
159
  ### Shipped this session
74
160
 
75
161
  - **Tier 5** found 12 routing/recognition fixes across teach and recall: article/head-word gaps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
package/src/chat.mjs CHANGED
@@ -1380,6 +1380,79 @@ const OWNS_TEACH_RE = /^([A-Za-z][\w'-]*(?:\s+[A-Z][\w'-]*)?)\s+(?:owns|maintain
1380
1380
  * the yes/no readers below answer either phrasing identically. */
1381
1381
  const OWNS_PASSIVE_TEACH_RE = /^(.+?)\s+(?:is|are|was|were)\s+owned\s+by\s+([A-Za-z][\w'-]*(?:\s+[A-Z][\w'-]*)?)[.!?]*$/i;
1382
1382
 
1383
+ /** "<Name> is the <role> of <Name>" — the relational-fact teach declarative
1384
+ * (PLAN_TAUGHT_RELATIONS.md Item 1, Phase 1): a NAMED relationship between
1385
+ * two entities ("ahab is the father of john"), grouped here with the other
1386
+ * relational/possessive teach shapes above (ownership) since it's tried on
1387
+ * the SAME ownSrc in teachLane, right after OWNS_PASSIVE_TEACH_RE and before
1388
+ * SOME_A_FEW_RE — unconditionally ahead of generalVerbTeach's own call site,
1389
+ * so GENERAL_VERB_ANYWHERE_EXCLUDE_RE never gets a say. The literal "the" +
1390
+ * bare role-noun + "of" anchor is deliberate: PLAN_TAUGHT_RELATIONS.md's
1391
+ * Item 3 (a future, not-yet-implemented "a <rule> is a <relation> of a
1392
+ * <relation>" composition-rule teach shape) uses an INDEFINITE "a"/"an" in
1393
+ * the same slot instead, so the two shapes structurally can never collide —
1394
+ * this regex must keep requiring literal "the", never "a"/"an", so any
1395
+ * future Item 3 work stays disjoint from this one. Subject/object each use
1396
+ * the SAME 1-2-token name-capture convention OWNS_TEACH_RE's own subject/
1397
+ * owner already use. */
1398
+ const RELATION_FACT_TEACH_RE =
1399
+ /^([\w'-]+(?:\s+[A-Z][\w'-]*)?)\s+(?:is|are|was|were)\s+the\s+([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[.!?]*$/i;
1400
+
1401
+ /** "a <name> is a <base1> of a <base2>" — the fixed-hop COMPOSITION-RULE teach
1402
+ * declarative (PLAN_TAUGHT_RELATIONS.md Item 3, Phase 4): "a grandparent is a
1403
+ * parent of a parent" teaches a RULE (mgx:ruleKind "compose2"), never a Fact —
1404
+ * the query side chases it via a hop-counted findActionPath search (see
1405
+ * teachLane's own call site below and factReadBack's relational-query
1406
+ * dispatcher). Both slots use an INDEFINITE article ("a"/"an"), the
1407
+ * structural anchor that keeps this shape disjoint from Item 1's
1408
+ * RELATION_FACT_TEACH_RE just above: that regex requires a literal "the" +
1409
+ * a lone role word with no second "of"-clause; this one requires "a"/"an" in
1410
+ * BOTH determiner slots plus a second relation-name word after "of" — the
1411
+ * two can never both match the same input (re-verified against the real
1412
+ * regexes, not just the design doc's own claim). m[1] = the new rule name
1413
+ * ("grandparent"), m[2]/m[3] = the two base relation names ("parent",
1414
+ * "parent" — may differ, e.g. an out-of-scope "an aunt is a sibling of a
1415
+ * parent"). */
1416
+ const COMPOSE2_RULE_TEACH_RE =
1417
+ /^an?\s+([a-z][\w-]*)\s+(?:is|are)\s+an?\s+([a-z][\w-]*)\s+of\s+an?\s+([a-z][\w-]*)[.!?]*$/i;
1418
+
1419
+ /** "a <name> is a <base> who is <property>" — the PROPERTY-FILTERED
1420
+ * composition-rule teach declarative (PLAN_TAUGHT_RELATIONS.md Item 4,
1421
+ * Phase 5): "a grandfather is a grandparent who is male" teaches a RULE
1422
+ * (mgx:ruleKind "filter"), never a Fact. `m[1]` = the new rule name
1423
+ * ("grandfather"), `m[2]` = the base rule/relation name ("grandparent" —
1424
+ * may itself resolve as EITHER a plain taught relation OR another Rule,
1425
+ * e.g. a compose2 rule; the query-side dispatcher below handles either
1426
+ * generically, never assuming which), `m[3]` = the property literal
1427
+ * ("male"). Structurally disjoint from COMPOSE2_RULE_TEACH_RE (anchored on
1428
+ * a literal "who", never "of" a second time) and from
1429
+ * RECURSIVE_RULE_TEACH_RE below (anchored on "or", never "who") — the three
1430
+ * rule-teach shapes are told apart purely by their own distinct anchor
1431
+ * word ("of" only / "who" / "or … of … <same name>"), re-verified against
1432
+ * the real regexes, not just this claim. */
1433
+ const FILTER_RULE_TEACH_RE =
1434
+ /^an?\s+([a-z][\w-]*)\s+(?:is|are)\s+an?\s+([a-z][\w-]*)\s+who\s+(?:is|are)\s+([a-z][\w-]*)[.!?]*$/i;
1435
+
1436
+ /** "a <name> is a <baseCase>, or a <recStep> of a <name>" — the
1437
+ * RECURSIVE/REACHABILITY rule teach declarative (PLAN_TAUGHT_RELATIONS.md
1438
+ * Item 6, Phase 6): "a descendant is a parent, or a parent of a
1439
+ * descendant" teaches a RULE (mgx:ruleKind "recursive"), never a Fact. The
1440
+ * rule's OWN name reappears inside its own definition — the `\1`
1441
+ * backreference requires the recursive slot's trailing name to be the
1442
+ * LITERAL SAME word as `m[1]`, so a mismatched/malformed self-reference
1443
+ * ("a descendant is a parent, or a parent of a person") simply never
1444
+ * matches this regex at all — an honest structural decline, not a runtime
1445
+ * guess. `m[1]` = the new rule name ("descendant"), `m[2]` = the base-case
1446
+ * relation ("parent", hop zero), `m[3]` = the recursive step's first-hop
1447
+ * relation ("parent" again in the illustration, though `m[2]`/`m[3]` are
1448
+ * independently captured and need not be identical to each other — only
1449
+ * `m[1]`'s OWN name must recur at the end). Query side is a genuine
1450
+ * KIND-CHANGE (reachability-SET enumeration via `findReachableSet`,
1451
+ * src/planning.mjs) from items 3/4's single-target search — see the
1452
+ * RECURSIVE_LIST_ASK_RE query recognizer, below. */
1453
+ const RECURSIVE_RULE_TEACH_RE =
1454
+ /^an?\s+([a-z][\w-]*)\s+(?:is|are)\s+an?\s+([a-z][\w-]*),?\s+or\s+an?\s+([a-z][\w-]*)\s+of\s+an?\s+\1[.!?]*$/i;
1455
+
1383
1456
  /** "<X> is <adjective>" — the property teach payload (wrapper-REQUIRED): a lazy
1384
1457
  * subject and a single bare complement word. Never matches the "is a <noun>"
1385
1458
  * membership shape (that stays the ACE grammar's), so "remember that cache is
@@ -1701,6 +1774,99 @@ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon })
1701
1774
  });
1702
1775
  }
1703
1776
 
1777
+ /** The adjective-MINT fallback (PLAN_TAUGHT_RELATIONS.md Item 1's sibling
1778
+ * design, Item 5, Phase 1): "mary is female" / "the cache is bespoke" — a
1779
+ * brand-new adjective with no lexicon entry at all, on a SUBJECT that's
1780
+ * already grounded (by ANY of the same senses isGroundedTerm already tests
1781
+ * for unknownObjectFallback, OR a bare Capitalized name-shaped token, the
1782
+ * exact convention OWNS_TEACH_RE's own bare-form gate uses). Deliberately
1783
+ * its own standalone function, tried ALONGSIDE unknownSubjectFallback rather
1784
+ * than nested inside it: unknownSubjectFallback's own very first line
1785
+ * (`classify(subjectRaw, lex)` truthy -> immediate `return null`) exists to
1786
+ * hand a KNOWN subject's ACE miss back as a real miss — precisely the case
1787
+ * this fallback needs to keep working for ("the cache is bespoke" — "cache"
1788
+ * is a known lexicon noun), so nesting this inside that early-return would
1789
+ * make it unreachable.
1790
+ *
1791
+ * GROUNDING — property-specific, NOT unknownObjectFallback's class-mint
1792
+ * guard: no "every"-quantifier gate at all. A property claim is about ONE
1793
+ * entity, never a quantified class claim (unknownSubjectFallback's own
1794
+ * point 3 precedent: property assertions never carry a quantifier even
1795
+ * under "every"). So the guard here is SUBJECT-side only — isGroundedTerm,
1796
+ * OR a bare Capitalized token — and the OBJECT (the new adjective) is never
1797
+ * required to be independently grounded (minting it is the entire point).
1798
+ * UNKNOWN_SUBJECT_RE's own determiner alternation has no "the" (only
1799
+ * every/each/all/a/an), so a leading "the" rides into the subject capture
1800
+ * itself ("the cache") rather than being split off as a determiner — the
1801
+ * SAME leading-article strip normFactTerm (memory/core.mjs) applies before
1802
+ * storage is applied here too, purely for the groundedness check, so this
1803
+ * recognizes exactly the head noun ("cache") teachFact will actually store
1804
+ * under. Branch order mirrors unknownSubjectFallback's own noun-then-
1805
+ * adjective order: declines (null) first when the OBJECT already resolves
1806
+ * as a known NOUN or a fact-grounded CLASS term, so a genuine class-
1807
+ * membership sentence is never misread as a property. Matches
1808
+ * UNKNOWN_SUBJECT_RE verbatim (the same regex unknownObjectFallback already
1809
+ * reuses) and writes HAS_PROPERTY_PREDICATE, no quantifier, ever.
1810
+ *
1811
+ * Sits strictly UPSTREAM of the pre-existing TEACH_PROPERTY_RE gap (the
1812
+ * wrapped-only surface that mints ANY bare complement word with zero
1813
+ * grounding check at all, e.g. "remember that zorp is florpy" —
1814
+ * Verification finding 3, PLAN_TAUGHT_RELATIONS.md): this fallback does not
1815
+ * close that gap (out of scope, a deliberate separate operator decision),
1816
+ * only adds a properly-grounded alternative ahead of it.
1817
+ *
1818
+ * IMPLEMENTATION ADJUSTMENT found live (not in the original plan text): a
1819
+ * bare "module is banana" (a KNOWN lexicon-noun subject, NO article, NO
1820
+ * capitalization, an unrecognized bare object) is an EXISTING pinned
1821
+ * regression (test/chat-teach-quantifier.test.mjs, test/wiring-facts.test.mjs
1822
+ * — both from unknownObjectFallback's own commit 901528f) that must stay a
1823
+ * plain honest miss. unknownObjectFallback's own mint is guarded against
1824
+ * this exact shape by requiring a genuine "every/each/all" quantifier — but
1825
+ * a property claim never carries one (this function's whole premise), so a
1826
+ * plain "subject grounded via the static lexicon alone" test would
1827
+ * re-open precisely that regression for the property case instead. The
1828
+ * fix: a subject grounded ONLY by a bare static-lexicon match (no article,
1829
+ * no capitalization) does NOT qualify on its own — an article (stripped
1830
+ * above into `bareSubject`), a capitalized name-shape, or a PRIOR-TAUGHT
1831
+ * fact anchor (isGroundedByFact) each stand in as the "this is a deliberate
1832
+ * entity reference, not ordinary bare prose" signal a quantifier would
1833
+ * otherwise provide. "the cache is bespoke" and "Mary is female" both carry
1834
+ * one of those signals (the leading "the", and capitalization,
1835
+ * respectively); "module is banana" carries none. */
1836
+ async function unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon }) {
1837
+ if (!memoryDir) return null;
1838
+ const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
1839
+ if (!m) return null;
1840
+ const [, , subjectRaw, objectRaw] = m;
1841
+ const { loadLexicon, lookupNoun, classify } = await import("./grammar/lexicon.mjs");
1842
+ const lex = lexicon || loadLexicon();
1843
+ // Y already a known NOUN or a fact-grounded CLASS term — a genuine class-
1844
+ // membership sentence, unknownSubjectFallback/unknownObjectFallback's own
1845
+ // territory (already had first refusal on it) — never misread as a property.
1846
+ if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
1847
+ || (await isGroundedByFact(objectRaw, memoryDir))) return null;
1848
+ // Subject-side groundedness — strip a leading "the"/"a"/"an" first
1849
+ // (normFactTerm's own article-strip, mirrored here) so "the cache" checks
1850
+ // groundedness under its real head noun "cache", the same spelling
1851
+ // teachFact will actually normalize and store.
1852
+ const bareSubject = subjectRaw.replace(/^(?:the|an?)\s+/i, "").trim() || subjectRaw;
1853
+ const hadArticle = bareSubject !== subjectRaw;
1854
+ const capitalized = /^[A-Z]/.test(bareSubject);
1855
+ const factGrounded = await isGroundedByFact(bareSubject, memoryDir);
1856
+ const genericAnchor = GENERIC_ANCHOR_NOUNS.has(bareSubject.toLowerCase());
1857
+ // A bare (no article, no capitalization) subject grounded ONLY via the
1858
+ // static lexicon is exactly the pinned "module is banana" shape — see this
1859
+ // function's own docblock. Requires the article/capitalization/prior-fact
1860
+ // signal ALONGSIDE (not instead of) lexicon groundedness before an
1861
+ // article-only subject qualifies.
1862
+ const lexiconGrounded = hadArticle && classify(bareSubject, lex) != null;
1863
+ const subjectGrounded = capitalized || factGrounded || genericAnchor || lexiconGrounded;
1864
+ if (!subjectGrounded) return null; // no deliberate-entity signal — never a guessed mint
1865
+ return teachFact(memoryDir, sessionId, {
1866
+ subject: subjectRaw, predicate: HAS_PROPERTY_PREDICATE, object: objectRaw,
1867
+ });
1868
+ }
1869
+
1704
1870
  // ---- BUG 3 (2026-07-09, operator-authorized generalizing — "I don't know
1705
1871
  // where that ban came from, overturn it. build it."): general verb-to-
1706
1872
  // predicate teaching. "remember tony has a hat" / "remember margo eats ribs"
@@ -1942,8 +2108,22 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1942
2108
  // a "your" appearing mid-sentence; applied to both the bare and the
1943
2109
  // remember-wrapped surface.
1944
2110
  const stripYour = (s) => (s == null ? s : s.replace(/^your\s+/i, "a "));
1945
- const raw = stripYour(rawInput);
1946
- const wrapped = stripYour(wrappedInput);
2111
+ // "X is a KIND OF Y" / "X is a TYPE OF Y" (PLAN_TAUGHT_RELATIONS.md Item 2,
2112
+ // Phase 2): the teach-side half of this item is a ONE-LINE normalization,
2113
+ // not new storage — "a father is a kind of parent" reaches NEITHER
2114
+ // UNKNOWN_SUBJECT_RE nor BARE_DECLARATIVE_RE nor TEACH_PROPERTY_RE today,
2115
+ // because every one of those regexes requires a SINGLE-token object and
2116
+ // "kind of parent" is three tokens (Verification finding 1). Stripping the
2117
+ // "kind/type of" run down to a bare "a "/"an " immediately after the
2118
+ // is/are/was/were copula — BEFORE any teach regex ever sees the sentence —
2119
+ // recognition stays exactly as closed as before (still only "X is a Y",
2120
+ // just one more determiner-phrase spelling of "a"), no new mint path, no
2121
+ // new predicate: "a father is a kind of parent" normalizes to "a father is
2122
+ // a parent", which unknownSubjectFallback already stores as
2123
+ // father ⊑ parent today (finding 2: "parent" is already a lexicon noun).
2124
+ const stripKindOf = (s) => (s == null ? s : s.replace(/\b(is|are|was|were)\s+(?:an?\s+)?(?:kind|type)\s+of\s+/i, "$1 a "));
2125
+ const raw = stripKindOf(stripYour(rawInput));
2126
+ const wrapped = stripKindOf(stripYour(wrappedInput));
1947
2127
 
1948
2128
  // PRONOUN-SUBJECT GUARD — tried against BOTH surfaces (bare and remember-
1949
2129
  // wrapped; trailing punctuation stripped the same way the OWNS/SOME_A_FEW
@@ -1987,6 +2167,102 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1987
2167
  if (stored) return stored;
1988
2168
  }
1989
2169
 
2170
+ // RELATIONAL FACT — "<Name> is the <role> of <Name>" (PLAN_TAUGHT_RELATIONS.md
2171
+ // Item 1, Phase 1). Grouped with the other relational/possessive teach shapes
2172
+ // just above (both ownership forms), tried on the SAME ownSrc, unconditionally
2173
+ // ahead of generalVerbTeach's own call site below so
2174
+ // GENERAL_VERB_ANYWHERE_EXCLUDE_RE never gets a say. Predicate minting reuses
2175
+ // generalVerbPredicate VERBATIM (no sibling function) — implementation-agnostic
2176
+ // to part of speech, so a role noun like "father" mints mgx:father the same
2177
+ // way a general verb would; an ordinary Fact, no new storage shape.
2178
+ const rel = ownSrc.match(RELATION_FACT_TEACH_RE);
2179
+ if (rel && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2180
+ const stored = await teachFact(memoryDir, sessionId, {
2181
+ subject: rel[1], predicate: await generalVerbPredicate(rel[2]), object: rel[3],
2182
+ });
2183
+ if (stored) return stored;
2184
+ }
2185
+
2186
+ // COMPOSE2 RULE TEACH — "a <name> is a <base1> of a <base2>"
2187
+ // (PLAN_TAUGHT_RELATIONS.md Item 3, Phase 4): stores a RULE (appendRule,
2188
+ // kind "compose2"), never a Fact — tried right after item 1's relational
2189
+ // fact above, on the SAME ownSrc, disjoint from it by determiner alone (see
2190
+ // COMPOSE2_RULE_TEACH_RE's own docblock). The query-side hop-counted chase
2191
+ // lives in factReadBack's relational-query dispatcher.
2192
+ const compose2 = ownSrc.match(COMPOSE2_RULE_TEACH_RE);
2193
+ if (compose2 && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2194
+ try {
2195
+ const { appendRule, RULE_KIND_COMPOSE2 } = await import("./memory/core.mjs");
2196
+ const { id } = await appendRule(memoryDir, {
2197
+ name: compose2[1],
2198
+ kind: RULE_KIND_COMPOSE2,
2199
+ slots: { base1: compose2[2], base2: compose2[3] },
2200
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
2201
+ });
2202
+ if (id) {
2203
+ return {
2204
+ text: `noted — remembered: a ${compose2[1]} is a ${compose2[2]} of a ${compose2[3]}`,
2205
+ via: "assert", miss: false,
2206
+ };
2207
+ }
2208
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2209
+ }
2210
+
2211
+ // FILTER RULE TEACH — "a <name> is a <base> who is <property>"
2212
+ // (PLAN_TAUGHT_RELATIONS.md Item 4, Phase 5): stores a RULE (appendRule,
2213
+ // kind "filter"), never a Fact — tried right after item 3's compose2
2214
+ // block above, same ownSrc, disjoint from it by anchor word alone
2215
+ // ("who", never a second "of" — see FILTER_RULE_TEACH_RE's own docblock).
2216
+ // The query-side generic base-then-property chase lives in factReadBack's
2217
+ // relational-query dispatcher (resolveRelation's own "filter" branch).
2218
+ const filterRule = ownSrc.match(FILTER_RULE_TEACH_RE);
2219
+ if (filterRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2220
+ try {
2221
+ const { appendRule, RULE_KIND_FILTER } = await import("./memory/core.mjs");
2222
+ const { id } = await appendRule(memoryDir, {
2223
+ name: filterRule[1],
2224
+ kind: RULE_KIND_FILTER,
2225
+ slots: { base: filterRule[2], property: filterRule[3] },
2226
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
2227
+ });
2228
+ if (id) {
2229
+ return {
2230
+ text: `noted — remembered: a ${filterRule[1]} is a ${filterRule[2]} who is ${filterRule[3]}`,
2231
+ via: "assert", miss: false,
2232
+ };
2233
+ }
2234
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2235
+ }
2236
+
2237
+ // RECURSIVE RULE TEACH — "a <name> is a <baseCase>, or a <recStep> of a
2238
+ // <name>" (PLAN_TAUGHT_RELATIONS.md Item 6, Phase 6): stores a RULE
2239
+ // (appendRule, kind "recursive"), never a Fact. Tried alongside the other
2240
+ // rule-teach shapes above, on the same ownSrc — RECURSIVE_RULE_TEACH_RE's
2241
+ // own `\1` backreference already guarantees a malformed/mismatched
2242
+ // self-reference never matches at all, so no extra validation is needed
2243
+ // here beyond appendRule's own slot-presence check. The query-side
2244
+ // reachability-SET enumeration (a genuine kind-change from the other two
2245
+ // rule kinds' single-target search) lives in factReadBack's own
2246
+ // RECURSIVE_LIST_ASK_RE dispatch, below.
2247
+ const recursiveRule = ownSrc.match(RECURSIVE_RULE_TEACH_RE);
2248
+ if (recursiveRule && memoryDir && !QUESTION_LEAD_RE.test(ownSrc)) {
2249
+ try {
2250
+ const { appendRule, RULE_KIND_RECURSIVE } = await import("./memory/core.mjs");
2251
+ const { id } = await appendRule(memoryDir, {
2252
+ name: recursiveRule[1],
2253
+ kind: RULE_KIND_RECURSIVE,
2254
+ slots: { baseCase: recursiveRule[2], recStep: recursiveRule[3] },
2255
+ provenance: teachProvenanceTag(sessionId, new Date().toISOString()),
2256
+ });
2257
+ if (id) {
2258
+ return {
2259
+ text: `noted — remembered: a ${recursiveRule[1]} is a ${recursiveRule[2]}, or a ${recursiveRule[3]} of a ${recursiveRule[1]}`,
2260
+ via: "assert", miss: false,
2261
+ };
2262
+ }
2263
+ } catch { /* malformed slots — fall through to the ordinary honest-miss cascade */ }
2264
+ }
2265
+
1990
2266
  // "some Xs are Ys" / "a few Xs are Ys" (Feature A) — the plural class-
1991
2267
  // membership quantifier shape. ACE has no quantifier-phrase pattern at all
1992
2268
  // (parseAce never even attempts a fit), so this is ALWAYS a direct write,
@@ -2109,6 +2385,15 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
2109
2385
  // narrowing rules (the "both sides ungrounded" safety guard, etc.).
2110
2386
  const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon });
2111
2387
  if (objectFallback) return objectFallback;
2388
+ // ADJECTIVE-MINT fallback (PLAN_TAUGHT_RELATIONS.md Item 5, Phase 1): tried
2389
+ // right after unknownObjectFallback declines, so a grounded subject (static
2390
+ // lexicon, a prior taught fact, or a bare Capitalized name) can mint a
2391
+ // brand-new adjective's property fact. See unknownAdjectiveFallback's own
2392
+ // docblock for the exact narrowing rules (the "both sides ungrounded"
2393
+ // safety guard, and why this must be a standalone function rather than
2394
+ // nested inside unknownSubjectFallback).
2395
+ const adjectiveFallback = await unknownAdjectiveFallback(payload, { memoryDir, sessionId, lexicon });
2396
+ if (adjectiveFallback) return adjectiveFallback;
2112
2397
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
2113
2398
  // (a bare "X is deprecated" is never silently reified), and only after the
2114
2399
  // ACE grammar declined (unknown words / not the membership shape), so a
@@ -2899,6 +3184,22 @@ function predicatePhrase(predicate) {
2899
3184
  }
2900
3185
  const factPhrase = (f) => `${f.subject} ${predicatePhrase(f.predicate)} ${f.object}`;
2901
3186
 
3187
+ /** The mechanical INVERSE of generalVerbPredicate: recovers the bare role/verb
3188
+ * word a taught relational Fact's predicate was minted from ("mgx:father" ->
3189
+ * "father"), or null for a predicate that isn't the "mgx:<word>" mint shape at
3190
+ * all (a curated predicate like mgx:hasProperty/mgx:ownedBy/mgx:hasA never
3191
+ * names a chaseable relation, so callers below simply never match it against
3192
+ * a queried relation name). PLAN_TAUGHT_RELATIONS.md Phase 2/4's own shared
3193
+ * substrate: factReadBack's relational-query dispatcher (RELATION_FACT_YESNO_RE)
3194
+ * uses this to enumerate "which already-taught fact-predicates touch this
3195
+ * (subject, object) pair" without hand-rolling a second lemma table — the
3196
+ * SAME "mgx:<lemma>" shape generalVerbPredicate mints is simply read backward,
3197
+ * synchronously (no lemmatizer round-trip needed to go this direction). */
3198
+ function relationRoleWord(predicate) {
3199
+ const m = /^mgx:([a-z][\w-]*)$/i.exec(String(predicate || ""));
3200
+ return m ? m[1].toLowerCase() : null;
3201
+ }
3202
+
2902
3203
  // ---- BUG 1 fix (2026-07-08): "what is a tree used for" filters to JUST the
2903
3204
  // UsedFor facts, instead of grammar.mjs's meta-whatis template's lazy tail
2904
3205
  // swallowing "tree used for" whole as one literal term (a guaranteed
@@ -3110,6 +3411,67 @@ async function synonymsOf(term) {
3110
3411
  return index.get(String(term || "").trim().toLowerCase()) || [];
3111
3412
  }
3112
3413
 
3414
+ /** "is/are/was/were <X> the/a/an <role> of <Y>" — the RELATIONAL-QUERY yes/no
3415
+ * reader (PLAN_TAUGHT_RELATIONS.md Phase 2, item 1's own query-side gap +
3416
+ * item 2's alias chase + Phase 4 item 3's compose2-rule chase, all three
3417
+ * dispatched from ONE recognizer — see factReadBack's own relAsk block for
3418
+ * the full 3-step lookup: direct fact, alias-chased fact, then compose2 rule).
3419
+ * Deliberately tried BEFORE ISA_ASK_RE gets a chance at this shape (see
3420
+ * factReadBack's own placement, ahead of ISA_ASK_RE's match site): "is ahab a
3421
+ * parent of john" ALSO fits ISA_ASK_RE's own "a"/"an" determiner alternation
3422
+ * (backtracking "parent of john" into ISA_ASK_RE's single free-form object
3423
+ * capture) — checked live, ISA_ASK_RE's own block always returns (a hit or an
3424
+ * explicit `return null`), so whichever regex's block runs FIRST wins the
3425
+ * shape outright; this is the same "add a more specific recognizer earlier in
3426
+ * the cascade" precedent WHO_OWNS_RE/ISA_ASK_RE themselves already set
3427
+ * relative to the more general readers below them. Accepts "the" (item 1's
3428
+ * own literal-"the" direct-fact query, "is ahab the father of john") AND
3429
+ * "a"/"an" (item 2/4's alias-chase and rule-chase queries, which need the
3430
+ * indefinite article since the relation/rule name being asked about is
3431
+ * itself often the more general or composed one) — the determiner carries no
3432
+ * write-time collision risk here the way RELATION_FACT_TEACH_RE vs
3433
+ * COMPOSE2_RULE_TEACH_RE's determiner split does, because this is a READ-side
3434
+ * reader with a single unified dispatcher, not two competing WRITE shapes.
3435
+ * Structurally disjoint from plain ISA_ASK_RE/OWNS_PASSIVE_YESNO_RE shapes
3436
+ * that have no trailing " of <Y>" clause at all (verified: "is a module a
3437
+ * component" — no "of" clause — never matches this regex; see this file's
3438
+ * own PLAN_TAUGHT_RELATIONS.md "Phase 2 — DONE" note for the full collision
3439
+ * analysis). */
3440
+ const RELATION_FACT_YESNO_RE =
3441
+ /^(?:is|are|was|were)\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)\s+(?:the|an?)\s+([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[?.!\s]*$/i;
3442
+
3443
+ /** "who is the grandparent of john" — the REVERSE relational-query reader
3444
+ * (Gap 2, live-tested 2026-07-09, PLAN_TAUGHT_RELATIONS.md follow-up).
3445
+ * RELATION_FACT_YESNO_RE just above needs BOTH subject and object (a yes/no
3446
+ * for one named pair); this is the missing mirror shape — given a relation
3447
+ * name and only the OBJECT, find every SUBJECT that satisfies it. Shares
3448
+ * RELATION_FACT_YESNO_RE's own "the"/"a"/"an" determiner alternation (same
3449
+ * reasoning: "who is the father of john" is a direct-name query, "who is a
3450
+ * parent of john" is an alias/rule-name query — no write-time collision risk
3451
+ * here either, this is a read-side reader). Structurally disjoint from
3452
+ * WHO_OWNS_RE ("who owns/maintains …", a different verb entirely) and from
3453
+ * AUTHOR_WHO_IS_RE (no trailing " of <Y>" clause at all, and gated to a
3454
+ * would-miss git-authorship lane that declines silently on a non-author
3455
+ * name, verified live). Dispatch lives in factReadBack's own (a0.2) block,
3456
+ * below — a `resolveRelationChaseReverse` closure re-deriving the SAME
3457
+ * resolution logic as (a0)'s `resolveRelationChase` (direct fact, alias via
3458
+ * findIsaChain, compose2 via a reverse hop-counted chase, filter via a
3459
+ * recursive base-then-property chase), walked backward from the object. */
3460
+ const RELATION_WHO_ASK_RE =
3461
+ /^who\s+(?:is|are)\s+(?:the|an?)\s+([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[?.!\s]*$/i;
3462
+
3463
+ /** "list the descendants of ahab" — the REACHABILITY-SET list query
3464
+ * (PLAN_TAUGHT_RELATIONS.md Item 6, Phase 6's wiring half): a genuine
3465
+ * KIND-CHANGE from RELATION_FACT_YESNO_RE just above — every entity
3466
+ * reachable from the named start entity through a taught `recursive` Rule,
3467
+ * not a single yes/no. `m[1]` = the rule's PLURAL name ("descendants",
3468
+ * singularized via singularizeSurface before the findRuleByName lookup —
3469
+ * the same naive plural fold SOME_A_FEW_RE's own teach-side surface already
3470
+ * uses elsewhere in this file), `m[2]` = the start entity ("ahab"). Dispatch
3471
+ * lives in factReadBack's own (a0.5) block, below — findRuleByName +
3472
+ * findReachableSet (src/planning.mjs), never a yes/no answer. */
3473
+ const RECURSIVE_LIST_ASK_RE = /^list\s+(?:the\s+|all\s+)?([a-z][\w-]*)\s+of\s+([\w'-]+(?:\s+[A-Z][\w'-]*)?)[?.!\s]*$/i;
3474
+
3113
3475
  /** "is a module a component" — the yes/no vocabulary form the graph grammar
3114
3476
  * doesn't parse; checked against the isa-family fact predicates only. */
3115
3477
  const ISA_ASK_RE = /^(?:is|are)\s+(?:an?\s+)?(.+?)\s+(?:a\s+kind\s+of|a\s+type\s+of|an?)\s+(.+?)[?.!\s]*$/i;
@@ -3561,8 +3923,11 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
3561
3923
  // not just the live playtest transcript. Excluded explicitly: ISA_ASK_RE
3562
3924
  // matches take the SAME priority here they get in the non-empty-rows
3563
3925
  // path below, and a leading "there" is existential, never a real named
3564
- // subject a property claim would name.
3565
- if (!ISA_ASK_RE.test(qHedge)) {
3926
+ // subject a property claim would name. RELATION_FACT_YESNO_RE (Phase 2/4)
3927
+ // gets the SAME exclusion for the SAME reason — "is ahab a parent of
3928
+ // john" with truly zero facts remembered is an honest miss on the
3929
+ // relational reader below, never a bogus adjective teach-offer here.
3930
+ if (!ISA_ASK_RE.test(qHedge) && !RELATION_FACT_YESNO_RE.test(qHedge)) {
3566
3931
  const emptyIsAdj = qHedge.match(IS_ADJECTIVE_YESNO_RE);
3567
3932
  if (emptyIsAdj) {
3568
3933
  const rawSubject = emptyIsAdj[1].trim();
@@ -3610,6 +3975,472 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
3610
3975
  return renderMany(hits);
3611
3976
  }
3612
3977
 
3978
+ // (a0) RELATIONAL FACT / ALIAS-CHASE / RULE-CHASE yes/no — "is/are/was/
3979
+ // were <X> the/a/an <role> of <Y>" (PLAN_TAUGHT_RELATIONS.md Phase 2 item
3980
+ // 1's own query-side gap + item 2's alias chase + Phase 4 item 3's
3981
+ // hop-counted compose2 chase + Phase 5 item 4's property-filtered chase,
3982
+ // all dispatched from ONE recognizer, tried BEFORE ISA_ASK_RE gets a
3983
+ // chance at this shape — see RELATION_FACT_YESNO_RE's own docblock for why
3984
+ // placement, not regex disjointness, keeps this ahead of ISA_ASK_RE). The
3985
+ // actual dispatch lives in resolveRelationChase, below (a recursive
3986
+ // closure, not four independent branches):
3987
+ // (i) DIRECT — a fact already taught under the queried role word exactly
3988
+ // ("is ahab the father of john" against a literal mgx:father fact —
3989
+ // Phase 1's own live-found gap, closed here).
3990
+ // (ii) ALIAS CHASE (item 2) — the SAME candidate list, widened: any fact
3991
+ // connecting this exact (subject, object) pair whose OWN role word
3992
+ // reaches the queried name via a TAUGHT rdfs:subClassOf chain over
3993
+ // relation-NAME strings (findIsaChain, reused completely unmodified,
3994
+ // maxHops:2, corpus-excluded — the identical isTaught discipline the
3995
+ // cax-sco/scm-sco class-term proof chase below already uses, just
3996
+ // walked over relation names instead of class names).
3997
+ // (iii) COMPOSE2 RULE CHASE (Phase 4 item 3) — the queried name may itself
3998
+ // be an already-taught Rule (findRuleByName), not a plain relation at
3999
+ // all: a hop-counted findActionPath search over { entity, hopsTaken }
4000
+ // states, dispatching base1's edges at hop 0 and base2's edges at hop
4001
+ // 1, requiring EXACTLY hopsTaken === 2 at the goal — never just
4002
+ // entity === target at any depth (the load-bearing nuance: a
4003
+ // coincidental 1-hop or 3-hop path through the SAME edge relation
4004
+ // must NOT falsely satisfy a rule that must be exactly 2 hops).
4005
+ // (iv) FILTER RULE CHASE (Phase 5 item 4) — the queried name may be a
4006
+ // `filter`-kind Rule: recursively resolve its OWN base (step i/ii OR
4007
+ // iii again, generic over which the base turns out to be — the same
4008
+ // function calls itself), then require the SUBJECT also carry the
4009
+ // taught property (mgx:hasProperty, a plain Fact lookup).
4010
+ // (i) and (ii) share one candidate list (relationFactsFor); (iii)/(iv) reuse
4011
+ // the SAME list-builder as their per-hop edge lookup, so all four steps
4012
+ // agree on what "a fact under relation X" means. No hit at any step → null,
4013
+ // the honest miss stands (never a guessed "no" — the same OWA discipline
4014
+ // every other yes/no reader in this function follows).
4015
+ const relAsk = qHedge.match(RELATION_FACT_YESNO_RE);
4016
+ if (relAsk) {
4017
+ const rawSubject = relAsk[1].trim();
4018
+ const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
4019
+ const relationName = relAsk[2].trim().toLowerCase();
4020
+ const object = relAsk[3].trim();
4021
+ if (subject) {
4022
+ const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
4023
+ const aliasSubClassEdges = rows
4024
+ .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
4025
+ .map((f) => [f.subject, f.object]);
4026
+ const { findIsaChain: chaseAlias } = await import("./syllogise.mjs");
4027
+ // Shared alias-chase substrate (item 2): every stored Fact whose
4028
+ // predicate resolves — directly, or via a TAUGHT rdfs:subClassOf chain
4029
+ // over relation-NAME strings, never corpus noise — to `name`. Reused for
4030
+ // BOTH the direct/alias yes-no readback just below AND the compose2
4031
+ // hop-search's per-hop edge lookup further down, so the two never
4032
+ // disagree on what "a fact under relation X" means.
4033
+ const relationFactsFor = (name) => {
4034
+ const target = String(name || "").trim().toLowerCase();
4035
+ const out = [];
4036
+ for (const f of rows) {
4037
+ const role = relationRoleWord(f.predicate);
4038
+ if (!role) continue;
4039
+ if (role === target) { out.push({ fact: f, aliasFacts: [] }); continue; }
4040
+ const chain = chaseAlias(role, new Set([target]), [], aliasSubClassEdges, { maxHops: 2 });
4041
+ if (!chain) continue;
4042
+ const aliasFacts = chain.map((step) => rows.find(
4043
+ (r) => r.predicate === SUBCLASS_PREDICATE && r.subject === step.subject && r.object === step.object,
4044
+ ));
4045
+ if (aliasFacts.every(Boolean)) out.push({ fact: f, aliasFacts });
4046
+ }
4047
+ return out;
4048
+ };
4049
+ // Generic relation-NAME resolver (PLAN_TAUGHT_RELATIONS.md Phase 5's
4050
+ // own genericity requirement) — the SAME "what kind of thing is this
4051
+ // name" dispatch §3 designs, made explicitly RECURSIVE so a Rule's
4052
+ // own base can be EITHER a plain taught relation (terminal — steps
4053
+ // i/ii, direct fact or alias chase) OR ANOTHER Rule (compose2's
4054
+ // hop-counted chase, step iii; filter's own base-then-property chase,
4055
+ // Phase 5 item 4, step iv). Returns `{ citation: string[] }` on a
4056
+ // genuine hit, or null on an honest miss — never a guessed "no", the
4057
+ // same OWA discipline every other yes/no reader in this function
4058
+ // follows. Recursion is naturally bounded (§3.3): a filter rule's
4059
+ // base is always either a plain relation (case a, terminal) or
4060
+ // another rule (case b, one dispatch level deeper) — FILTER_RULE_TEACH_RE
4061
+ // never lets a rule name its OWN name as its own base, so no cycle
4062
+ // guard is needed at THIS dispatch level (the search kernels
4063
+ // underneath — findActionPath — carry their own `seen`-set safety
4064
+ // regardless).
4065
+ const resolveRelationChase = async (name, subjectTerm, objectTerm) => {
4066
+ const target = String(name || "").trim().toLowerCase();
4067
+ // (i)+(ii): direct hit or alias-chased hit for this exact (subject,
4068
+ // object) pair under the queried relation name.
4069
+ const sv = factTermVariants(normFactTerm, subjectTerm);
4070
+ const ov = factTermVariants(normFactTerm, objectTerm);
4071
+ const pairHits = relationFactsFor(target).filter((e) => sv.has(e.fact.subject) && ov.has(e.fact.object));
4072
+ if (pairHits.length) {
4073
+ const hit = pairHits.slice().sort((a, b) => byTrust(a.fact, b.fact))[0];
4074
+ return { citation: [renderFactLine(hit.fact), ...hit.aliasFacts.map(
4075
+ (af) => `${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`,
4076
+ )] };
4077
+ }
4078
+ // The queried name may itself be a taught RULE. findRuleByName is
4079
+ // the SAME lookup §2/§3's own genericity design uses ("what kind of
4080
+ // thing is X") — no per-rule-name branch, just a class/kind check.
4081
+ const {
4082
+ loadMemory, findRuleByName, RULE_KIND_PROP: ruleKindProp,
4083
+ RULE_KIND_COMPOSE2: composeKind, RULE_KIND_FILTER: filterKind,
4084
+ } = await import("./memory/core.mjs");
4085
+ const memory = await loadMemory(memoryDir);
4086
+ const rule = findRuleByName(memory, target);
4087
+ const ruleKind = rule?.attributes?.find((a) => a.prop === ruleKindProp)?.value;
4088
+ // (iii) COMPOSE2 RULE CHASE (Phase 4 item 3) — a hop-counted
4089
+ // findActionPath search over { entity, hopsTaken } states,
4090
+ // dispatching base1's edges at hop 0 and base2's edges at hop 1,
4091
+ // requiring EXACTLY hopsTaken === 2 at the goal — never just
4092
+ // entity === target at any depth.
4093
+ if (rule && ruleKind === composeKind) {
4094
+ const base1 = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
4095
+ const base2 = rule.attributes.find((a) => a.prop === "mgx:ruleBase2")?.value;
4096
+ const startEntity = normFactTerm(subjectTerm);
4097
+ const targetEntity = normFactTerm(objectTerm);
4098
+ if (!base1 || !base2 || !startEntity || !targetEntity) return null;
4099
+ const { findActionPath } = await import("./planning.mjs");
4100
+ const applyActions = (state) => {
4101
+ if (state.hopsTaken >= 2) return [];
4102
+ const relName = state.hopsTaken === 0 ? base1 : base2;
4103
+ return relationFactsFor(relName)
4104
+ .filter((e) => e.fact.subject === state.entity)
4105
+ .map((e) => ({ action: e, nextState: { entity: e.fact.object, hopsTaken: state.hopsTaken + 1 } }));
4106
+ };
4107
+ const isGoal = (state) => state.hopsTaken === 2 && state.entity === targetEntity;
4108
+ const stateKey = (state) => `${state.entity}#${state.hopsTaken}`;
4109
+ const found = findActionPath({ entity: startEntity, hopsTaken: 0 }, isGoal, applyActions, { maxDepth: 2, stateKey });
4110
+ if (!found) return null;
4111
+ const seenAlias = new Set();
4112
+ const parts = [];
4113
+ for (const e of found.actions) {
4114
+ parts.push(renderFactLine(e.fact));
4115
+ for (const af of e.aliasFacts) {
4116
+ const key = af.id || `${af.subject}|${af.predicate}|${af.object}`;
4117
+ if (seenAlias.has(key)) continue;
4118
+ seenAlias.add(key);
4119
+ parts.push(`${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`);
4120
+ }
4121
+ }
4122
+ return { citation: parts };
4123
+ }
4124
+ // (iv) FILTER RULE CHASE (Phase 5 item 4) — recursively resolve the
4125
+ // base (a plain relation OR another rule — this SAME function,
4126
+ // generic over which one it turns out to be), then filter by
4127
+ // whether the SUBJECT carries the property literal
4128
+ // (mgx:hasProperty, a plain Fact lookup over the already-loaded
4129
+ // `rows`). A base chase that fails declines here too (never a
4130
+ // guess); a base chase that succeeds but whose subject lacks the
4131
+ // taught property declines as well — the filter correctly EXCLUDES
4132
+ // that candidate rather than silently ignoring the property clause.
4133
+ if (rule && ruleKind === filterKind) {
4134
+ const base = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
4135
+ const property = rule.attributes.find((a) => a.prop === "mgx:ruleFilterProperty")?.value;
4136
+ if (!base || !property) return null;
4137
+ const baseHit = await resolveRelationChase(base, subjectTerm, objectTerm);
4138
+ if (!baseHit) return null;
4139
+ const subjectEntity = normFactTerm(subjectTerm);
4140
+ const propertyNorm = normFactTerm(property);
4141
+ const propHit = rows.find(
4142
+ (f) => f.predicate === HAS_PROPERTY_PREDICATE && f.subject === subjectEntity && normFactTerm(f.object) === propertyNorm,
4143
+ );
4144
+ if (!propHit) return null; // base relation holds, but the property filter excludes this candidate
4145
+ return { citation: [...baseHit.citation, renderFactLine(propHit)] };
4146
+ }
4147
+ return null; // no remembered fact, alias, or rule (of any kind) reaches this
4148
+ };
4149
+ const hit = await resolveRelationChase(relationName, subject, object);
4150
+ if (hit) return { text: `yes — ${hit.citation.join("; ")}`, replace: true };
4151
+ // Gap 1 fix (live-tested 2026-07-09, PLAN_TAUGHT_RELATIONS.md follow-up):
4152
+ // this used to `return null` unconditionally on any miss here — the
4153
+ // SHAPE was already recognized (subject/relation/object all parsed
4154
+ // successfully), so an ordinary `null` fell all the way through
4155
+ // factReadBack's cascade to runTurn's GENERIC structural wall
4156
+ // ("couldn't parse this as a graph question…"), which doesn't even
4157
+ // mention the relation the user actually asked about. Distinguish two
4158
+ // real cases, both rendered HERE (never deferred to the generic wall):
4159
+ // (1) the relation/rule NAME itself was never taught at all — no fact
4160
+ // or alias reaches it under any spelling, AND no Rule is stored under
4161
+ // it either; (2) the name IS known, but THIS specific (subject,
4162
+ // object) pair's chase came up short (e.g. a 2-hop rule with only 1
4163
+ // hop of facts taught, or an unrelated pair) — an honest, specific
4164
+ // decline that NAMES the relation, never a guessed "no".
4165
+ const { loadMemory: loadMemForMiss, findRuleByName: findRuleByNameForMiss } = await import("./memory/core.mjs");
4166
+ const memoryForMiss = await loadMemForMiss(memoryDir);
4167
+ const nameKnown = relationFactsFor(relationName).length > 0
4168
+ || !!findRuleByNameForMiss(memoryForMiss, relationName);
4169
+ if (!nameKnown) {
4170
+ return { text: `I don't know a relation or rule called '${relationName}' yet.`, replace: true };
4171
+ }
4172
+ return {
4173
+ text: `I know the '${relationName}' relation, but I can't confirm ${subject} is the ${relationName} of ${object} from what you've told me.`,
4174
+ replace: true,
4175
+ };
4176
+ }
4177
+ }
4178
+
4179
+ // (a0.2) RELATION "WHO" REVERSE ASK — "who is the/a/an <relation> of <Y>"
4180
+ // (Gap 2, live-tested 2026-07-09, PLAN_TAUGHT_RELATIONS.md follow-up):
4181
+ // every relational-query recognizer built so far (RELATION_FACT_YESNO_RE,
4182
+ // the (a0) block just above) requires BOTH subject and object — a yes/no
4183
+ // answer for one named pair. This is the REVERSE shape: given a relation
4184
+ // name and an OBJECT, find every SUBJECT that satisfies it. Re-derives
4185
+ // relationFactsFor/resolveRelationChase's SAME resolution logic (never
4186
+ // duplicating the SEARCH kernels themselves — findIsaChain, findReachableSet
4187
+ // — only the small, cheap, pure list-builder around them), for the SAME
4188
+ // reason (a0.5)'s own list block re-derives relationFactsFor rather than
4189
+ // sharing it with (a0): RELATION_WHO_ASK_RE and RELATION_FACT_YESNO_RE never
4190
+ // both match the same query (one starts with "who", the other with
4191
+ // "is/are/was/were"), so the two blocks never run in the same call.
4192
+ const whoAsk = qHedge.match(RELATION_WHO_ASK_RE);
4193
+ if (whoAsk) {
4194
+ const relationName = whoAsk[1].trim().toLowerCase();
4195
+ const rawObject = whoAsk[2].trim();
4196
+ const object = IS_ADJECTIVE_PRONOUN_RE.test(rawObject) ? (focusLabel || null) : rawObject;
4197
+ if (object) {
4198
+ const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
4199
+ const aliasSubClassEdges = rows
4200
+ .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
4201
+ .map((f) => [f.subject, f.object]);
4202
+ const { findIsaChain: chaseAliasWho } = await import("./syllogise.mjs");
4203
+ // Same candidate-list shape as (a0)'s own relationFactsFor — every
4204
+ // stored Fact whose predicate resolves, directly or via a TAUGHT
4205
+ // rdfs:subClassOf chain over relation-NAME strings, to `name`.
4206
+ const relationFactsForWho = (name) => {
4207
+ const target = String(name || "").trim().toLowerCase();
4208
+ const out = [];
4209
+ for (const f of rows) {
4210
+ const role = relationRoleWord(f.predicate);
4211
+ if (!role) continue;
4212
+ if (role === target) { out.push({ fact: f, aliasFacts: [] }); continue; }
4213
+ const chain = chaseAliasWho(role, new Set([target]), [], aliasSubClassEdges, { maxHops: 2 });
4214
+ if (!chain) continue;
4215
+ const aliasFacts = chain.map((step) => rows.find(
4216
+ (r) => r.predicate === SUBCLASS_PREDICATE && r.subject === step.subject && r.object === step.object,
4217
+ ));
4218
+ if (aliasFacts.every(Boolean)) out.push({ fact: f, aliasFacts });
4219
+ }
4220
+ return out;
4221
+ };
4222
+ const {
4223
+ loadMemory: loadMemWho, findRuleByName: findRuleByNameWho, RULE_KIND_PROP: ruleKindPropWho,
4224
+ RULE_KIND_COMPOSE2: composeKindWho, RULE_KIND_FILTER: filterKindWho,
4225
+ } = await import("./memory/core.mjs");
4226
+ const memoryWho = await loadMemWho(memoryDir);
4227
+ // Generic REVERSE relation-NAME resolver — the mirror image of (a0)'s
4228
+ // resolveRelationChase: given a relation/rule name and a FIXED OBJECT,
4229
+ // return every { subject, citation } pair that satisfies it, instead of
4230
+ // a single yes/no for a fixed (subject, object) pair. Recursion is
4231
+ // bounded the SAME way (a0)'s own chase is (§3.3): a filter rule's base
4232
+ // is always either a plain relation (terminal) or another rule (one
4233
+ // level deeper), never itself.
4234
+ const resolveRelationChaseReverse = async (name, objectTerm) => {
4235
+ const target = String(name || "").trim().toLowerCase();
4236
+ const ov = factTermVariants(normFactTerm, objectTerm);
4237
+ // (i)+(ii): every direct/alias-chased fact under this name whose
4238
+ // object matches the target — one result per distinct subject (the
4239
+ // highest-trust fact when more than one reaches the same subject).
4240
+ const directHits = relationFactsForWho(target).filter((e) => ov.has(e.fact.object));
4241
+ if (directHits.length) {
4242
+ const bySubject = new Map();
4243
+ for (const e of directHits) {
4244
+ if (!bySubject.has(e.fact.subject)) bySubject.set(e.fact.subject, []);
4245
+ bySubject.get(e.fact.subject).push(e);
4246
+ }
4247
+ return [...bySubject.entries()].map(([subj, hits]) => {
4248
+ const hit = hits.slice().sort((a, b) => byTrust(a.fact, b.fact))[0];
4249
+ return {
4250
+ subject: subj,
4251
+ citation: [renderFactLine(hit.fact), ...hit.aliasFacts.map(
4252
+ (af) => `${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`,
4253
+ )],
4254
+ };
4255
+ });
4256
+ }
4257
+ const rule = findRuleByNameWho(memoryWho, target);
4258
+ const ruleKind = rule?.attributes?.find((a) => a.prop === ruleKindPropWho)?.value;
4259
+ // (iii) COMPOSE2 REVERSE CHASE — the same hop-counted search (a0)'s
4260
+ // forward chase uses, walked BACKWARD: seed from the TARGET object,
4261
+ // reverse-hop via base2's edges first (the SECOND forward hop,
4262
+ // closest to the object), then base1's edges (the FIRST forward
4263
+ // hop) — swapping which side of each fact is queried (object instead
4264
+ // of subject) rather than building a new search kernel. Enumerates
4265
+ // every subject reachable at EXACTLY 2 reverse hops (never just
4266
+ // "reachable within budget" — the same exact-hop-count discipline
4267
+ // (a0)'s own isGoal uses), via findReachableSet (already proven
4268
+ // cycle-safe by item 6's own reachability-list wiring, (a0.5) below).
4269
+ if (rule && ruleKind === composeKindWho) {
4270
+ const base1 = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
4271
+ const base2 = rule.attributes.find((a) => a.prop === "mgx:ruleBase2")?.value;
4272
+ const targetEntity = normFactTerm(objectTerm);
4273
+ if (!base1 || !base2 || !targetEntity) return [];
4274
+ const { findReachableSet: findReachableSetWho } = await import("./planning.mjs");
4275
+ const applyActionsRev = (state) => {
4276
+ if (state.hopsTaken >= 2) return [];
4277
+ const relName = state.hopsTaken === 0 ? base2 : base1;
4278
+ return relationFactsForWho(relName)
4279
+ .filter((e) => e.fact.object === state.entity)
4280
+ .map((e) => ({ action: e, nextState: { entity: e.fact.subject, hopsTaken: state.hopsTaken + 1 } }));
4281
+ };
4282
+ const stateKeyRev = (state) => `${state.entity}#${state.hopsTaken}`;
4283
+ const reached = findReachableSetWho(
4284
+ { entity: targetEntity, hopsTaken: 0 }, applyActionsRev, { maxDepth: 2, stateKey: stateKeyRev },
4285
+ );
4286
+ return reached.filter((r) => r.node.hopsTaken === 2).map(({ node, path }) => {
4287
+ const seenAlias = new Set();
4288
+ const parts = [];
4289
+ // path.actions was accumulated walking BACKWARD from the object
4290
+ // (base2's edge first, base1's edge second) — reversed here so
4291
+ // the citation reads in the natural subject-to-object order
4292
+ // ("ahab fathers john; …; john fathers ishmael"), matching (a0)'s
4293
+ // own forward-chase citation order rather than exposing the
4294
+ // reverse-walk's internal accumulation order to the user.
4295
+ for (const e of path.actions.slice().reverse()) {
4296
+ parts.push(renderFactLine(e.fact));
4297
+ for (const af of e.aliasFacts) {
4298
+ const key = af.id || `${af.subject}|${af.predicate}|${af.object}`;
4299
+ if (seenAlias.has(key)) continue;
4300
+ seenAlias.add(key);
4301
+ parts.push(`${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`);
4302
+ }
4303
+ }
4304
+ return { subject: node.entity, citation: parts };
4305
+ });
4306
+ }
4307
+ // (iv) FILTER REVERSE CHASE — reverse-chase the base (recursively,
4308
+ // same as the forward filter chase — this SAME function calls
4309
+ // itself), then filter the resulting subjects by whether EACH
4310
+ // carries the taught property.
4311
+ if (rule && ruleKind === filterKindWho) {
4312
+ const base = rule.attributes.find((a) => a.prop === "mgx:ruleBase1")?.value;
4313
+ const property = rule.attributes.find((a) => a.prop === "mgx:ruleFilterProperty")?.value;
4314
+ if (!base || !property) return [];
4315
+ const baseHits = await resolveRelationChaseReverse(base, objectTerm);
4316
+ const propertyNorm = normFactTerm(property);
4317
+ const out = [];
4318
+ for (const bh of baseHits) {
4319
+ const subjectEntity = normFactTerm(bh.subject);
4320
+ const propHit = rows.find(
4321
+ (f) => f.predicate === HAS_PROPERTY_PREDICATE && f.subject === subjectEntity && normFactTerm(f.object) === propertyNorm,
4322
+ );
4323
+ if (propHit) out.push({ subject: bh.subject, citation: [...bh.citation, renderFactLine(propHit)] });
4324
+ }
4325
+ return out;
4326
+ }
4327
+ return []; // no remembered fact, alias, or rule (of any kind) reaches this
4328
+ };
4329
+ const hits = await resolveRelationChaseReverse(relationName, object);
4330
+ if (hits.length) {
4331
+ const lines = hits.map((h) => `${h.subject} — ${h.citation.join("; ")}`);
4332
+ return { text: lines.join("\n"), replace: true };
4333
+ }
4334
+ // Gap 1's SAME two-case honest-miss discipline, mirrored for the
4335
+ // reverse shape: is the relation/rule name known at all, or known but
4336
+ // empty for this particular object?
4337
+ const nameKnownWho = relationFactsForWho(relationName).length > 0
4338
+ || !!findRuleByNameWho(memoryWho, relationName);
4339
+ if (!nameKnownWho) {
4340
+ return { text: `I don't know a relation or rule called '${relationName}' yet.`, replace: true };
4341
+ }
4342
+ return {
4343
+ text: `I don't know anyone who is the ${relationName} of ${object} from what you've told me.`,
4344
+ replace: true,
4345
+ };
4346
+ }
4347
+ }
4348
+
4349
+ // (a0.5) RECURSIVE-RULE REACHABILITY LIST — "list the <plural> of <X>"
4350
+ // (PLAN_TAUGHT_RELATIONS.md Item 6, Phase 6's wiring half): a genuine
4351
+ // KIND-CHANGE from the yes/no dispatcher just above — REACHABILITY-SET
4352
+ // enumeration (every node ever reached), not single-target search.
4353
+ // Dispatches to a `recursive`-kind taught Rule via the SAME "what kind of
4354
+ // thing is this name" lookup (findRuleByName) the yes/no dispatcher uses,
4355
+ // then calls findReachableSet (src/planning.mjs, Phase 6's own kernel
4356
+ // half, landed unmodified here) seeded from baseCase's taught edges for
4357
+ // the start entity, stepping via recStep's edges at every further hop.
4358
+ // Renders each result with its own derivation path, mirroring the yes/no
4359
+ // chain-citation style above (renderFactLine + interleaved alias-fact
4360
+ // citations, deduped). No hit at all → null, the honest miss stands.
4361
+ const listAsk = qHedge.match(RECURSIVE_LIST_ASK_RE);
4362
+ if (listAsk) {
4363
+ const ruleName = singularizeSurface(listAsk[1].trim().toLowerCase());
4364
+ const rawSubject = listAsk[2].trim();
4365
+ const subject = IS_ADJECTIVE_PRONOUN_RE.test(rawSubject) ? (focusLabel || null) : rawSubject;
4366
+ if (subject) {
4367
+ const {
4368
+ loadMemory, findRuleByName, RULE_KIND_PROP: ruleKindProp, RULE_KIND_RECURSIVE: recKind,
4369
+ } = await import("./memory/core.mjs");
4370
+ const memory = await loadMemory(memoryDir);
4371
+ const rule = findRuleByName(memory, ruleName);
4372
+ const ruleKind = rule?.attributes?.find((a) => a.prop === ruleKindProp)?.value;
4373
+ if (rule && ruleKind === recKind) {
4374
+ const baseCase = rule.attributes.find((a) => a.prop === "mgx:ruleBaseCase")?.value;
4375
+ const recStep = rule.attributes.find((a) => a.prop === "mgx:ruleRecStep")?.value;
4376
+ const startEntity = normFactTerm(subject);
4377
+ if (baseCase && recStep && startEntity) {
4378
+ const isTaughtRow = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
4379
+ const aliasSubClassEdges = rows
4380
+ .filter((f) => f.predicate === SUBCLASS_PREDICATE && isTaughtRow(f))
4381
+ .map((f) => [f.subject, f.object]);
4382
+ const { findIsaChain: chaseAlias } = await import("./syllogise.mjs");
4383
+ // Same alias-chase substrate the yes/no dispatcher's own
4384
+ // relationFactsFor uses (re-derived here rather than shared across
4385
+ // the two `if` blocks, which never run in the same call — one
4386
+ // regex or the other matches, never both).
4387
+ const relationFactsForList = (name) => {
4388
+ const target = String(name || "").trim().toLowerCase();
4389
+ const out = [];
4390
+ for (const f of rows) {
4391
+ const role = relationRoleWord(f.predicate);
4392
+ if (!role) continue;
4393
+ if (role === target) { out.push({ fact: f, aliasFacts: [] }); continue; }
4394
+ const chain = chaseAlias(role, new Set([target]), [], aliasSubClassEdges, { maxHops: 2 });
4395
+ if (!chain) continue;
4396
+ const aliasFacts = chain.map((step) => rows.find(
4397
+ (r) => r.predicate === SUBCLASS_PREDICATE && r.subject === step.subject && r.object === step.object,
4398
+ ));
4399
+ if (aliasFacts.every(Boolean)) out.push({ fact: f, aliasFacts });
4400
+ }
4401
+ return out;
4402
+ };
4403
+ // hop 0 (the base case) uses baseCase's edges; every hop after
4404
+ // that uses recStep's edges — a plain hop counter would need to
4405
+ // fold into the state-identity key (defeating dedup-by-node), so
4406
+ // the dedup key is the ENTITY alone (stateKey below): once a node
4407
+ // is reached via its SHORTEST path, a longer alternate path to the
4408
+ // same node is correctly pruned, never re-recorded or re-expanded
4409
+ // (this is also what makes a genuine cycle in the taught edges —
4410
+ // e.g. two individuals mutually taught as each other's parent —
4411
+ // terminate safely: the cyclic-back node is already `seen`).
4412
+ const { findReachableSet } = await import("./planning.mjs");
4413
+ const applyActions = (state) => {
4414
+ const relName = state.hop === 0 ? baseCase : recStep;
4415
+ return relationFactsForList(relName)
4416
+ .filter((e) => e.fact.subject === state.entity)
4417
+ .map((e) => ({ action: e, nextState: { entity: e.fact.object, hop: state.hop + 1 } }));
4418
+ };
4419
+ const stateKey = (state) => state.entity;
4420
+ const results = findReachableSet({ entity: startEntity, hop: 0 }, applyActions, { maxDepth: 20, stateKey });
4421
+ if (results.length) {
4422
+ const lines = results.map(({ node, path }) => {
4423
+ const seenAlias = new Set();
4424
+ const parts = [];
4425
+ for (const e of path.actions) {
4426
+ parts.push(renderFactLine(e.fact));
4427
+ for (const af of e.aliasFacts) {
4428
+ const key = af.id || `${af.subject}|${af.predicate}|${af.object}`;
4429
+ if (seenAlias.has(key)) continue;
4430
+ seenAlias.add(key);
4431
+ parts.push(`${factPhrase(af)}${af.provenance ? ` (source: ${af.provenance})` : ""}`);
4432
+ }
4433
+ }
4434
+ return `${node.entity} — ${parts.join("; ")}`;
4435
+ });
4436
+ return { text: lines.join("\n"), replace: true };
4437
+ }
4438
+ }
4439
+ }
4440
+ return null; // no taught recursive rule of this name reaches anything — honest miss
4441
+ }
4442
+ }
4443
+
3613
4444
  // (a) FORWARD membership — "is an X a Y". X's fact-subject candidates are the
3614
4445
  // term itself (a class word) AND, when it resolves in the graph, its class-noun
3615
4446
  // (an instance) — so "is app/lib/a.mjs a component" answers off "module …".
package/src/planning.mjs CHANGED
@@ -40,6 +40,23 @@ function defaultStateKey(state) {
40
40
  return String(state);
41
41
  }
42
42
 
43
+ /** The one-hop expansion of `startState` into an initial frontier of
44
+ * `{ state, actions, states }` path-entries — IDENTICAL in both
45
+ * `findActionPath` and `findReachableSet` below (seeding a frontier from a
46
+ * start state has no goal/accumulation semantics to differ on: it is pure
47
+ * "call `applyActions` once, wrap each result"), so this one small step is
48
+ * genuinely, safely shared rather than duplicated verbatim in both
49
+ * functions. See the file-header note above `findActionPath` for why the
50
+ * REST of the two functions' bodies are deliberately NOT merged the same
51
+ * way. */
52
+ function seedFrontier(startState, applyActions) {
53
+ const frontier = [];
54
+ for (const { action, nextState } of applyActions(startState) || []) {
55
+ frontier.push({ state: nextState, actions: [action], states: [startState, nextState] });
56
+ }
57
+ return frontier;
58
+ }
59
+
43
60
  /**
44
61
  * Bounded, cycle-safe, shortest-path-first breadth-first search over a state
45
62
  * space whose successors are generated ON DEMAND, not pre-loaded.
@@ -77,10 +94,7 @@ function defaultStateKey(state) {
77
94
  export function findActionPath(startState, isGoal, applyActions, { maxDepth = 50, stateKey = defaultStateKey } = {}) {
78
95
  if (isGoal(startState)) return { actions: [], states: [startState] };
79
96
 
80
- let frontier = [];
81
- for (const { action, nextState } of applyActions(startState) || []) {
82
- frontier.push({ state: nextState, actions: [action], states: [startState, nextState] });
83
- }
97
+ let frontier = seedFrontier(startState, applyActions);
84
98
 
85
99
  // depth counts the LENGTH of the paths currently in `frontier` (1 at the
86
100
  // first check) — exactly `findIsaChain`'s own "hop counts the LENGTH of the
@@ -107,3 +121,107 @@ export function findActionPath(startState, isGoal, applyActions, { maxDepth = 50
107
121
  }
108
122
  return null;
109
123
  }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // findReachableSet — PLAN_TAUGHT_RELATIONS.md, Item 6 (recursive/reachability
127
+ // rules) kernel half. Query-side need: "list every X reachable from Y" (e.g.
128
+ // "list the descendants of ahab") is REACHABILITY-SET ENUMERATION, not
129
+ // single-goal search — checked against `findActionPath` above's own body:
130
+ // it returns the INSTANT the first goal-satisfying state is found (the
131
+ // `for (const entry of frontier) if (isGoal(entry.state)) return …` line),
132
+ // so there is no way to keep it running to collect every reachable node
133
+ // without changing both its halting condition (early-return vs. never-
134
+ // return-early) AND its return shape (one path vs. every path) — a
135
+ // genuinely new function, not a parameter tweak, per the plan's own
136
+ // analysis.
137
+ //
138
+ // Code-sharing decision (asked for explicitly, decided fresh here rather
139
+ // than copying the plan doc's framing verbatim): the file-header reasoning
140
+ // for why `findActionPath` is an independent SIBLING of `findIsaChain`
141
+ // (lines 1-32) is "pre-built static edge maps vs. on-demand successor
142
+ // generation don't share an implementation, only a discipline." That
143
+ // reasoning does NOT distinguish `findActionPath` from `findReachableSet`
144
+ // — both call the caller's `applyActions(state)` fresh at every expansion;
145
+ // neither pre-builds anything. So on the file's own stated logic, these two
146
+ // are legitimately closer to each other than either is to `findIsaChain`,
147
+ // and it's worth asking whether MORE sharing is warranted here specifically
148
+ // — not just repeating the same verdict by default.
149
+ //
150
+ // Having written both bodies out, the answer is: share the one step that is
151
+ // truly identical (`seedFrontier` above — a single `applyActions(startState)`
152
+ // call with no goal/accumulation semantics to differ on), but keep the main
153
+ // expand-loop bodies independent. The reason isn't "different edge
154
+ // generation" this time — it's that the two loops' HALTING and RESULT-
155
+ // COLLECTION semantics are irreducibly different: `findActionPath` returns
156
+ // the instant ANY frontier entry satisfies `isGoal`, discarding the rest of
157
+ // the frontier and every state it hasn't reached yet; `findReachableSet`
158
+ // never returns early, has no predicate at all, and must keep every
159
+ // newly-seen state (not just one) across the entire bounded search. Forcing
160
+ // both through one shared "expand a frontier" core would mean threading an
161
+ // optional `isGoal` (or a sentinel "never" predicate) AND an accumulator
162
+ // mode through a single function — that parameter surface would itself
163
+ // recreate the complexity the merge was meant to remove, for a savings of
164
+ // roughly the ~10-line inner loop. Given the file's own established
165
+ // precedent of favoring readable independent siblings over cleverly
166
+ // parameterized cores, and that the one truly shared step already isn't
167
+ // duplicated (`seedFrontier`), landing this as an independent sibling
168
+ // remains the right call — just not for the identical reason `findIsaChain`
169
+ // vs. `findActionPath` had.
170
+ /**
171
+ * Bounded, cycle-safe breadth-first ENUMERATION of every state reachable
172
+ * from `startState` within `maxDepth` hops — the reachability-set sibling of
173
+ * `findActionPath`'s single-goal search.
174
+ *
175
+ * - `startState`, `applyActions`, `opts.maxDepth`, `opts.stateKey` — same
176
+ * meaning and defaults as `findActionPath` (see above); successors are
177
+ * generated ON DEMAND by calling `applyActions(state)` fresh at every
178
+ * expansion, nothing precomputed or cached.
179
+ * - Deliberately NO `isGoal` parameter: every state reachable from
180
+ * `startState` (EXCLUDING `startState` itself — the start is where you
181
+ * already are, not a reachable result) within the hop budget is a
182
+ * result, not just one goal-satisfying state.
183
+ *
184
+ * Returns an array of `{ node, path: { actions, states } }` — one entry per
185
+ * distinct reachable state, `path` mirroring `findActionPath`'s own
186
+ * `{ actions, states }` return shape (the action sequence AND intermediate
187
+ * states from `startState` to that node), so a caller gets "how did we get
188
+ * here" for every reachable node, not just one. Returns `[]` (never
189
+ * `null`/`undefined`) when nothing is reachable within budget — reachability
190
+ * enumeration has no "miss" case the way single-goal search does; an empty
191
+ * result set is itself the honest, complete answer.
192
+ *
193
+ * Cycle-safe via the same `seen` state-key convention as `findActionPath`: a
194
+ * state is recorded (and expanded) only the FIRST time it is reached, so the
195
+ * shortest path to it is what gets stored, and a state reachable by two
196
+ * different routes (or sitting inside a genuine cycle) is reported exactly
197
+ * once, never duplicated, and never causes an infinite loop.
198
+ */
199
+ export function findReachableSet(startState, applyActions, { maxDepth = 50, stateKey = defaultStateKey } = {}) {
200
+ let frontier = seedFrontier(startState, applyActions);
201
+
202
+ const seen = new Set([stateKey(startState)]);
203
+ const results = [];
204
+ // Single combined loop (not findActionPath's check-then-separate-extend):
205
+ // there is no per-iteration early return to protect here, so recording a
206
+ // newly-seen state and deciding whether to expand it past it can live in
207
+ // the same pass without losing any of findActionPath's check-then-extend
208
+ // discipline — a state discovered exactly at maxDepth is still recorded
209
+ // (it IS reachable within budget) but is never expanded past it.
210
+ for (let depth = 1; depth <= maxDepth && frontier.length; depth += 1) {
211
+ const next = [];
212
+ for (const entry of frontier) {
213
+ const key = stateKey(entry.state);
214
+ if (seen.has(key)) continue; // already recorded via an earlier (shorter-or-equal) path
215
+ seen.add(key);
216
+ results.push({ node: entry.state, path: { actions: entry.actions, states: entry.states } });
217
+ if (depth === maxDepth) continue; // recorded, but budget exhausted — do not expand further
218
+ for (const { action, nextState } of applyActions(entry.state) || []) {
219
+ const nk = stateKey(nextState);
220
+ if (seen.has(nk)) continue;
221
+ next.push({ state: nextState, actions: [...entry.actions, action], states: [...entry.states, nextState] });
222
+ }
223
+ }
224
+ frontier = next;
225
+ }
226
+ return results;
227
+ }