@polycode-projects/the-mechanical-code-talker 1.2.0 → 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
@@ -35,6 +35,40 @@ Test count across the session's later stretch:
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
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.
38
72
 
39
73
  ### Shipped this session
40
74
 
@@ -63,6 +97,17 @@ Test count across the session's later stretch:
63
97
  hand-rolled copy of `WALL_MISS_RE` with the real export; and extracted a shared session-driver
64
98
  helper (`test/helpers/session.mjs`), replacing 11 near-duplicate `drive()`/`driveSession()`
65
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`.
66
111
  - **Compound-name resolution**, from the operator's own worked example: "the payment system" now
67
112
  finds `PaymentSystem`, `payment-system`, a compound path like
68
113
  `westfield-payment-system/src/MyCode.cs`, and an interface-style name like
@@ -985,14 +1030,22 @@ minimal benchmark domains before anything domain-general is attempted:
985
1030
  slot (`game`) threaded through `createSession`/`runTurn` exactly the way `focus` already is,
986
1031
  kept deliberately separate from the `pending` pagination field since a game must survive an
987
1032
  aside mid-play, unlike a listing remainder.
988
-
989
- 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
990
1042
  anywhere in tmct: something that computes a SUCCESSOR STATE (apply a chosen action, produce the
991
1043
  next graph/belief to reason over) — every existing traversal (`ancestorsOf`, `computeFind`,
992
- `findIsaChain` itself) is read-only. That primitive, plus a still-open recognition question (how
993
- tmct notices "the user wants goal-directed action" at all, and whether multi-step execution needs
994
- confirmation before running) is the real next-session scope a dedicated design/implementation
995
- 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.
996
1049
 
997
1050
  ### The design horizon
998
1051
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.2.0",
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
@@ -3831,9 +3831,15 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
3831
3831
  // session): a taught general-verb fact ("margo eats ribs") answered back
3832
3832
  // directly. Yes/no form matches the taught triple EXACTLY (subject +
3833
3833
  // predicate + object, via the SAME factTermVariants/normFactTerm matching
3834
- // WHO_OWNS_RE just used above) — no match is an honest, closed-world "no",
3835
- // never a guess. Open form lists every stored fact row for {subject,
3836
- // 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.
3837
3843
  const genYN = q.match(GENERAL_VERB_YESNO_RE);
3838
3844
  if (genYN && !GENERAL_VERB_ANYWHERE_EXCLUDE_RE.test(q)) {
3839
3845
  const [, subjectRaw, verbRaw, objectRaw] = genYN;
@@ -3849,10 +3855,7 @@ async function factReadBack(memoryDir, query, envelope, miss, graph = null, focu
3849
3855
  .filter((f) => f.predicate === predicate && subjVariants.has(f.subject) && objVariants.has(f.object))
3850
3856
  .sort(byTrust)[0];
3851
3857
  if (hit) return { text: `yes — ${renderFactLine(hit)}`, replace: true, generalVerbQuery: true };
3852
- return {
3853
- text: `no — no remembered fact says ${subject.toLowerCase()} ${predicatePhrase(predicate)} ${object}.`,
3854
- replace: true, generalVerbQuery: true,
3855
- };
3858
+ return null; // no remembered fact — the honest miss stands (never a guessed "no")
3856
3859
  }
3857
3860
  }
3858
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
+ }