@polycode-projects/the-mechanical-code-talker 1.2.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/README.md +9 -0
- package/ROADMAP.md +145 -6
- package/package.json +1 -1
- package/src/chat.mjs +845 -11
- package/src/memory/core.mjs +117 -1
- package/src/planning.mjs +227 -0
package/src/memory/core.mjs
CHANGED
|
@@ -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 ×
|
package/src/planning.mjs
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
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
|
+
/** 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
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Bounded, cycle-safe, shortest-path-first breadth-first search over a state
|
|
62
|
+
* space whose successors are generated ON DEMAND, not pre-loaded.
|
|
63
|
+
*
|
|
64
|
+
* - `startState` — any value; identity for cycle-detection is derived via
|
|
65
|
+
* `stateKey` (default: `String()`/`JSON.stringify()`).
|
|
66
|
+
* - `isGoal(state) -> boolean` — goal predicate, checked BEFORE a state is
|
|
67
|
+
* expanded (never after — see the hop-counting discipline below).
|
|
68
|
+
* - `applyActions(state) -> Array<{ action, nextState }>` — the caller's
|
|
69
|
+
* domain logic: given the CURRENT state, the legal (action, resulting-
|
|
70
|
+
* state) pairs reachable in exactly one step. Called fresh every time a
|
|
71
|
+
* state is expanded; nothing is precomputed or cached across calls.
|
|
72
|
+
* - `opts.maxDepth` (default 50) — hop budget, mirrors `findIsaChain`'s
|
|
73
|
+
* `maxHops`: the frontier is checked for the goal AT every depth up to
|
|
74
|
+
* and including `maxDepth`, but never extended past it (check-then-
|
|
75
|
+
* extend — `findIsaChain`'s own comment on this exact off-by-one:
|
|
76
|
+
* "the frontier is checked AT every length up to and including maxHops,
|
|
77
|
+
* never one hop beyond it").
|
|
78
|
+
* - `opts.stateKey(state) -> string` — override the default identity key
|
|
79
|
+
* when `startState`/successor states are richer than a plain
|
|
80
|
+
* string/number/JSON-able object.
|
|
81
|
+
*
|
|
82
|
+
* Returns `{ actions: [...], states: [startState, ...,goalState] }` on
|
|
83
|
+
* success (the full action sequence AND the resulting state at each step, so
|
|
84
|
+
* a caller can actually execute the plan, not just know one exists), or
|
|
85
|
+
* `null` when no path reaches a goal state within `maxDepth` — an honest
|
|
86
|
+
* miss, never a guessed/truncated path.
|
|
87
|
+
*
|
|
88
|
+
* Cycle-safe via a `seen` state-key set (this function's direct precedent:
|
|
89
|
+
* `findIsaChain`'s own `seen` set, `syllogise.mjs:311`) — a state is only
|
|
90
|
+
* ever expanded once, the first (shortest) path to reach it, so a domain
|
|
91
|
+
* with cycles (two states that can reach each other) still terminates and
|
|
92
|
+
* still returns the correct shortest path, never loops.
|
|
93
|
+
*/
|
|
94
|
+
export function findActionPath(startState, isGoal, applyActions, { maxDepth = 50, stateKey = defaultStateKey } = {}) {
|
|
95
|
+
if (isGoal(startState)) return { actions: [], states: [startState] };
|
|
96
|
+
|
|
97
|
+
let frontier = seedFrontier(startState, applyActions);
|
|
98
|
+
|
|
99
|
+
// depth counts the LENGTH of the paths currently in `frontier` (1 at the
|
|
100
|
+
// first check) — exactly `findIsaChain`'s own "hop counts the LENGTH of the
|
|
101
|
+
// paths currently in frontier" discipline. Check-then-extend, and never
|
|
102
|
+
// extend past maxDepth: the frontier is checked at every depth up to and
|
|
103
|
+
// including maxDepth, never one hop beyond it (the off-by-one findIsaChain
|
|
104
|
+
// itself once had and fixed — not reintroduced here).
|
|
105
|
+
const seen = new Set([stateKey(startState)]);
|
|
106
|
+
for (let depth = 1; depth <= maxDepth && frontier.length; depth += 1) {
|
|
107
|
+
for (const entry of frontier) if (isGoal(entry.state)) return { actions: entry.actions, states: entry.states };
|
|
108
|
+
if (depth === maxDepth) break; // budget exhausted — do not extend further
|
|
109
|
+
const next = [];
|
|
110
|
+
for (const entry of frontier) {
|
|
111
|
+
const key = stateKey(entry.state);
|
|
112
|
+
if (seen.has(key)) continue;
|
|
113
|
+
seen.add(key);
|
|
114
|
+
for (const { action, nextState } of applyActions(entry.state) || []) {
|
|
115
|
+
const nk = stateKey(nextState);
|
|
116
|
+
if (seen.has(nk)) continue;
|
|
117
|
+
next.push({ state: nextState, actions: [...entry.actions, action], states: [...entry.states, nextState] });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
frontier = next;
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
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
|
+
}
|