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

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/README.md CHANGED
@@ -127,6 +127,15 @@ Teaching isn't limited to the ACE grammar's fixed shapes. Tell tmct an
127
127
  arbitrary fact, like "margo eats ribs", and it mints a fact you can later ask
128
128
  about directly: "what does margo eat", or "does margo eat ribs".
129
129
 
130
+ New vocabulary compounds as you teach it. "redis is a cache" mints "redis" as
131
+ a class-level concept even though it was never in the built-in lexicon, and a
132
+ later "every cache is a store" does the same for "store," the other way
133
+ round, as long as one side of the sentence is already grounded. tmct never
134
+ mints a fact between two totally ungrounded terms; it declines and nudges you
135
+ to ground one side first. Quantified teaching works too: "some functions are
136
+ risky" stores the quantifier, and a later "how many functions are risky"
137
+ answers "A few."
138
+
130
139
  ### Provenance and trust
131
140
 
132
141
  Every fact and text block records **where it came from and when**. Sources are
package/ROADMAP.md CHANGED
@@ -20,7 +20,7 @@ Tiers 0, 1, 2, and 4 each closed in one pass. Tier 3 took 7 passes to track down
20
20
  turned up one genuinely important correctness bug alongside a batch of routing fixes. Full
21
21
  per-cycle detail is in `HANDOVER.md`'s "The dialogue-flow playtest loop" section.
22
22
 
23
- `npm test` is green at **1352** (up from 1258 at the start of this session). v1.0.7 is
23
+ `npm test` is green at **1355** (up from 1258 at the start of this session). v1.0.7 is
24
24
  published (0.9.11 → 1.0.0 → 1.0.7 across an earlier session; the exact release chain and
25
25
  file:line detail are in `HANDOVER.md`). Nothing has pushed since, so the local version sits at
26
26
  1.0.9 per the bump-at-push-time policy.
@@ -34,6 +34,41 @@ Test count across the session's later stretch:
34
34
  | Tier 5 (teach + recall + reasoning in dialogue), 5 cycles | 1307 → 1328 |
35
35
  | Tier 6 (the messy real user), 5 cycles, run alongside a background test-suite health pass | 1328 → 1345 |
36
36
  | Compound-name resolution (multi-word queries to joined-token symbol names) | 1345 → 1352 |
37
+ | Vocabulary-growth mirror fix — known-subject/unknown-object mint (`unknownObjectFallback`), so new terms compound turn over turn | 1352 → 1355 |
38
+ | `findActionPath` (`src/planning.mjs`) — generic bounded on-demand-successor state-space search, `PLAN_HANOI.md`'s Phase 2 kernel, proven against a small toy graph; not wired into chat, Hanoi itself not started | 1355 → 1361 |
39
+
40
+ **INFBENCH re-measured against 1.2.0** (measurement-only dispatch, 2026-07-09): `INFBENCH_1.2.0.md`
41
+ confirms chat/INF-A2 now closes to 100% (the cax-sco/proof-chase win the STATUS banner above already
42
+ claimed) but also finds chat/INF-C1 has flipped from an honest ceiling to a genuine 93%-fabrication
43
+ regression, traced to the new general-verb-to-predicate query lane answering "no" on an absent fact
44
+ instead of declining — a real correctness bug, separate from and cheaper than the still-gating
45
+ INF-B1 (`cax-dw`) work.
46
+
47
+ **INF-C1 fabrication FIXED (2026-07-09, follow-up dispatch)**: `GENERAL_VERB_YESNO_RE`'s no-hit
48
+ branch (`src/chat.mjs`) now declines (`null`) instead of asserting a confident "no" when no taught
49
+ fact matches the queried subject/predicate/object triple, falling through to the ordinary
50
+ honest-miss cascade — same convention as `WHO_OWNS_RE`'s own no-hit branch. Re-ran `npm run
51
+ infbench`: chat/INF-C1 is back to **93% completion / 0% fabrication**, its `0.8.2`-era honest
52
+ ceiling, exactly as predicted (up from `1.2.0`'s 0% completion / 93% fabrication). Everything else
53
+ in the ladder is unchanged — still gated at INF-B1 (33% completion), unaffected by this fix.
54
+ `npm test` 1361 → 1362 (this fix's own contribution; see `HANDOVER.md` for the combined total
55
+ alongside the concurrent Rule-storage dispatch).
56
+
57
+ **`PLAN_TAUGHT_RELATIONS.md`** (research/design, 2026-07-09, nothing implemented): scopes teaching
58
+ tmct brand-new relations and rules through ordinary chat (a Prolog-style family tree — father,
59
+ parent, grandparent, descendant — none of it hardcoded, all of it taught), reusing
60
+ `findActionPath` for the hop-counted relation chase and a new sibling kernel, `findReachableSet`,
61
+ for open-ended enumeration. Live-testing while designing it surfaced real, already-shipped gaps:
62
+ the "is a kind of" teach phrasing isn't accepted anywhere today, a "parent" example in the original
63
+ scoping conversation only worked by an accidental lexicon collision, and a wrapped property-teach
64
+ shape (`TEACH_PROPERTY_RE`) has no groundedness check at all, unlike the newer subject/object
65
+ mint-fallback pair's explicit discipline. See `HANDOVER.md` for the full finding list; this is
66
+ next-session pickup material, not yet started.
67
+
68
+ **`PLAN_TAUGHT_RELATIONS.md` Phase 3 — DONE (2026-07-09)**: the Rule storage foundation landed in
69
+ `src/memory/core.mjs` (`RULE_CLASS`, `appendRule`, `findRuleByName`) — pure plumbing, zero
70
+ `chat.mjs` change, reusing the existing Source/trust pipeline unmodified. `npm test` 1361 → 1371.
71
+ Phase 4 (compose2 query-side wiring) is next in that plan's build order.
37
72
 
38
73
  ### Shipped this session
39
74
 
@@ -62,6 +97,17 @@ Test count across the session's later stretch:
62
97
  hand-rolled copy of `WALL_MISS_RE` with the real export; and extracted a shared session-driver
63
98
  helper (`test/helpers/session.mjs`), replacing 11 near-duplicate `drive()`/`driveSession()`
64
99
  implementations. Full detail is in `HANDOVER.md`'s "Test-suite health pass" entry.
100
+ - **Vocabulary-growth mirror fix.** New vocabulary used to grow one-directionally only: "redis is
101
+ a cache" could mint the unknown subject "redis" because the object "cache" was already a known
102
+ noun, but the reverse ("every cache is a store," subject known, object unknown) declined
103
+ outright. Added `unknownObjectFallback`, gated on a genuine universal quantifier ("every"/"each"/
104
+ "all") so it can't reopen the general lexicon bypass the existing bare/"a" shapes rely on. A term
105
+ minted by either direction now grounds a later sentence exactly like a lexicon word, using
106
+ taught-only groundedness checks that deliberately exclude the bulk ConceptNet corpus seed (the
107
+ corpus mentions ordinary English words constantly and must never silently count as "grounded").
108
+ When both sides are totally ungrounded, tmct still declines, but now with an actionable grounding
109
+ nudge instead of a bare "I couldn't store that." New coverage in
110
+ `test/chat-teach-quantifier.test.mjs`.
65
111
  - **Compound-name resolution**, from the operator's own worked example: "the payment system" now
66
112
  finds `PaymentSystem`, `payment-system`, a compound path like
67
113
  `westfield-payment-system/src/MyCode.cs`, and an interface-style name like
@@ -984,14 +1030,22 @@ minimal benchmark domains before anything domain-general is attempted:
984
1030
  slot (`game`) threaded through `createSession`/`runTurn` exactly the way `focus` already is,
985
1031
  kept deliberately separate from the `pending` pagination field since a game must survive an
986
1032
  aside mid-play, unlike a listing remainder.
987
-
988
- Both docs converge on the SAME one genuinely new primitive neither doc found already built
1033
+ - `PLAN_TAUGHT_RELATIONS.md` — teaching tmct brand-new relations and rules through ordinary chat
1034
+ (a taught Prolog-style family tree, none of the kinship vocabulary hardcoded), the first of the
1035
+ three to need a successor function SYNTHESIZED from data the user taught in an earlier turn,
1036
+ rather than hand-written per domain the way Hanoi's `legalMoves` and guess-number's
1037
+ interval-update rule are. Its own enumeration capability ("list the descendants of X," no fixed
1038
+ goal) needs a genuine new sibling kernel, `findReachableSet`, since `findActionPath` only ever
1039
+ searches toward one goal.
1040
+
1041
+ All three docs converged on the one genuinely new primitive none of them found already built
989
1042
  anywhere in tmct: something that computes a SUCCESSOR STATE (apply a chosen action, produce the
990
1043
  next graph/belief to reason over) — every existing traversal (`ancestorsOf`, `computeFind`,
991
- `findIsaChain` itself) is read-only. That primitive, plus a still-open recognition question (how
992
- tmct notices "the user wants goal-directed action" at all, and whether multi-step execution needs
993
- confirmation before running) is the real next-session scope a dedicated design/implementation
994
- session, not a routing fix.
1044
+ `findIsaChain` itself) is read-only. That primitive now exists (`findActionPath`, `src/planning.mjs`,
1045
+ shipped this session see "Shipped this session" above), proven against a small toy graph but not
1046
+ wired into any of the three domains yet. The remaining next-session scope is that wiring, plus a
1047
+ still-open recognition question: how tmct notices "the user wants goal-directed action" at all, and
1048
+ whether multi-step execution needs confirmation before running.
995
1049
 
996
1050
  ### The design horizon
997
1051
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.0.9",
3
+ "version": "1.3.0",
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
@@ -1474,6 +1474,112 @@ const SOME_A_FEW_RE = /^(some|a few)\s+([\w-]+)\s+are\s+([\w-]+)$/i;
1474
1474
  * the ambiguity a fully free-form multi-word subject would otherwise have. */
1475
1475
  const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|a\s+|an\s+)?([\w-]+(?:\s+[\w-]+)?)\s+(?:is|are)\s+(?:an?\s+)?([\w-]+)$/i;
1476
1476
 
1477
+ /** ISA-family predicates (mirrors the private ISA_PREDICATES set defined near
1478
+ * memoryFacts, below, at module scope — both are simple top-level consts
1479
+ * evaluated once at load time, so referencing either from a function defined
1480
+ * earlier in this file is safe: no function here actually RUNS until well
1481
+ * after the whole module has finished loading). Named again here, right by
1482
+ * its one caller, so isGroundedByFact reads standalone. */
1483
+ const MINT_ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
1484
+
1485
+ /** Small CLOSED set of generic English root nouns that count as always-
1486
+ * grounded anchor terms for the mint-fallbacks below (operator refinement,
1487
+ * 2026-07-09) — deliberately NOT added to lexicon-core.json itself (that
1488
+ * file stays the curated ~180-word CODE vocabulary; these are ordinary-
1489
+ * English root nouns with no code meaning at all, confirmed absent from it
1490
+ * today). Their only job is to give a user who hits the "both sides
1491
+ * ungrounded" decline (groundingSuggestionMiss, below) an honest, guessable
1492
+ * way in: ground one brand-new term via one of THESE words first ("every
1493
+ * zorp is a thing"), then chain the other new term off the now-grounded one. */
1494
+ const GENERIC_ANCHOR_NOUNS = new Set(["thing", "concept", "object", "entity"]);
1495
+
1496
+ /** Shared fact-groundedness primitive (Feature A mint-extension, point 2):
1497
+ * true when `term` already appears as the SUBJECT or OBJECT of a previously
1498
+ * taught isa-family fact (rdfs:subClassOf/rdf:type) in memory. A term minted
1499
+ * by EITHER mint-fallback below (this session, or an earlier one — this
1500
+ * reads persisted memory, not session-scoped state) is exactly as legitimate
1501
+ * an anchor for a NEW fact as a static lexicon-core.json word — the whole
1502
+ * point of this extension is letting new vocabulary compound turn over turn
1503
+ * ("every cache is a store" mints "store"; "every store is a container" then
1504
+ * needs "store" to read as known even though it's not in the static lexicon
1505
+ * at all). Read-only, reuses memoryFacts' plain read path (existence only —
1506
+ * no trust-ranking needed here) and normFactTerm's own normalization, so a
1507
+ * fact-grounded term matches under the EXACT spelling teachFact itself stored
1508
+ * it under. Failure-tolerated: no memory dir / no match → false, never a
1509
+ * guessed "yes". */
1510
+ async function isGroundedByFact(term, memoryDir) {
1511
+ if (!memoryDir) return false;
1512
+ const raw = String(term ?? "").trim();
1513
+ if (!raw) return false;
1514
+ const { normFactTerm } = await import("./memory/core.mjs");
1515
+ const t = normFactTerm(raw);
1516
+ if (!t) return false;
1517
+ // TAUGHT-only (same discipline factReadBack's own cax-sco/scm-sco proof
1518
+ // chase already uses, above, for the identical reason: the bulk background
1519
+ // corpus band (ConceptNet, trust 0.7, seeded by the thousands on a fresh
1520
+ // repo) mentions ordinary English words like "store"/"container" constantly
1521
+ // — treating THOSE as "grounded" would silently reopen the general lexicon
1522
+ // bypass this whole feature is deliberately narrow to avoid. Only what the
1523
+ // OPERATOR actually taught (or a prior `tmct syllogise` entailment) anchors
1524
+ // a term here. factRows (not memoryFacts) is used specifically because it's
1525
+ // the one read path that carries sourceTypes for this filter.
1526
+ const rows = await factRows(memoryDir);
1527
+ const isTaught = (f) => !f.sourceTypes?.includes("corpus") && !f.sourceTypes?.includes("web");
1528
+ return rows.some((f) => MINT_ISA_PREDICATES.has(f.predicate) && isTaught(f) && (f.subject === t || f.object === t));
1529
+ }
1530
+
1531
+ /** Shared "is this term grounded in ANY sense" aggregate (Feature A mint-
1532
+ * extension, point 2's named shared helper) — a static lexicon word (any
1533
+ * part of speech, via `classify`), a GENERIC_ANCHOR_NOUNS root, OR a term
1534
+ * already anchored by a previously taught isa-family fact (isGroundedByFact,
1535
+ * above). Used by unknownObjectFallback's subject/object groundedness checks
1536
+ * below, where no part-of-speech branching follows — just "known or not".
1537
+ * (unknownSubjectFallback's own object-known check, above/below, stays
1538
+ * narrower and NOUN-specific — see its own comment — so an object that's
1539
+ * merely a known ADJECTIVE doesn't get misrouted into the class/subClassOf
1540
+ * branch instead of the property branch.) */
1541
+ async function isGroundedTerm(term, lex, memoryDir) {
1542
+ const raw = String(term ?? "").trim();
1543
+ if (!raw) return false;
1544
+ if (GENERIC_ANCHOR_NOUNS.has(raw.toLowerCase())) return true;
1545
+ const { classify } = await import("./grammar/lexicon.mjs");
1546
+ if (classify(raw, lex)) return true;
1547
+ return isGroundedByFact(raw, memoryDir);
1548
+ }
1549
+
1550
+ /** The "both sides ungrounded" grounding NUDGE (operator refinement,
1551
+ * 2026-07-09): reuses teachSuggestion's own "compute a hint, APPEND it to
1552
+ * the existing honest-miss message, never replace/silently guess" pattern
1553
+ * (see its docblock, above, and the "did"/"why" append-style construction in
1554
+ * teachLane's own final decline, below) for a DIFFERENT decline case —
1555
+ * rather than mint a relationship between two brand-new terms (a real
1556
+ * fabrication risk, unknownObjectFallback's own explicit safety guard,
1557
+ * below), teachLane's final honest-miss text gets an EXTRA appended nudge
1558
+ * whenever the declined payload fit the "X is/are Y" shape
1559
+ * (UNKNOWN_SUBJECT_RE) but NEITHER side is grounded — an honest, actionable
1560
+ * way in: ground one side via a GENERIC_ANCHOR_NOUNS root first ("every zorp
1561
+ * is a thing"), then chain the other off the now-grounded term. Deliberately
1562
+ * APPENDED rather than a replacement/short-circuit: a "both sides
1563
+ * ungrounded" is/are sentence with a KNOWN subject on one side (e.g. "module
1564
+ * is banana") never reaches this at all (isGroundedTerm(subject) is true, so
1565
+ * the very first return below fires) — that stays unknownObjectFallback's
1566
+ * own mint territory, entirely unaffected here. Returns "" (message
1567
+ * unchanged) whenever the payload doesn't fit the shape, or at least one
1568
+ * side IS already grounded — a DIFFERENT, more specific reason it declined,
1569
+ * where this nudge would be actively unhelpful noise. */
1570
+ async function ungroundedPairHint(payload, lexicon, memoryDir) {
1571
+ if (!memoryDir) return "";
1572
+ const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
1573
+ if (!m) return "";
1574
+ const [, , subjectRaw, objectRaw] = m;
1575
+ const { loadLexicon } = await import("./grammar/lexicon.mjs");
1576
+ const lex = lexicon || loadLexicon();
1577
+ if (await isGroundedTerm(subjectRaw, lex, memoryDir)) return "";
1578
+ if (await isGroundedTerm(objectRaw, lex, memoryDir)) return "";
1579
+ return ` I don't know "${subjectRaw}" or "${objectRaw}" yet. Try grounding one first, e.g. `
1580
+ + `"every ${subjectRaw} is a thing", then "every ${objectRaw} is a ${subjectRaw}".`;
1581
+ }
1582
+
1477
1583
  /** The unknown-SUBJECT direct-write fallback (point 1 + point 2's bare-property
1478
1584
  * extension): tried ONLY after the real ACE grammar (assertTurn) has already
1479
1585
  * had its turn and declined. Declines itself (returns null, never a guess)
@@ -1485,18 +1591,25 @@ const UNKNOWN_SUBJECT_RE = /^(every\s+|each\s+|all\s+|a\s+|an\s+)?([\w-]+(?:\s+[
1485
1591
  * - X is actually a KNOWN lexicon word — then the ACE grammar's own miss was
1486
1592
  * a real structural/vocabulary problem elsewhere (e.g. Y itself unknown as
1487
1593
  * the WRONG part of speech), never silently reinterpreted through this
1488
- * narrow exception;
1489
- * - Y resolves as NEITHER a known noun NOR a known adjective — the OBJECT
1490
- * must still be a term tmct actually knows; an unknown Y stays an honest
1491
- * miss (never a guess), exactly like the pre-existing "monkey is an
1492
- * animal" case.
1493
- * Y resolving as a NOUN writes rdfs:subClassOf (mirrors the ACE grammar's own
1494
- * subClassOf/typeAssertion pattern); Y resolving as an ADJECTIVE (and not also
1495
- * a noun) writes mgx:hasProperty (mirrors the wrapped "remember that X is
1496
- * deprecated" property frame reused here for the bare/unwrapped form too,
1497
- * since the free pass is about the SUBJECT, not about the "remember that"
1498
- * wrapper). Only the "every" determiner records a quantifier (point 3: "a"/
1499
- * bare/"your" read as one specific entity, not a class-level generalization). */
1594
+ * narrow exception. (NOTE: this stays a STATIC-lexicon-only check,
1595
+ * deliberately not widened to isGroundedTerm a subject that's grounded
1596
+ * only via a PRIOR taught fact, not the static lexicon, is precisely the
1597
+ * case unknownObjectFallback, below, owns instead.)
1598
+ * - Y resolves as NEITHER a known noun NOR a known adjective NOR a term
1599
+ * already grounded by a prior taught fact / a GENERIC_ANCHOR_NOUNS root
1600
+ * (Feature A mint-extension, point 2 a term minted by
1601
+ * unknownObjectFallback, below, reads exactly as known here as any
1602
+ * lexicon word)the OBJECT must still be a term tmct actually knows;
1603
+ * an unknown Y stays an honest miss (never a guess), exactly like the
1604
+ * pre-existing "monkey is an animal" case.
1605
+ * Y resolving as a NOUN (or fact-/anchor-grounded) writes rdfs:subClassOf
1606
+ * (mirrors the ACE grammar's own subClassOf/typeAssertion pattern); Y
1607
+ * resolving as an ADJECTIVE (and not also a noun) writes mgx:hasProperty
1608
+ * (mirrors the wrapped "remember that X is deprecated" property frame —
1609
+ * reused here for the bare/unwrapped form too, since the free pass is about
1610
+ * the SUBJECT, not about the "remember that" wrapper). Only the "every"
1611
+ * determiner records a quantifier (point 3: "a"/bare/"your" read as one
1612
+ * specific entity, not a class-level generalization). */
1500
1613
  async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }) {
1501
1614
  if (!memoryDir) return null;
1502
1615
  const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
@@ -1507,7 +1620,12 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
1507
1620
  // A known X's own ACE miss is a real miss — never silently reinterpreted here.
1508
1621
  if (classify(subjectRaw, lex)) return null;
1509
1622
  const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
1510
- if (lookupNoun(lex, objectRaw)) {
1623
+ // Point 2 (mint-extension): a PRIOR turn's minted term, or a
1624
+ // GENERIC_ANCHOR_NOUNS root, grounds Y just as legitimately as a static
1625
+ // lexicon noun — both are always treated as class-level (never property),
1626
+ // consistent with unknownObjectFallback (below) always minting a CLASS.
1627
+ if (lookupNoun(lex, objectRaw) || GENERIC_ANCHOR_NOUNS.has(String(objectRaw).toLowerCase())
1628
+ || (await isGroundedByFact(objectRaw, memoryDir))) {
1511
1629
  return teachFact(memoryDir, sessionId, {
1512
1630
  subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
1513
1631
  });
@@ -1522,6 +1640,67 @@ async function unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon }
1522
1640
  return null; // Y unknown too — decline honestly, never guess
1523
1641
  }
1524
1642
 
1643
+ /** The unknown-OBJECT mint fallback (Feature A, 2026-07-09 operator-authorized
1644
+ * vocabulary-growth extension): the MIRROR of unknownSubjectFallback, above —
1645
+ * same "X is/are Y" payload shape (UNKNOWN_SUBJECT_RE, reused verbatim,
1646
+ * never a second regex for the identical shape), tried as a SIBLING call
1647
+ * right after unknownSubjectFallback in teachLane (below), but firing on the
1648
+ * OPPOSITE asymmetry: SUBJECT already grounded (a real lexicon-core.json
1649
+ * word of ANY part of speech, a GENERIC_ANCHOR_NOUNS root, OR a term a PRIOR
1650
+ * turn already minted via either fallback — isGroundedTerm, shared with this
1651
+ * check) and OBJECT completely ungrounded. Mints the object as a new
1652
+ * class-level concept (rdfs:subClassOf, same predicate/quantifier machinery
1653
+ * teachFact/unknownSubjectFallback already use) so ordinary conversation can
1654
+ * build up new vocabulary turn over turn: "every cache is a store" (subject
1655
+ * "cache" grounded via the static lexicon) mints "store"; a LATER "every
1656
+ * store is a container" then finds "store" grounded via the fact just
1657
+ * minted (not the static lexicon at all) and mints "container" the same way.
1658
+ *
1659
+ * GATED ON A GENUINE UNIVERSAL QUANTIFIER ("every"/"each"/"all" — never bare/
1660
+ * "a"/"an"/"your"): minting a NEW CLASS-LEVEL CONCEPT is inherently a general
1661
+ * claim about a class, the same "every"/bare distinction unknownSubjectFallback's
1662
+ * own docblock already draws (point 3) between a class generalization and a
1663
+ * claim about ONE specific entity. This is load-bearing, not cosmetic: a bare
1664
+ * "module is banana" (a KNOWN lexicon subject, an unrecognized bare object,
1665
+ * NO determiner at all) is a pinned regression — it must stay a plain honest
1666
+ * miss, never silently minted — and a WRAPPED "remember that X is <adjective>"
1667
+ * (also determiner-less at the subject) must keep falling through to
1668
+ * TEACH_PROPERTY_RE's own, more permissive arbitrary-adjective path
1669
+ * unimpeded. Requiring the determiner keeps this fallback's mint exactly as
1670
+ * narrow as the vocabulary-growth feature actually needs (every required
1671
+ * test case in this feature's own spec phrases the mint sentence with
1672
+ * "every"), without swallowing either of those pre-existing shapes.
1673
+ *
1674
+ * The critical safety guard (operator's own stated worry, mirrored from
1675
+ * unknownSubjectFallback's docblock): this must NEVER silently mint when
1676
+ * BOTH sides are ungrounded ("every zorp is a florp" — two brand-new,
1677
+ * never-seen terms with no relation to each other tmct actually knows) —
1678
+ * declines here (null), and teachLane's own final honest-miss text picks up
1679
+ * an appended grounding NUDGE for exactly this case (ungroundedPairHint,
1680
+ * above) — never a silent guess, never a silent hard-swallowed decline
1681
+ * either. Any OTHER decline (subject ungrounded + object grounded — not this
1682
+ * fallback's asymmetry; or both grounded — already known, nothing to mint;
1683
+ * or no genuine universal quantifier) falls through as a plain null,
1684
+ * letting the ordinary teachLane cascade (property teach, then the generic
1685
+ * honest-miss text) continue unaffected. */
1686
+ async function unknownObjectFallback(payload, { memoryDir, sessionId, lexicon }) {
1687
+ if (!memoryDir) return null;
1688
+ const m = String(payload).trim().match(UNKNOWN_SUBJECT_RE);
1689
+ if (!m) return null;
1690
+ const [, det, subjectRaw, objectRaw] = m;
1691
+ if (!/^(?:every|each|all)$/i.test((det || "").trim())) return null; // class-level mint needs a real universal quantifier
1692
+ const { loadLexicon } = await import("./grammar/lexicon.mjs");
1693
+ const lex = lexicon || loadLexicon();
1694
+ const subjectGrounded = await isGroundedTerm(subjectRaw, lex, memoryDir);
1695
+ if (!subjectGrounded) return null; // ungrounded subject isn't this fallback's asymmetry — never a guessed mint
1696
+ const objectGrounded = await isGroundedTerm(objectRaw, lex, memoryDir);
1697
+ if (objectGrounded) return null; // object already known — nothing to mint
1698
+ const quantifier = /^every$/i.test((det || "").trim()) ? "every" : "";
1699
+ return teachFact(memoryDir, sessionId, {
1700
+ subject: subjectRaw, predicate: SUBCLASS_PREDICATE, object: objectRaw, quantifier,
1701
+ });
1702
+ }
1703
+
1525
1704
  // ---- BUG 3 (2026-07-09, operator-authorized generalizing — "I don't know
1526
1705
  // where that ban came from, overturn it. build it."): general verb-to-
1527
1706
  // predicate teaching. "remember tony has a hat" / "remember margo eats ribs"
@@ -1922,6 +2101,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1922
2101
  // the exact narrowing rules (object must still be known, etc.).
1923
2102
  const fallback = await unknownSubjectFallback(payload, { memoryDir, sessionId, lexicon });
1924
2103
  if (fallback) return fallback;
2104
+ // MIRROR mint fallback (Feature A, 2026-07-09 operator-authorized vocabulary-
2105
+ // growth extension): the known-subject/unknown-object asymmetry — tried
2106
+ // right after the unknown-subject case declines, so a subject the STATIC
2107
+ // lexicon (or a prior taught fact) already grounds can mint a brand-new
2108
+ // object term. See unknownObjectFallback's own docblock for the exact
2109
+ // narrowing rules (the "both sides ungrounded" safety guard, etc.).
2110
+ const objectFallback = await unknownObjectFallback(payload, { memoryDir, sessionId, lexicon });
2111
+ if (objectFallback) return objectFallback;
1925
2112
  // PROPERTY teach — "remember/note that <X> is <adjective>": wrapper-REQUIRED
1926
2113
  // (a bare "X is deprecated" is never silently reified), and only after the
1927
2114
  // ACE grammar declined (unknown words / not the membership shape), so a
@@ -1980,9 +2167,14 @@ async function teachLane(query, { memoryDir, sessionId = "", lexicon = null }) {
1980
2167
  + "I can only teach facts using tmct's own code-vocabulary nouns (like module, class, function…), "
1981
2168
  + "not arbitrary new terms."
1982
2169
  : "";
2170
+ // Grounding NUDGE (operator refinement, 2026-07-09): APPENDED, never a
2171
+ // replacement, exactly like "did" above — see ungroundedPairHint's own
2172
+ // docblock for why this is scoped to the "both sides ungrounded, fits the
2173
+ // X is/are Y shape" case only.
2174
+ const groundingHint = await ungroundedPairHint(payload, lexicon, memoryDir);
1983
2175
  return {
1984
2176
  text: `I couldn't store that —${why} I remember facts in the shape "every X is a Y", where X and Y are `
1985
- + `words I know.${did} Type /memory to see what I already remember.`,
2177
+ + `words I know.${did}${groundingHint} Type /memory to see what I already remember.`,
1986
2178
  via: "teach-miss", miss: true,
1987
2179
  };
1988
2180
  }
@@ -3639,9 +3831,15 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
3639
3831
  // session): a taught general-verb fact ("margo eats ribs") answered back
3640
3832
  // directly. Yes/no form matches the taught triple EXACTLY (subject +
3641
3833
  // predicate + object, via the SAME factTermVariants/normFactTerm matching
3642
- // WHO_OWNS_RE just used above) — no match is an honest, closed-world "no",
3643
- // never a guess. Open form lists every stored fact row for {subject,
3644
- // predicate} regardless of object.
3834
+ // WHO_OWNS_RE just used above) — a hit is a confident "yes". INFBENCH 1.2.0
3835
+ // fix: a no-hit here used to synthesize a confident "no", but "no matching
3836
+ // triple found after one lookup" is NOT a proof of absence (this project's
3837
+ // OWA/honesty discipline — see PLAN_INFERENCE_TESTING.md) — it's
3838
+ // indistinguishable from "I simply don't know". So a no-hit now returns
3839
+ // null (declining), same as WHO_OWNS_RE's own no-hit above, falling through
3840
+ // to the ordinary honest-miss cascade instead of fabricating a "no". Open
3841
+ // form lists every stored fact row for {subject, predicate} regardless of
3842
+ // object.
3645
3843
  const genYN = q.match(GENERAL_VERB_YESNO_RE);
3646
3844
  if (genYN && !GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(q)) {
3647
3845
  const [, subjectRaw, verbRaw, objectRaw] = genYN;
@@ -3657,10 +3855,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
3657
3855
  .filter((f) => f.predicate === predicate && subjVariants.has(f.subject) && objVariants.has(f.object))
3658
3856
  .sort(byTrust)[0];
3659
3857
  if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true, generalVerbQuery: true };
3660
- return {
3661
- text: `no — no remembered fact says ${subject.toLowerCase()} ${predicatePhrase(predicate)} ${object}.`,
3662
- replace: true, generalVerbQuery: true,
3663
- };
3858
+ return null; // no remembered fact — the honest miss stands (never a guessed "no")
3664
3859
  }
3665
3860
  }
3666
3861
  }
@@ -41,6 +41,9 @@ export const UTTERANCE_CLASS = "Utterance";
41
41
  export const FACT_CLASS = "Fact";
42
42
  export const MEMORY_SESSION_CLASS = "Session";
43
43
  export const SOURCE_CLASS = "Source";
44
+ // PLAN_TAUGHT_RELATIONS.md Phase 3: a taught RULE (a composed/filtered/
45
+ // recursive relation-shape) — a sibling of Fact, never a taught concept itself.
46
+ export const RULE_CLASS = "Rule";
44
47
 
45
48
  export const SAID_IN_SESSION_PROP = "mgx:saidInSession";
46
49
  export const IN_REPLY_TO_PROP = "mgx:inReplyTo";
@@ -75,6 +78,13 @@ const MEMORY_VOCABULARY = [
75
78
  { prop: "rdf:object", note: "reified fact: the triple's object term" },
76
79
  { prop: "mgx:factProvenance", note: "LEGACY COMPAT SHIM: the ' | '-joined provenance tag string a fact came from; the source-of-truth is now the mgx:statedBy edges derived from it" },
77
80
  { prop: "mgx:factQuantifier", note: "OPTIONAL: the quantifier word a plural class-membership teach used ('every'/'some'/'a few'), for literal recall by 'how many Xs are Ys' — never real cardinality counting" },
81
+ { prop: "mgx:ruleName", note: "a taught Rule's own name (e.g. 'grandparent') — the query-dispatcher's lookup key, PLAN_TAUGHT_RELATIONS.md §2/§3" },
82
+ { prop: "mgx:ruleKind", note: "a taught Rule's SHAPE tag — the closed vocabulary compose2 | filter | recursive (structural, like 'Fact'/'Rule' themselves, never a domain word)" },
83
+ { prop: "mgx:ruleBase1", note: "compose2: the first hop's base relation name; filter: the base rule/relation being filtered (same 'base relation' role in both kinds, so the name is shared)" },
84
+ { prop: "mgx:ruleBase2", note: "compose2 only: the second hop's base relation name" },
85
+ { prop: "mgx:ruleFilterProperty", note: "filter only: the property literal candidates are filtered by (an mgx:hasProperty-shaped Fact lookup)" },
86
+ { prop: "mgx:ruleBaseCase", note: "recursive only: the base-case relation name (hop zero)" },
87
+ { prop: "mgx:ruleRecStep", note: "recursive only: the self-referential recursive-step relation name" },
78
88
  { prop: CREATED_AT_PROP, note: "when an individual was FIRST written, ISO-8601 (first-write-wins on upsert); the audit 'when', the recency input to trust, the novelty signal" },
79
89
  { prop: DERIVED_FROM_PROP, predicate: "derivedFrom", note: "umbrella: a Fact derived from a Source (or another Fact). ext ref prov:wasDerivedFrom (UNVERIFIED-pending-web-check)" },
80
90
  { prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
@@ -328,7 +338,7 @@ function upsertEdge(payload, { predicate, prop }, edge) {
328
338
  /** Recount `classes[]` from the individuals — every memory class stays counted
329
339
  * and sampled the way graph-build.mjs counts the code classes. */
330
340
  function recountClasses(payload) {
331
- const names = [MEMORY_SESSION_CLASS, UTTERANCE_CLASS, FACT_CLASS, SOURCE_CLASS];
341
+ const names = [MEMORY_SESSION_CLASS, UTTERANCE_CLASS, FACT_CLASS, SOURCE_CLASS, RULE_CLASS];
332
342
  payload.classes = payload.classes.filter((c) => !names.includes(c?.name));
333
343
  for (const name of names) {
334
344
  const of = payload.individuals.filter((i) => i?.class === name);
@@ -582,6 +592,112 @@ export async function appendFacts(dir, facts) {
582
592
  return { ids, appended: ids.length, skipped };
583
593
  }
584
594
 
595
+ // ---- Rules (PLAN_TAUGHT_RELATIONS.md Phase 3: storage foundation) -----------
596
+ // A taught RULE — a composed/filtered/recursive relation SHAPE, distinct from a
597
+ // plain Fact triple. Same convention as a Fact's subject/predicate/object: every
598
+ // slot is a plain string ATTRIBUTE, never an edge to a per-term individual.
599
+
600
+ export const RULE_KIND_COMPOSE2 = "compose2";
601
+ export const RULE_KIND_FILTER = "filter";
602
+ export const RULE_KIND_RECURSIVE = "recursive";
603
+ export const RULE_KINDS = Object.freeze([RULE_KIND_COMPOSE2, RULE_KIND_FILTER, RULE_KIND_RECURSIVE]);
604
+
605
+ export const RULE_NAME_PROP = "mgx:ruleName";
606
+ export const RULE_KIND_PROP = "mgx:ruleKind";
607
+
608
+ // Per-kind slot contract: JS slot key -> the mgx: attribute it's written under.
609
+ // filter's "base" slot deliberately reuses ruleBase1 (not a fresh "ruleBase") —
610
+ // §3's own query-dispatcher design chases a filter rule's candidate set via
611
+ // "ruleBase1's candidate set (step (a) or (b) again)", the exact same attribute
612
+ // name compose2's first hop already uses, since both play the identical "base
613
+ // relation this rule builds on" role. Order within each array is the (slot1,
614
+ // slot2) order the content-address hash below uses — fixed and load-bearing.
615
+ const RULE_SLOT_SPEC = {
616
+ [RULE_KIND_COMPOSE2]: [["base1", "mgx:ruleBase1"], ["base2", "mgx:ruleBase2"]],
617
+ [RULE_KIND_FILTER]: [["base", "mgx:ruleBase1"], ["property", "mgx:ruleFilterProperty"]],
618
+ [RULE_KIND_RECURSIVE]: [["baseCase", "mgx:ruleBaseCase"], ["recStep", "mgx:ruleRecStep"]],
619
+ };
620
+
621
+ // The rule-id contract, mirroring factIdFor's (:456) NUL-delimited discipline
622
+ // exactly: content-addressed over (kind, name, slot1, slot2), so re-teaching an
623
+ // IDENTICAL rule (same kind + name + slots) upserts, never duplicates; teaching
624
+ // a DIFFERENT rule under the SAME name (different slots) hashes to a distinct
625
+ // id — both individuals exist side by side, the same way two different Facts
626
+ // sharing a subject are two distinct Fact individuals, never a silent overwrite.
627
+ const ruleIdFor = (kind, name, slot1, slot2) => `rule:${fnv1aHex(`${kind}\0${name}\0${slot1}\0${slot2}`)}`;
628
+
629
+ /** Append one taught RULE (compose2 | filter | recursive) — a sibling of
630
+ * appendFact (:462) for the relation-composition shapes PLAN_TAUGHT_RELATIONS.md
631
+ * items 3/4/6 need, storing a `Rule` individual instead of a `Fact`. Same
632
+ * load→mutate→write discipline via mutateMemory, same content-addressed-id
633
+ * upsert convention as appendFact.
634
+ *
635
+ * { name, kind, slots, provenance = "", createdAt = "" }: `kind` is the ONE
636
+ * closed vocabulary this store needs to know — compose2 | filter | recursive,
637
+ * three STRUCTURAL tags describing the SHAPE of what was taught (never a
638
+ * domain word, the same way "Fact"/"Rule" describe the store's own shape, not
639
+ * what's stored in it). `slots` is the matching per-kind object (RULE_SLOT_SPEC
640
+ * above). `name` and every slot value are normFactTerm-normalized, exactly like
641
+ * a Fact's subject/object.
642
+ *
643
+ * Provenance/trust ride the EXACT SAME syncFactSources/recomputeFactTrust
644
+ * pipeline appendFact uses, unmodified — neither function ever checks
645
+ * `individual.class`, so a Rule carrying the same mgx:factProvenance compat
646
+ * attribute + CREATED_AT_PROP gets the same Source-derivation + trust score an
647
+ * ordinary Fact would. Returns { id }. */
648
+ export async function appendRule(dir, { name, kind, slots, provenance = "", createdAt = "" } = {}) {
649
+ const spec = RULE_SLOT_SPEC[kind];
650
+ if (!spec) throw new Error(`a rule kind must be one of ${RULE_KINDS.join(", ")}, got ${JSON.stringify(kind)}`);
651
+ const n = normFactTerm(name);
652
+ if (!n) throw new Error("a rule needs a name");
653
+ const slotValues = spec.map(([slotKey]) => normFactTerm(slots?.[slotKey]));
654
+ if (slotValues.some((v) => !v)) {
655
+ throw new Error(`a ${kind} rule needs ${spec.map(([slotKey]) => slotKey).join(" + ")}`);
656
+ }
657
+ const id = ruleIdFor(kind, n, slotValues[0], slotValues[1]);
658
+ const label = labelOf(`${n} = ${kind}(${slotValues.join(", ")})`);
659
+ await mutateMemory(dir, (payload) => {
660
+ const prior = payload.individuals.find((x) => x?.id === id);
661
+ const priorProv = prior?.attributes?.find((a) => a?.prop === "mgx:factProvenance")?.value || "";
662
+ // Same union-of-tags discipline as appendFact — the compat string stays
663
+ // byte-identical in spirit; the Source edges below are DERIVED from it.
664
+ const provs = [...new Set([...priorProv.split(" | "), normText(provenance)].filter(Boolean))];
665
+ const createdAtVal = firstWriteCreatedAt(prior, createdAt); // first-write-wins
666
+ upsertIndividual(payload, {
667
+ id, label, class: RULE_CLASS,
668
+ derived_from: [], mentions: [],
669
+ attributes: [
670
+ { prop: "rdf:type", key: "type", value: "owl:NamedIndividual" },
671
+ { prop: RULE_NAME_PROP, key: "ruleName", value: n },
672
+ { prop: RULE_KIND_PROP, key: "ruleKind", value: kind },
673
+ ...spec.map(([slotKey, prop], i) => ({ prop, key: slotKey, value: slotValues[i] })),
674
+ { prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
675
+ ...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
676
+ ],
677
+ });
678
+ // Same Source-derivation + trust-materialisation call appendFact makes —
679
+ // syncFactSources/recomputeFactTrust only ever touch fact.attributes/id/
680
+ // label, never fact.class, so a Rule individual rides it unmodified.
681
+ syncFactSources(payload, payload.individuals.find((x) => x?.id === id));
682
+ recountClasses(payload);
683
+ });
684
+ return { id };
685
+ }
686
+
687
+ /** Genericity lookup for the future query-dispatcher (PLAN_TAUGHT_RELATIONS.md
688
+ * §2's closing paragraph / §3 step (b)): "what kind of thing is name X" — scan
689
+ * for the Rule individual whose mgx:ruleName matches, the SAME lookup serving
690
+ * every taught rule name uniformly (no per-rule-name branch). This phase only
691
+ * proves the stored shape supports the lookup correctly; Phase 4/5/6 build the
692
+ * actual kind-dispatch (compose2/filter/recursive branching) on top of this.
693
+ * Returns the raw individual, or undefined if no Rule has that name. */
694
+ export function findRuleByName(memory, name) {
695
+ const n = normFactTerm(name);
696
+ return (memory?.individuals || []).find(
697
+ (i) => i?.class === RULE_CLASS && (i.attributes || []).find((a) => a?.prop === RULE_NAME_PROP)?.value === n,
698
+ );
699
+ }
700
+
585
701
  // ---- Chat-facing seams (W4 fact lookup + contradiction) ---------------------
586
702
  // The W4 fact-lookup THREADING lives in chat.mjs (NOT here); these pure readers
587
703
  // are the seam it calls so the answer layer ranks candidates by relevance ×
@@ -0,0 +1,109 @@
1
+ // planning.mjs — a domain-agnostic bounded state-space search primitive
2
+ // (PLAN_HANOI.md's Phase 2 kernel, landed ahead of the phased plan as a
3
+ // standalone proof that the mechanism works, per the operator's own framing:
4
+ // "generalizing findIsaChain from 'walk pre-loaded class edges' to 'walk
5
+ // on-demand successor states' is a moderate, in-house-idiom-consistent
6
+ // extension, not a foreign paradigm").
7
+ //
8
+ // `src/syllogise.mjs`'s `findIsaChain` is, in shape, already a bounded rooted
9
+ // BFS path search: it walks a FIXED, pre-loaded edge list (`typeEdges`/
10
+ // `subClassEdges`) from a start node to a target set, frontier-expansion
11
+ // style, checking the frontier for a hit BEFORE extending it one hop further,
12
+ // stopping the instant a target is reached or the hop budget is exhausted.
13
+ //
14
+ // Real planning (Hanoi, or anything with actions) needs the same shape over a
15
+ // state space where successors are NOT pre-loaded — they are generated ON
16
+ // DEMAND by applying an action to the CURRENT state. `findActionPath` below
17
+ // is that generalization: same frontier/seen-set/check-then-extend/shortest-
18
+ // path discipline as `findIsaChain`, but the "edges" come from calling the
19
+ // caller-supplied `applyActions(state)` fresh at every expansion, instead of
20
+ // looking them up in a fixed array.
21
+ //
22
+ // Deliberately NOT sharing code with `findIsaChain` itself: that function's
23
+ // edge lists are pre-built ONCE into a `Map` before the search loop even
24
+ // starts (`subSucc`, `syllogise.mjs:291-296`) — a real, load-bearing
25
+ // optimization for its domain (static edges, looked up many times) that does
26
+ // not apply here (successors are computed fresh, never looked up twice for
27
+ // the same state). Extracting a "shared" BFS core would either lose that
28
+ // optimization or force `findActionPath` to fake a static edge list, so this
29
+ // lands as an independent sibling, following the same DISCIPLINE, not the
30
+ // same code path. `findIsaChain` itself is untouched by this file.
31
+ //
32
+ // Pure, no I/O, deterministic given a deterministic `applyActions`.
33
+
34
+ /** Default state-identity key: plain values compare by `String()`, plain
35
+ * objects by a stable-ish `JSON.stringify` (good enough for a toy/plain-
36
+ * object state; a caller with a richer state shape should pass its own
37
+ * `stateKey` that canonicalizes the fields that actually matter). */
38
+ function defaultStateKey(state) {
39
+ if (state && typeof state === "object") return JSON.stringify(state);
40
+ return String(state);
41
+ }
42
+
43
+ /**
44
+ * Bounded, cycle-safe, shortest-path-first breadth-first search over a state
45
+ * space whose successors are generated ON DEMAND, not pre-loaded.
46
+ *
47
+ * - `startState` — any value; identity for cycle-detection is derived via
48
+ * `stateKey` (default: `String()`/`JSON.stringify()`).
49
+ * - `isGoal(state) -> boolean` — goal predicate, checked BEFORE a state is
50
+ * expanded (never after — see the hop-counting discipline below).
51
+ * - `applyActions(state) -> Array<{ action, nextState }>` — the caller's
52
+ * domain logic: given the CURRENT state, the legal (action, resulting-
53
+ * state) pairs reachable in exactly one step. Called fresh every time a
54
+ * state is expanded; nothing is precomputed or cached across calls.
55
+ * - `opts.maxDepth` (default 50) — hop budget, mirrors `findIsaChain`'s
56
+ * `maxHops`: the frontier is checked for the goal AT every depth up to
57
+ * and including `maxDepth`, but never extended past it (check-then-
58
+ * extend — `findIsaChain`'s own comment on this exact off-by-one:
59
+ * "the frontier is checked AT every length up to and including maxHops,
60
+ * never one hop beyond it").
61
+ * - `opts.stateKey(state) -> string` — override the default identity key
62
+ * when `startState`/successor states are richer than a plain
63
+ * string/number/JSON-able object.
64
+ *
65
+ * Returns `{ actions: [...], states: [startState, ...,goalState] }` on
66
+ * success (the full action sequence AND the resulting state at each step, so
67
+ * a caller can actually execute the plan, not just know one exists), or
68
+ * `null` when no path reaches a goal state within `maxDepth` — an honest
69
+ * miss, never a guessed/truncated path.
70
+ *
71
+ * Cycle-safe via a `seen` state-key set (this function's direct precedent:
72
+ * `findIsaChain`'s own `seen` set, `syllogise.mjs:311`) — a state is only
73
+ * ever expanded once, the first (shortest) path to reach it, so a domain
74
+ * with cycles (two states that can reach each other) still terminates and
75
+ * still returns the correct shortest path, never loops.
76
+ */
77
+ export function findActionPath(startState, isGoal, applyActions, { maxDepth = 50, stateKey = defaultStateKey } = {}) {
78
+ if (isGoal(startState)) return { actions: [], states: [startState] };
79
+
80
+ let frontier = [];
81
+ for (const { action, nextState } of applyActions(startState) || []) {
82
+ frontier.push({ state: nextState, actions: [action], states: [startState, nextState] });
83
+ }
84
+
85
+ // depth counts the LENGTH of the paths currently in `frontier` (1 at the
86
+ // first check) — exactly `findIsaChain`'s own "hop counts the LENGTH of the
87
+ // paths currently in frontier" discipline. Check-then-extend, and never
88
+ // extend past maxDepth: the frontier is checked at every depth up to and
89
+ // including maxDepth, never one hop beyond it (the off-by-one findIsaChain
90
+ // itself once had and fixed — not reintroduced here).
91
+ const seen = new Set([stateKey(startState)]);
92
+ for (let depth = 1; depth <= maxDepth && frontier.length; depth += 1) {
93
+ for (const entry of frontier) if (isGoal(entry.state)) return { actions: entry.actions, states: entry.states };
94
+ if (depth === maxDepth) break; // budget exhausted — do not extend further
95
+ const next = [];
96
+ for (const entry of frontier) {
97
+ const key = stateKey(entry.state);
98
+ if (seen.has(key)) continue;
99
+ seen.add(key);
100
+ for (const { action, nextState } of applyActions(entry.state) || []) {
101
+ const nk = stateKey(nextState);
102
+ if (seen.has(nk)) continue;
103
+ next.push({ state: nextState, actions: [...entry.actions, action], states: [...entry.states, nextState] });
104
+ }
105
+ }
106
+ frontier = next;
107
+ }
108
+ return null;
109
+ }