@polycode-projects/the-mechanical-code-talker 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ROADMAP.md +120 -11
- package/bin/tmct.mjs +45 -0
- package/package.json +3 -1
- package/src/ask-vocab.mjs +1 -1
- package/src/ask.mjs +123 -3
- package/src/chat.mjs +79 -7
- package/src/concept.mjs +5 -0
- package/src/conformance.mjs +1 -1
- package/src/corpus/templates.mjs +1 -1
- package/src/finish.mjs +1 -1
- package/src/hash.mjs +1 -1
- package/src/interpret/normalize.mjs +65 -1
- package/src/interpret/pipeline.mjs +18 -3
- package/src/interpret/strategies/ace.mjs +49 -0
- package/src/interpret/strategies/keywords.mjs +2 -2
- package/src/providers/bootstrap.mjs +1 -1
- package/src/providers/fixture.mjs +1 -1
- package/src/providers/graph-service.mjs +1 -1
- package/src/repository-interface.mjs +1 -1
- package/src/router/goal-reasoner.mjs +266 -0
- package/src/router/guardrail.mjs +120 -0
- package/src/router/planner.mjs +168 -0
- package/src/router/registry.mjs +271 -0
- package/src/router/resolver.mjs +331 -0
- package/src/server-http.mjs +296 -0
- package/src/syllogise.mjs +0 -0
- package/src/tui/app.mjs +63 -14
package/src/concept.mjs
CHANGED
|
@@ -213,6 +213,11 @@ export function composeConcept(graph, term, { definition = null, factRows = [] }
|
|
|
213
213
|
instances: graphInstances.slice(0, MAX_EXAMPLES).map((i) => ({
|
|
214
214
|
id: i.id, label: i.label, type: i.class,
|
|
215
215
|
})),
|
|
216
|
+
// EVERY instance id (uncapped), for discourse-count anaphora ("count them" /
|
|
217
|
+
// "how many of those") over a truncated listing: `instances` is capped at
|
|
218
|
+
// MAX_EXAMPLES and the rest lives in `remainder` as prose labels, invisible to
|
|
219
|
+
// the counter — so a follow-up count would undercount without the full id set.
|
|
220
|
+
allInstanceIds: graphInstances.map((i) => i.id),
|
|
216
221
|
// the un-shown instance labels + their plural noun, for the shell's "more"
|
|
217
222
|
// pagination — empty when nothing was truncated.
|
|
218
223
|
remainder: remainderLabels,
|
package/src/conformance.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// conformance.mjs — the Repository-Interface CONTRACT TEST SUITE as a reusable kit.
|
|
2
2
|
//
|
|
3
|
-
// PLAN_REPOSITORY_INTERFACE.md deliverable 3: an implementation is CONFORMANT iff it
|
|
3
|
+
// archive/PLAN_REPOSITORY_INTERFACE.md deliverable 3: an implementation is CONFORMANT iff it
|
|
4
4
|
// passes `runConformance(name, makeProvider)`. tmct's own fixture + bootstrap providers
|
|
5
5
|
// pass it in `npm test`; an EXTERNAL producer (seonix) imports this kit from the
|
|
6
6
|
// published package and runs the SAME suite against its native provider to claim
|
package/src/corpus/templates.mjs
CHANGED
|
@@ -19,7 +19,7 @@ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
|
19
19
|
export const TEMPLATES_FILE = join(PKG_ROOT, "data", "templates", "responses.jsonl");
|
|
20
20
|
export const PHRASEBOOK_FILE = join(PKG_ROOT, "data", "phrasebook", "software-phrases.txt");
|
|
21
21
|
|
|
22
|
-
// Registers (Phase 6, PLAN_FORMULAIC_COMPETENCE.md): `terse|friendly` are the
|
|
22
|
+
// Registers (Phase 6, archive/PLAN_FORMULAIC_COMPETENCE.md): `terse|friendly` are the
|
|
23
23
|
// conversational bands; `technical` is the C1 / technical-paper band whose
|
|
24
24
|
// templates render item-5 mechanical conclusions (count / comparison /
|
|
25
25
|
// superlative + the provenance we already compute) as advanced prose. A
|
package/src/finish.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// finish.mjs — Phase 7 response finishing: the segmentation IR seam.
|
|
2
|
-
// (PLAN_RESPONSE_FINISHING.md, "The segmentation IR (lever 1)".)
|
|
2
|
+
// (archive/PLAN_RESPONSE_FINISHING.md, "The segmentation IR (lever 1)".)
|
|
3
3
|
//
|
|
4
4
|
// The governing principle is fact-invariance BY CONSTRUCTION. An answer is a
|
|
5
5
|
// list of typed spans, [{ type, text }, …], carried alongside the flat string
|
package/src/hash.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// hash.mjs — the single home for tmct's content-address hash.
|
|
2
2
|
//
|
|
3
|
-
// FNV-1a 32-bit is deliberately home-grown (see PLAN_DEPENDENCY_STRATEGY.md): it
|
|
3
|
+
// FNV-1a 32-bit is deliberately home-grown (see archive/PLAN_DEPENDENCY_STRATEGY.md): it
|
|
4
4
|
// must be synchronous, browser-safe, dependency-free, and — critically —
|
|
5
5
|
// CROSS-VERSION STABLE, because fact ids are content-addressed by it and a fact's
|
|
6
6
|
// id is its identity across the whole memory graph. Every library candidate fails
|
|
@@ -118,6 +118,70 @@ export const PHRASING_FRAMES = Object.freeze([
|
|
|
118
118
|
{ re: /^what\s+(?:defined|declared)\s+(?:the\s+)?(?:function\s+|method\s+|class\s+|module\s+|variable\s+|constant\s+)?(.+?)\??$/i, to: (m) => `where is ${m[1]} defined` },
|
|
119
119
|
// "where's X defined" (the "where's" contraction is not in the contraction table)
|
|
120
120
|
{ re: /^where'?s\s+(?:the\s+)?(.+?)\s+(defined|declared|located|implemented)\??$/i, to: (m) => `where is ${m[1]} ${m[2]}` },
|
|
121
|
+
|
|
122
|
+
// PREDICATIVE QUALIFIER → the ATTRIBUTIVE form the grammar already answers. The
|
|
123
|
+
// adjective-qualifier post-filters (ask-vocab.mjs QUALIFIERS: tested/untested,
|
|
124
|
+
// public/private, exported, static/abstract/constant, …) parse in the ATTRIBUTIVE
|
|
125
|
+
// slot — "untested modules", "public methods" — but a developer just as naturally
|
|
126
|
+
// asks the PREDICATIVE "which modules are untested" / "what functions are tested",
|
|
127
|
+
// which hit the grammar wall (and, worse, the wall's own hint SUGGESTED "which
|
|
128
|
+
// functions are tested" — a shape it could not then answer). Rewriting the
|
|
129
|
+
// predicative "<which|what> <kind> are <QUALIFIER>" to "<QUALIFIER> <kind>" routes
|
|
130
|
+
// it onto the working attributive filter. Closed to the known qualifier adjectives
|
|
131
|
+
// (not a general "… are X" catch), and the QUALIFIER must sit immediately after
|
|
132
|
+
// are/is, so "which modules are NOT tested" never matches here — that keeps its own
|
|
133
|
+
// set-complement handler (matchNegationSet, downstream in ask.mjs's parseNegation).
|
|
134
|
+
{
|
|
135
|
+
re: /^(?:which|what)\s+(?:the\s+|all\s+)?([a-z][a-z-]*?)\s+(?:are|is)\s+(public|private|protected|static|abstract|constant|exported|re-?exported|tested|covered|untested|uncovered)\??$/i,
|
|
136
|
+
to: (m) => `${m[2].toLowerCase()} ${m[1].toLowerCase()}`,
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
// BARE COVERAGE SURVEY (no entity kind) → the attributive "<qualifier> modules"
|
|
140
|
+
// the grammar already answers. Once "what is a test" opens the topic, a developer
|
|
141
|
+
// asks the survey the plainest way — "what is untested", "what's not tested",
|
|
142
|
+
// "what isn't covered", "what is covered" — with NO entity noun at all, so the
|
|
143
|
+
// predicative-qualifier frame above (which needs a KIND between what/which and
|
|
144
|
+
// are/is) can't catch it, and it fell through to a soft wall ("no module matching
|
|
145
|
+
// 'not'…" / the "I answer questions…" orientation). Default the surveyed kind to
|
|
146
|
+
// modules (the same set "which modules are not tested" / "untested modules" return)
|
|
147
|
+
// and fold the negation into the qualifier (not tested → untested, not covered →
|
|
148
|
+
// uncovered). Anchored with no object, so "what tests cover X" / "what is a test"
|
|
149
|
+
// never match here.
|
|
150
|
+
{
|
|
151
|
+
re: /^what\s+(?:is|are)\s+(not\s+)?(tested|untested|covered|uncovered)\??$/i,
|
|
152
|
+
to: (m) => {
|
|
153
|
+
const q = m[2].toLowerCase();
|
|
154
|
+
const flipped = m[1] ? (q === "tested" ? "untested" : q === "covered" ? "uncovered" : q) : q;
|
|
155
|
+
return `${flipped} modules`;
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// CO-CHANGE → the "co-changes with" canonical the RELATIONS table answers. The
|
|
160
|
+
// cochange verb synonyms (ask-vocab.mjs) include "co-changes with" / "moves
|
|
161
|
+
// together with" / "tends to change together with", but NOT the plainest form a
|
|
162
|
+
// developer types — the one the README itself prints and the relation renders as:
|
|
163
|
+
// "what does X change together with" / "what changes together with X". Both hit a
|
|
164
|
+
// dead-end ("couldn't resolve one of the terms" / the grammar wall); rewriting them
|
|
165
|
+
// onto "what co-changes with X" routes them to the working change-coupling query.
|
|
166
|
+
{ re: /^what\s+does\s+(.+?)\s+changes?\s+together\s+with\??$/i, to: (m) => `what co-changes with ${m[1]}` },
|
|
167
|
+
{ re: /^what\s+changes?\s+together\s+with\s+(.+?)\??$/i, to: (m) => `what co-changes with ${m[1]}` },
|
|
168
|
+
|
|
169
|
+
// AUTHORSHIP → the "who touched X" churn query. "who touched X" now names the
|
|
170
|
+
// commit author beside the sha (the 0.8.1 commit-ref quick-win), which invites the
|
|
171
|
+
// synonyms a developer reaches for next — "who wrote X", "who authored X", "who is
|
|
172
|
+
// the author of X" — and every one of them hit the grammar wall. tmct has no
|
|
173
|
+
// separate authorship edge; "touched" IS the authorship signal (the churn commits
|
|
174
|
+
// carry the author), so these are true synonyms of "who touched X", not a new
|
|
175
|
+
// capability. Anaphora rides through untouched ("who wrote it" → "who touched it").
|
|
176
|
+
{ re: /^who\s+(?:wrote|authored)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
|
|
177
|
+
{ re: /^who\s+is\s+the\s+authors?\s+of\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
|
|
178
|
+
|
|
179
|
+
// NEEDS-TESTS → the untested-module survey. "what needs tests" / "what needs
|
|
180
|
+
// testing" is the plainest way to ask which modules are uncovered, and it hit the
|
|
181
|
+
// grammar wall ("no module matching 'needs'…"). Route it onto the same attributive
|
|
182
|
+
// survey the bare "what is untested" frame lands on. Closed to the tests/coverage
|
|
183
|
+
// object, so it can't swallow a general "what needs X".
|
|
184
|
+
{ re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" },
|
|
121
185
|
]);
|
|
122
186
|
|
|
123
187
|
/** Apply the phrasing frames (members-of-class + where-defined) — first match wins
|
|
@@ -132,7 +196,7 @@ export function applyPhrasingFrames(text) {
|
|
|
132
196
|
return text;
|
|
133
197
|
}
|
|
134
198
|
|
|
135
|
-
// ---- §B1 negation — the SET-COMPLEMENT frame (Cycle 5, PLAN_CYCLE_4.md). Recognizes
|
|
199
|
+
// ---- §B1 negation — the SET-COMPLEMENT frame (Cycle 5, archive/PLAN_CYCLE_4.md). Recognizes
|
|
136
200
|
// a BARE set-negation query — "which X do not <verb> Y", "X that don't <verb> Y",
|
|
137
201
|
// "modules not importing Y", "which X are not <qualifier>" — and returns a descriptor
|
|
138
202
|
// {entWord, predicate} that ask.mjs's compositional grammar turns into a bounded
|
|
@@ -27,6 +27,13 @@ import { normalizeQuery, applyNegationFrames, applyPhrasingFrames } from "./norm
|
|
|
27
27
|
import { grammarStrategy } from "./strategies/grammar.mjs";
|
|
28
28
|
import { keywordSpotStrategy } from "./strategies/keywords.mjs";
|
|
29
29
|
import { noiseStripStrategy } from "./strategies/noise-strip.mjs";
|
|
30
|
+
// Optional Node-flavored ACE strategy — same viewer-bundle boundary as the
|
|
31
|
+
// ask-nlp adapter below: the ACE grammar reaches grammar/ace.mjs -> lexicon.mjs,
|
|
32
|
+
// which reads its committed JSON via Node fs, so an inlining viewer bundle strips
|
|
33
|
+
// this import; the `typeof` guard where STRATEGIES is built then degrades to an
|
|
34
|
+
// ace-less registry instead of throwing over an undeclared identifier. (ACE is
|
|
35
|
+
// async-only anyway, so the sync parseQuery path the viewer uses never ran it.)
|
|
36
|
+
import { aceStrategy } from "./strategies/ace.mjs";
|
|
30
37
|
import { mergeStrategyResults } from "./merge.mjs";
|
|
31
38
|
// Optional Node-only wink adapter — same viewer-bundle boundary as ask.mjs: an
|
|
32
39
|
// inlining bundle strips this import and the `typeof` read below degrades to
|
|
@@ -38,9 +45,17 @@ import { nlpAdapter } from "../ask-nlp.mjs";
|
|
|
38
45
|
* byte-identical to the original two-way agree/disagree behavior); noise-strip
|
|
39
46
|
* is the item-10 tolerant fallback (its own class; it only fires when the
|
|
40
47
|
* anchored grammar missed the text as-given, so it can never displace an
|
|
41
|
-
* existing template parse). interpret/strategies/ace.mjs (Phase 2)
|
|
42
|
-
*
|
|
43
|
-
|
|
48
|
+
* existing template parse). interpret/strategies/ace.mjs (Phase 2 / Stage 2) is
|
|
49
|
+
* the ACE-OWL controlled-fragment grammar, registered here as an ADDITIVE, own-
|
|
50
|
+
* class ("ace-fact") strategy. It is ASYNC on purpose: runStrategiesSync (the
|
|
51
|
+
* parseQuery / CHATBENCH-facing path) SKIPS Promise-returning strategies, so ACE
|
|
52
|
+
* adds declarative-fragment reach to interpret() while leaving the sync spine
|
|
53
|
+
* byte-stable (see strategies/ace.mjs for the full rationale). The `typeof` guard
|
|
54
|
+
* mirrors the nlpAdapter degradation: a stripped ACE import (viewer bundle) leaves
|
|
55
|
+
* the identifier undeclared, so the registry is ace-less there instead of a crash. */
|
|
56
|
+
// eslint-disable-next-line no-undef
|
|
57
|
+
const OPTIONAL_STRATEGIES = typeof aceStrategy !== "undefined" ? [aceStrategy] : [];
|
|
58
|
+
export const STRATEGIES = [grammarStrategy, keywordSpotStrategy, noiseStripStrategy, ...OPTIONAL_STRATEGIES];
|
|
44
59
|
|
|
45
60
|
/** The documented normalization pre-pass: whitespace-collapse + the §3.5
|
|
46
61
|
* normalization pipeline + the closed rhetorical-frame rewrites, applied ONCE
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// interpret/strategies/ace.mjs — the ACE-OWL controlled-fragment grammar wired
|
|
2
|
+
// into the interpretation pipeline as an ADDITIVE strategy (Stage 2, "ACE reach").
|
|
3
|
+
//
|
|
4
|
+
// The ACE engine (src/grammar/ace.mjs) has existed since Phase 2, but its pipeline
|
|
5
|
+
// ADAPTER was the "real and empty" seam the pipeline header names (interpret/
|
|
6
|
+
// pipeline.mjs). This file fills it. The contract is strictly ADD-ONLY:
|
|
7
|
+
//
|
|
8
|
+
// · Its own class, "ace-fact" — DISJOINT from the graph-query strategies, so a
|
|
9
|
+
// clean ACE parse is a distinct-class ALTERNATE ("if you mean X then …"), never
|
|
10
|
+
// a same-class competitor that could displace a graph-query winner.
|
|
11
|
+
// · It emits a candidate ONLY on a CLEAN parse (parseAce returns triples). A
|
|
12
|
+
// structural-fit-with-residue (empty triples) or a total miss returns null, so
|
|
13
|
+
// a query sentence that merely LOOKS relation-shaped ("which modules import X",
|
|
14
|
+
// whose ACE residue is the "which") contributes nothing — fitting the grammar
|
|
15
|
+
// is a strong signal; missing it is a FEATURE and the tolerant strategies win.
|
|
16
|
+
//
|
|
17
|
+
// WHY ASYNC — the byte-stability guarantee. ask.mjs's parseQuery (the CHATBENCH
|
|
18
|
+
// chat-facing path) runs strategies through runStrategiesSync, which — by the
|
|
19
|
+
// pipeline's documented contract — SKIPS any Promise-returning strategy ("an async
|
|
20
|
+
// strategy can only participate via interpret()"). Registering ACE async therefore
|
|
21
|
+
// makes the sync parseQuery path PROVABLY untouched (CHATBENCH neutral, byte-for-
|
|
22
|
+
// byte) while interpret() — the async pipeline — gains the declarative-fragment
|
|
23
|
+
// reach. The work parseAce does is synchronous; the async wrapper is deliberate,
|
|
24
|
+
// the mechanical seam that keeps the chat spine frozen. (grammar/ace.mjs itself is
|
|
25
|
+
// imported UNCHANGED — no chat-facing edit.)
|
|
26
|
+
|
|
27
|
+
import { parseAce } from "../../grammar/ace.mjs";
|
|
28
|
+
|
|
29
|
+
/** Adapter: a clean ACE parse -> one candidate in its own class; anything else
|
|
30
|
+
* (residue-only structural fit, or a hard miss) -> null. `via:"exact"` — a
|
|
31
|
+
* controlled-grammar fit is exact evidence, never an approximate rewrite. */
|
|
32
|
+
export function runAce(text) {
|
|
33
|
+
let parsed = null;
|
|
34
|
+
try { parsed = parseAce(text); } catch { return null; }
|
|
35
|
+
if (!parsed || !Array.isArray(parsed.triples) || parsed.triples.length === 0) return null;
|
|
36
|
+
return { strategyId: "ace", class: "ace-fact", candidates: [{ parsed, confidence: 0.85, via: "exact", note: `ACE ${parsed.pattern}` }] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Pipeline registration (interpret/pipeline.mjs). ASYNC on purpose (see file
|
|
40
|
+
* header): it participates in interpret() but is SKIPPED by runStrategiesSync,
|
|
41
|
+
* so parseQuery — and the CHATBENCH spine it feeds — is byte-stable. */
|
|
42
|
+
export const aceStrategy = {
|
|
43
|
+
id: "ace",
|
|
44
|
+
class: "ace-fact",
|
|
45
|
+
// eslint-disable-next-line require-await
|
|
46
|
+
async run(text) {
|
|
47
|
+
return runAce(text);
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
import { STOPWORDS } from "../normalize.mjs";
|
|
13
13
|
import { VOCAB_WORDS, eligibleForCanon, fuzzyVocabWord } from "../fuzzy.mjs";
|
|
14
14
|
|
|
15
|
-
// Reversible-passive detection (Cycle 6, PLAN_CYCLE_4.md): the passive auxiliaries that,
|
|
15
|
+
// Reversible-passive detection (Cycle 6, archive/PLAN_CYCLE_4.md): the passive auxiliaries that,
|
|
16
16
|
// together with an agent-marking "by", flip the active reading, and the wh-words that
|
|
17
17
|
// mark a QUESTIONED agent ("by which classes" / stranded "who is X tested by"). Bare
|
|
18
18
|
// "do/does/did" are deliberately EXCLUDED — "which X do not <verb> Y" is a NEGATION, not
|
|
@@ -176,7 +176,7 @@ export function parseKeywordSpot(text, nlp = null) {
|
|
|
176
176
|
if (objText) return { shape: "when", entityType: null, modifier: "direct", kind: "touches", object: objText };
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
// reversible passive (Cycle 6, PLAN_CYCLE_4.md): "PATIENT is VERBed BY AGENT" — an
|
|
179
|
+
// reversible passive (Cycle 6, archive/PLAN_CYCLE_4.md): "PATIENT is VERBed BY AGENT" — an
|
|
180
180
|
// agent-marking "by" plus a passive auxiliary flips the active reading, so the AGENT
|
|
181
181
|
// (after "by") is the edge SUBJECT and the PATIENT the edge OBJECT. Object-first
|
|
182
182
|
// phrasing is otherwise read subject-first and the edge traversed backwards. Fires
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The BOOTSTRAP reference provider — the empty/degenerate graph a fresh repo
|
|
2
|
-
// "contains" before anything is indexed. PLAN_REPOSITORY_INTERFACE.md deliverable
|
|
2
|
+
// "contains" before anything is indexed. archive/PLAN_REPOSITORY_INTERFACE.md deliverable
|
|
3
3
|
// 2: "bootstrap returns honest empties".
|
|
4
4
|
//
|
|
5
5
|
// It implements every Repository-Interface service over the empty bootstrap
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The FIXTURE reference provider — a small, real, self-contained code graph that
|
|
2
|
-
// implements every Repository-Interface service. PLAN_REPOSITORY_INTERFACE.md
|
|
2
|
+
// implements every Repository-Interface service. archive/PLAN_REPOSITORY_INTERFACE.md
|
|
3
3
|
// deliverable 2: "the executable specification an external producer reads first".
|
|
4
4
|
//
|
|
5
5
|
// It is a degenerate provider in the sense that its graph is tiny and its source
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// The reference Repository-Interface service over a parsed code graph.
|
|
2
|
-
// PLAN_REPOSITORY_INTERFACE.md — "the executable specification".
|
|
2
|
+
// archive/PLAN_REPOSITORY_INTERFACE.md — "the executable specification".
|
|
3
3
|
//
|
|
4
4
|
// createGraphService(graph) returns a typed service object implementing EVERY
|
|
5
5
|
// service in src/repository-interface.mjs over the `{ individuals, byId,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The Repository Interface — tmct's OWNED, versioned contract between "interpret
|
|
2
2
|
// the query" (tmct, the brittle side) and "ask the graph for truth" (a provider,
|
|
3
|
-
// the stable side). PLAN_REPOSITORY_INTERFACE.md.
|
|
3
|
+
// the stable side). archive/PLAN_REPOSITORY_INTERFACE.md.
|
|
4
4
|
//
|
|
5
5
|
// tmct defines and versions this shape; a provider (seonix, a fixture, a browser
|
|
6
6
|
// page) IMPLEMENTS it over its native graph. Both sides already agree on the
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// src/router/goal-reasoner.mjs — Stage 5 of the capability router
|
|
2
|
+
// (PLAN_CAPABILITY_ROUTER.md / STAGE_5_GOAL_REASONER.md): THE CLOSED-WORLD C2
|
|
3
|
+
// GOAL-REASONER. "Self-directed" is not magic — it is a canned, HARD-BOUNDED
|
|
4
|
+
// meta-loop (Rao & Georgeff BDI × Aha/Molineaux/Cox GDA × continual planning):
|
|
5
|
+
//
|
|
6
|
+
// deduce current goals (step 1 — the only genuinely new part)
|
|
7
|
+
// → plan for each goal (step 2 — C1: the Stage-3 planner/resolver)
|
|
8
|
+
// → arbitrate the first steps (step 3a — keystone, threat-aware)
|
|
9
|
+
// → PERSIST the committed intention (step 3b — BDI drop conditions)
|
|
10
|
+
// → execute ONE, observe, repeat (step 5 — Steel & Ho monitor / GDA replan)
|
|
11
|
+
//
|
|
12
|
+
// The elegance (RFC): C2 collapses into C1 + a goal-deduction step + an
|
|
13
|
+
// action-selection rule. Everything except goal-deduction is solved machinery.
|
|
14
|
+
// This module supplies the goal-deduction as a DEDUCTION over a DECLARED goal
|
|
15
|
+
// model (never a judgement over the request string) and REFUSES at the
|
|
16
|
+
// open-world goal-generation seam rather than inventing a goal — the C2 analogue
|
|
17
|
+
// of the resolver's "never emit a call it cannot prove".
|
|
18
|
+
//
|
|
19
|
+
// DEDUCTION, NOT KEYWORD-MATCH. The current goals fall out of the KB via a
|
|
20
|
+
// declared goal model (GOAL_RULES), exactly as syllogise chains a declared rule
|
|
21
|
+
// over the graph under mechanical guards. The ONLY thing this reads off the
|
|
22
|
+
// request is a FOCUS entity (delegated to the resolver's extractEntity + the
|
|
23
|
+
// binding oracle — entity resolution, never intent keywords). Whether a goal is
|
|
24
|
+
// active is then deduced from the graph (is the focus module untested? what does
|
|
25
|
+
// its change reach?), so no request-string literal steers the routing.
|
|
26
|
+
//
|
|
27
|
+
// MECHANICAL TERMINATION (not a convergence argument). Two independent bounds:
|
|
28
|
+
// (1) a hard OUTER-tick budget MAX_TICKS (mirrors the planner's MAX_STEPS), and
|
|
29
|
+
// (2) a MONOTONE-PROGRESS invariant — every tick ACHIEVES exactly one intention
|
|
30
|
+
// (removes it from the pending set); the only growth is a SINGLE, bounded
|
|
31
|
+
// GDA expansion (impact-of-each over the finite untested set), gated by a
|
|
32
|
+
// one-shot flag. So the pending set strictly shrinks to the empty set in
|
|
33
|
+
// <= (initial + |untested|) ticks, and MAX_TICKS caps it absolutely. A tick
|
|
34
|
+
// that makes no progress HALTS (honest refuse). Termination is proven
|
|
35
|
+
// mechanically, not argued from BDI convergence.
|
|
36
|
+
//
|
|
37
|
+
// THREAT-AWARENESS (POP threats lifted to the meta-level). A first step that
|
|
38
|
+
// clobbers another live goal's precondition is a threat. Here it is PROVABLY
|
|
39
|
+
// absent: every registry capability is read-only with an EMPTY delete-list
|
|
40
|
+
// (queries mutate nothing — the STRIPS closed world), so no step can delete a
|
|
41
|
+
// condition another goal depends on. We compute this from the registry rather
|
|
42
|
+
// than assume it (threatsAmong), so the guarantee is grounded, not asserted.
|
|
43
|
+
|
|
44
|
+
import { backwardChain, extractEntity } from "./resolver.mjs";
|
|
45
|
+
import { capabilityByName, effectsOf } from "./registry.mjs";
|
|
46
|
+
import { hallucinationsIn } from "../../agentbench/grade.mjs";
|
|
47
|
+
import { intersect } from "../../agentbench/results.mjs";
|
|
48
|
+
|
|
49
|
+
// Hard OUTER-tick budget — the meta-loop runs at most this many ticks, then
|
|
50
|
+
// REFUSES (escalate). Independent of BDI convergence and of the monotone
|
|
51
|
+
// invariant: a belt-and-braces mechanical stop, the meta-level twin of the
|
|
52
|
+
// planner's MAX_STEPS. A deduce->plan->observe cycle can never wedge the caller.
|
|
53
|
+
export const MAX_TICKS = 16;
|
|
54
|
+
|
|
55
|
+
// ---- the DECLARED goal model (data, mirroring registry.mjs's STRIPS operators)
|
|
56
|
+
// A goal-rule is a maintenance INVARIANT over the graph, plus the epistemic
|
|
57
|
+
// sub-goals whose facts decide whether it is violated and the DECLARED priority
|
|
58
|
+
// that breaks ties in first-step arbitration. Growing this set is the "long-chain
|
|
59
|
+
// deduction library" the RFC flags — same discipline as syllogise's rule set.
|
|
60
|
+
export const GOAL_RULES = Object.freeze([
|
|
61
|
+
Object.freeze({
|
|
62
|
+
id: "coverage-invariant",
|
|
63
|
+
kind: "maintenance",
|
|
64
|
+
// INVARIANT: a Module whose change reaches other modules (non-empty impact
|
|
65
|
+
// closure) MUST have direct test coverage. A Module that is untested AND
|
|
66
|
+
// impactful VIOLATES it — an active goal to close the coverage gap.
|
|
67
|
+
invariant: "an impactful module must be tested",
|
|
68
|
+
// the epistemic facts a plan must gather to evaluate the invariant (each
|
|
69
|
+
// backward-chains to a capability, exactly like the resolver's NL intents).
|
|
70
|
+
subGoals: Object.freeze(["impact", "untested"]),
|
|
71
|
+
// the DECLARED priority key for first-step arbitration: a violation's
|
|
72
|
+
// priority is its blast radius |impact(module)| — the wider the reach, the
|
|
73
|
+
// higher the goal (keystone = the widest-reach untested module).
|
|
74
|
+
priorityTopic: "impact",
|
|
75
|
+
// the coverage predicate the invariant screens on.
|
|
76
|
+
coverageTopic: "untested",
|
|
77
|
+
// the meta-goal topic the composed answer achieves (backward-chained below).
|
|
78
|
+
achieves: "coverage-gap",
|
|
79
|
+
}),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** Backward-chain a meta-goal topic to the declared goal-rule that achieves it —
|
|
83
|
+
* the goal-level twin of resolver.backwardChain (capability selection). Pure. */
|
|
84
|
+
export function backwardChainGoal(topic) {
|
|
85
|
+
return GOAL_RULES.find((r) => r.achieves === topic) || null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, proof: [], composed: null, driver, why });
|
|
89
|
+
|
|
90
|
+
/** THREATS lifted to the meta-level: any pending intention whose needed condition
|
|
91
|
+
* a candidate step's DELETE-effects would clobber (POP threats over the
|
|
92
|
+
* conjunction of active goals). Computed from the registry's delete-lists. In
|
|
93
|
+
* this read-only registry every capability's delete-list is empty, so this is
|
|
94
|
+
* provably [] — but we DERIVE it rather than assume it, so the guarantee holds
|
|
95
|
+
* the day a mutating capability is ever registered. Pure over the registry. */
|
|
96
|
+
export function threatsAmong(candidateName, _pending) {
|
|
97
|
+
const cap = capabilityByName(candidateName);
|
|
98
|
+
const del = cap ? effectsOf(cap.name).del : [];
|
|
99
|
+
return del.length ? [{ name: candidateName, deletes: del }] : [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Resolve the FOCUS the request scopes the goal model to — an entity binding
|
|
103
|
+
* (extractEntity + the graph oracle), NOT an intent keyword. Returns the bound
|
|
104
|
+
* individual or null (no bindable focus => a whole-graph / global goal). */
|
|
105
|
+
function focusOf(request, ctx) {
|
|
106
|
+
const term = extractEntity(String(request || ""));
|
|
107
|
+
if (!term || !ctx || !ctx.resolve) return null;
|
|
108
|
+
const r = ctx.resolve(term);
|
|
109
|
+
return r && r.match && !r.ambiguous ? r.match : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Ground ONE epistemic sub-goal (a topic + optional bound entity) into a
|
|
113
|
+
* grounded, EXECUTED call, or null when it is not groundable in the declared
|
|
114
|
+
* toolset (=> the meta-loop escalates). Backward-chains topic->capability, binds
|
|
115
|
+
* the entity, self-checks the same zero-hallucination gate the grader enforces,
|
|
116
|
+
* then dispatches. Mirrors the resolver/planner's honest-miss discipline. */
|
|
117
|
+
async function groundSubGoal(topic, entityLabel, tools, ctx) {
|
|
118
|
+
const cap = backwardChain(topic);
|
|
119
|
+
if (!cap || !tools.includes(cap.name)) return null; // no declared capability => escalate
|
|
120
|
+
// the arg grain: a no-arg coverage scan (untested) binds nothing; an entity
|
|
121
|
+
// topic binds the focus label to the capability's single slot.
|
|
122
|
+
const param = cap.parameters.find((p) => p.required);
|
|
123
|
+
if (param && !entityLabel) return null; // an entity topic with nothing to bind
|
|
124
|
+
const input = param && entityLabel ? { [param.arg]: entityLabel } : {};
|
|
125
|
+
const call = { name: cap.name, input };
|
|
126
|
+
if (hallucinationsIn(call, tools).length) return null; // never emit an unprovable call
|
|
127
|
+
const res = await ctx.dispatch(cap.name, input);
|
|
128
|
+
if (!res || !res.ok) return null; // honest miss at dispatch => escalate
|
|
129
|
+
return { call, result: Array.isArray(res.result) ? res.result : [] };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** BDI DROP CONDITIONS (Rao & Georgeff): an intention persists until it is
|
|
133
|
+
* achieved / impossible / its goal lapses. Returns the reason string, or null
|
|
134
|
+
* to KEEP committing to it. Pure — unit-testable in isolation. */
|
|
135
|
+
export function dropCondition(intention, observed, mode, focus) {
|
|
136
|
+
if (observed.has(intention.key)) return "achieved"; // fact now gathered
|
|
137
|
+
if (mode === "scoped" && (!focus || focus.class !== "Module")) return "lapsed"; // focus moved
|
|
138
|
+
return null; // else keep the commitment
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** THE META-LOOP. deduce current goals -> plan-each (C1) -> threat-aware
|
|
142
|
+
* persistent first-step arbitration -> execute one -> observe -> repeat,
|
|
143
|
+
* HARD-BOUNDED. Returns a loopResult { calls, refused, terminated, proof, why,
|
|
144
|
+
* composed, driver } — a composed answer (the coverage-gap set for a focus, or
|
|
145
|
+
* the keystone module globally) or an HONEST REFUSE at the open-world
|
|
146
|
+
* goal-generation seam.
|
|
147
|
+
*
|
|
148
|
+
* ctx: { dispatch(name,input)->{ok,result}, resolve(term)->{match,ambiguous} }. */
|
|
149
|
+
export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" } = {}) {
|
|
150
|
+
const declared = Array.isArray(tools) ? tools : [];
|
|
151
|
+
const rule = backwardChainGoal("coverage-gap");
|
|
152
|
+
if (!rule) return refuse("no declared goal-rule achieves the meta-goal — escalate", driver);
|
|
153
|
+
|
|
154
|
+
// STEP 1 — deduce the goal scope from the DECLARED model + a bound focus.
|
|
155
|
+
const focus = focusOf(request, ctx);
|
|
156
|
+
let mode;
|
|
157
|
+
if (focus && focus.class === "Module") mode = "scoped"; // assess the focus module's change footprint
|
|
158
|
+
else if (focus) mode = "escalate"; // a non-Module focus: no declared rule covers it
|
|
159
|
+
else mode = "global"; // no focus => rank the whole codebase (keystone)
|
|
160
|
+
|
|
161
|
+
// The open-world goal-generation seam, named honestly: a resolved focus the
|
|
162
|
+
// declared goal model does not cover is REFUSED, never given an invented goal.
|
|
163
|
+
if (mode === "escalate") {
|
|
164
|
+
return refuse(`open-world: no declared goal-rule covers a ${focus.class} focus (the coverage-invariant is Module-scoped) — escalate`, driver);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// the glass-box WHY, citing the declared goal-rule by backward-chain (the C2
|
|
168
|
+
// twin of resolver.mjs's "backward-chain => <capability>" provenance).
|
|
169
|
+
const why = [
|
|
170
|
+
`goal-deduction: backward-chain (achieves ${rule.achieves}) => goal-rule "${rule.id}" (${rule.invariant})`,
|
|
171
|
+
`mode: ${mode}${focus ? ` (focus ${focus.label} [${focus.class}])` : " (whole-graph / keystone arbitration)"}`,
|
|
172
|
+
"threat-check: read-only registry => every capability delete-list empty => meta-level POP threats provably none",
|
|
173
|
+
];
|
|
174
|
+
const proof = [{ step: "goal-rule", rule: rule.id, achieves: rule.achieves, ok: true }];
|
|
175
|
+
const calls = [];
|
|
176
|
+
const observed = new Map(); // intention.key -> gathered result set
|
|
177
|
+
|
|
178
|
+
// STEP 2/3 — the pending INTENTIONS (epistemic sub-goals), each carrying its
|
|
179
|
+
// declared execution order (arbitration is least-commitment: min order first,
|
|
180
|
+
// the keystone selection over the gathered facts happens at compose).
|
|
181
|
+
// scoped: gather impact(focus) then the untested coverage-scan.
|
|
182
|
+
// global: gather untested first, then EXPAND to impact-of-each (GDA replan).
|
|
183
|
+
const pending = mode === "scoped"
|
|
184
|
+
? [{ topic: "impact", of: focus.label, key: `impact:${focus.label}`, order: 0 },
|
|
185
|
+
{ topic: "untested", of: null, key: "untested", order: 1 }]
|
|
186
|
+
: [{ topic: "untested", of: null, key: "untested", order: 0 }];
|
|
187
|
+
|
|
188
|
+
let committed = null; // the persisted BDI intention (not re-derived each tick)
|
|
189
|
+
let expanded = false; // one-shot guard: the single bounded GDA expansion
|
|
190
|
+
let ticks = 0;
|
|
191
|
+
|
|
192
|
+
while (pending.length) {
|
|
193
|
+
// (1) HARD OUTER BOUND — mechanical, independent of the monotone invariant.
|
|
194
|
+
if (ticks >= MAX_TICKS) return refuse(`meta-loop tick budget exhausted (${MAX_TICKS}) — escalate`, driver);
|
|
195
|
+
ticks += 1;
|
|
196
|
+
|
|
197
|
+
// (3b) PERSISTENCE — keep the committed intention unless a BDI drop condition
|
|
198
|
+
// fires; only THEN re-arbitrate. This is the "commitment, not recomputed
|
|
199
|
+
// preference" that stops the loop thrashing.
|
|
200
|
+
if (committed && dropCondition(committed, observed, mode, focus)) committed = null;
|
|
201
|
+
if (!committed || !pending.includes(committed)) {
|
|
202
|
+
// (3a) FIRST-STEP ARBITRATION — least-commitment: the lowest declared order
|
|
203
|
+
// among pending. Threat-aware: skip a step that would clobber another
|
|
204
|
+
// live goal (provably never, read-only) before committing.
|
|
205
|
+
const admissible = pending.filter((i) => threatsAmong(backwardChain(i.topic)?.name, pending).length === 0);
|
|
206
|
+
if (!admissible.length) return refuse("all first steps are threatened (would clobber a live goal) — escalate", driver);
|
|
207
|
+
committed = admissible.slice().sort((a, b) => a.order - b.order)[0];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// (5) EXECUTE ONE, then OBSERVE (Steel & Ho monitor).
|
|
211
|
+
const grounded = await groundSubGoal(committed.topic, committed.of, declared, ctx);
|
|
212
|
+
if (!grounded) return refuse(`sub-goal (knows ${committed.topic}${committed.of ? ` ${committed.of}` : ""}) not groundable in the declared toolset — escalate`, driver);
|
|
213
|
+
calls.push(grounded.call);
|
|
214
|
+
observed.set(committed.key, grounded.result);
|
|
215
|
+
proof.push({ step: "causal-link", producer: "graph", condition: committed.of ?? committed.topic, consumer: `${committed.topic}:${grounded.call.name}`, ok: true });
|
|
216
|
+
|
|
217
|
+
// MONOTONE PROGRESS — this tick ACHIEVED exactly one intention: drop it.
|
|
218
|
+
const before = pending.length;
|
|
219
|
+
const achievedTopic = committed.topic;
|
|
220
|
+
pending.splice(pending.indexOf(committed), 1);
|
|
221
|
+
committed = null;
|
|
222
|
+
|
|
223
|
+
// GDA EXPANSION (monitor -> replan), ONCE: on observing the untested set in
|
|
224
|
+
// global mode, expand to the priority sub-goal (impact) for each violating
|
|
225
|
+
// module, so arbitration can rank them. Bounded by |untested| (finite) and
|
|
226
|
+
// fired at most once (the `expanded` guard) => the pending set still
|
|
227
|
+
// converges.
|
|
228
|
+
let expandedThisTick = false;
|
|
229
|
+
if (mode === "global" && achievedTopic === rule.coverageTopic && !expanded) {
|
|
230
|
+
expanded = true;
|
|
231
|
+
expandedThisTick = true;
|
|
232
|
+
const untested = observed.get("untested") || [];
|
|
233
|
+
untested.forEach((m, i) => pending.push({ topic: rule.priorityTopic, of: m, key: `impact:${m}`, order: 100 + i }));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// the invariant, enforced mechanically: the pending set shrank by one this
|
|
237
|
+
// tick (progress) OR grew ONLY by the one-shot bounded expansion. Anything
|
|
238
|
+
// else is non-progress => HALT honestly rather than risk a livelock.
|
|
239
|
+
if (pending.length > before - 1 && !expandedThisTick) {
|
|
240
|
+
return refuse("meta-loop made no monotone progress — halting", driver);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// STEP 3a (the answer) — COMPOSE + arbitrate the keystone from the gathered
|
|
245
|
+
// facts (all INSIDE the driver's timeout guard; no unbounded post-work).
|
|
246
|
+
let composed;
|
|
247
|
+
if (mode === "scoped") {
|
|
248
|
+
// the coverage-gap of the focus: the untested modules in its change
|
|
249
|
+
// FOOTPRINT ({focus} ∪ its impact closure) — a real composed set (∅ = no gap).
|
|
250
|
+
const footprint = [focus.label, ...(observed.get(`impact:${focus.label}`) || [])];
|
|
251
|
+
composed = intersect(observed.get("untested") || [], footprint);
|
|
252
|
+
why.push(`compose: untested ∩ ({${focus.label}} ∪ impact) = the change's untested footprint (${composed.length ? composed.join(", ") : "∅ — no coverage gap"})`);
|
|
253
|
+
} else {
|
|
254
|
+
// KEYSTONE arbitration: among the coverage violations (untested modules), pick
|
|
255
|
+
// the highest declared priority — the widest blast radius |impact(m)| — tie
|
|
256
|
+
// broken by label order. The single most-worth-covering module.
|
|
257
|
+
const untested = observed.get("untested") || [];
|
|
258
|
+
const ranked = untested
|
|
259
|
+
.map((m) => ({ m, weight: (observed.get(`impact:${m}`) || []).length }))
|
|
260
|
+
.sort((a, b) => b.weight - a.weight || String(a.m).localeCompare(String(b.m)));
|
|
261
|
+
composed = ranked.length ? [ranked[0].m] : [];
|
|
262
|
+
why.push(`keystone: argmax |impact| over ${untested.length} untested module(s) => ${composed.length ? `${composed[0]} (weight ${ranked[0].weight})` : "∅"}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { calls, refused: false, terminated: true, proof, why, composed, driver, observed: `goal(${mode}): ${calls.map((c) => c.name).join(" -> ")}` };
|
|
266
|
+
}
|