@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 +86 -0
- package/package.json +1 -1
- package/src/chat.mjs +835 -4
- package/src/planning.mjs +122 -4
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.
|
|
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
|
-
|
|
1946
|
-
|
|
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
|
-
|
|
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
|
+
}
|