@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 +9 -0
- package/ROADMAP.md +59 -6
- package/package.json +1 -1
- package/src/chat.mjs +10 -7
- package/src/memory/core.mjs +117 -1
- package/src/planning.mjs +109 -0
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
|
-
|
|
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
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
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.
|
|
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) —
|
|
3835
|
-
//
|
|
3836
|
-
//
|
|
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
|
}
|
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,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
|
+
}
|