@polycode-projects/the-mechanical-code-talker 0.8.0 → 0.8.2
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 +80 -31
- package/data/templates/responses.jsonl +1 -1
- package/package.json +1 -1
- package/src/ask.mjs +138 -14
- package/src/chat.mjs +369 -20
- package/src/codegraph.mjs +109 -1
- package/src/interpret/merge.mjs +16 -1
- package/src/interpret/normalize.mjs +144 -2
- package/src/interpret/pipeline.mjs +18 -3
- package/src/interpret/strategies/ace.mjs +49 -0
- package/src/memory/core.mjs +8 -1
- package/src/memory/fold.mjs +0 -0
- package/src/memory/trust.mjs +8 -4
- package/src/router/call-validator.mjs +45 -0
- package/src/router/goal-reasoner.mjs +364 -0
- package/src/router/guardrail.mjs +1 -1
- package/src/router/planner.mjs +22 -1
- package/src/router/resolver.mjs +49 -11
- package/src/router/set-algebra.mjs +31 -0
|
@@ -0,0 +1,364 @@
|
|
|
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 "./call-validator.mjs";
|
|
47
|
+
import { intersect } from "./set-algebra.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 settles 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
|
+
//
|
|
61
|
+
// Each rule declares (pure frozen data, no code):
|
|
62
|
+
// focusClass — the entity class the scoped reading binds its focus to.
|
|
63
|
+
// modes — which deduced scopes the rule covers ("scoped" = a bound focus;
|
|
64
|
+
// "global" = whole-graph keystone arbitration). A rule whose
|
|
65
|
+
// sub-goals are all entity-scoped has no global reading.
|
|
66
|
+
// subGoals — the epistemic facts to gather, IN DECLARED ORDER (each
|
|
67
|
+
// backward-chains to a capability, exactly like an NL intent).
|
|
68
|
+
// compose — the declarative fold of the gathered facts into the scoped
|
|
69
|
+
// answer: intersect(a, b), each side naming a gathered topic,
|
|
70
|
+
// optionally bound to the focus (`of:"focus"`), optionally with
|
|
71
|
+
// the focus itself unioned in (`withFocus` — the change footprint).
|
|
72
|
+
// priorityTopic / coverageTopic — the global keystone arbitration keys
|
|
73
|
+
// (argmax |priority(m)| over the coverage-violating set).
|
|
74
|
+
// achieves — the meta-goal topic the composed answer achieves.
|
|
75
|
+
export const GOAL_RULES = Object.freeze([
|
|
76
|
+
Object.freeze({
|
|
77
|
+
id: "coverage-invariant",
|
|
78
|
+
kind: "maintenance",
|
|
79
|
+
// INVARIANT: a Module whose change reaches other modules (non-empty impact
|
|
80
|
+
// closure) MUST have direct test coverage. A Module that is untested AND
|
|
81
|
+
// impactful VIOLATES it — an active goal to close the coverage gap.
|
|
82
|
+
invariant: "an impactful module must be tested",
|
|
83
|
+
focusClass: "Module",
|
|
84
|
+
modes: Object.freeze(["scoped", "global"]),
|
|
85
|
+
subGoals: Object.freeze(["impact", "untested"]),
|
|
86
|
+
// the DECLARED priority key for first-step arbitration: a violation's
|
|
87
|
+
// priority is its blast radius |impact(module)| — the wider the reach, the
|
|
88
|
+
// higher the goal (keystone = the widest-reach untested module).
|
|
89
|
+
priorityTopic: "impact",
|
|
90
|
+
// the coverage predicate the invariant screens on.
|
|
91
|
+
coverageTopic: "untested",
|
|
92
|
+
// scoped fold: untested ∩ ({focus} ∪ impact(focus)) — the change footprint.
|
|
93
|
+
compose: Object.freeze({
|
|
94
|
+
op: "intersect",
|
|
95
|
+
a: Object.freeze({ topic: "untested" }),
|
|
96
|
+
b: Object.freeze({ topic: "impact", of: "focus", withFocus: true }),
|
|
97
|
+
names: "the change's untested footprint",
|
|
98
|
+
empty: "no coverage gap",
|
|
99
|
+
}),
|
|
100
|
+
// the meta-goal topic the composed answer achieves (backward-chained below).
|
|
101
|
+
achieves: "coverage-gap",
|
|
102
|
+
}),
|
|
103
|
+
Object.freeze({
|
|
104
|
+
id: "cochange-risk-invariant",
|
|
105
|
+
kind: "maintenance",
|
|
106
|
+
// INVARIANT: a module CHANGE-COUPLED with the focus (they historically land
|
|
107
|
+
// in the same commits) MUST have direct test coverage. A coupled module that
|
|
108
|
+
// is untested VIOLATES it — an active goal over the focus's coupling set.
|
|
109
|
+
invariant: "a module change-coupled with the focus must be tested",
|
|
110
|
+
focusClass: "Module",
|
|
111
|
+
// scoped ONLY: both sub-goals are read relative to a bound focus; there is
|
|
112
|
+
// no whole-graph keystone reading declared for change-coupling.
|
|
113
|
+
modes: Object.freeze(["scoped"]),
|
|
114
|
+
subGoals: Object.freeze(["cochanges", "untested"]),
|
|
115
|
+
priorityTopic: "cochanges",
|
|
116
|
+
coverageTopic: "untested",
|
|
117
|
+
// scoped fold: cochanges(focus) ∩ untested — the coupled-but-untested set.
|
|
118
|
+
compose: Object.freeze({
|
|
119
|
+
op: "intersect",
|
|
120
|
+
a: Object.freeze({ topic: "cochanges", of: "focus" }),
|
|
121
|
+
b: Object.freeze({ topic: "untested" }),
|
|
122
|
+
names: "the change-coupled untested set",
|
|
123
|
+
empty: "every change-coupled module is tested",
|
|
124
|
+
}),
|
|
125
|
+
achieves: "cochange-risk",
|
|
126
|
+
}),
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
/** Backward-chain a meta-goal topic to the declared goal-rule that achieves it —
|
|
130
|
+
* the goal-level twin of resolver.backwardChain (capability selection). Pure. */
|
|
131
|
+
export function backwardChainGoal(topic) {
|
|
132
|
+
return GOAL_RULES.find((r) => r.achieves === topic) || null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** THE RULE-SELECTION DEDUCTION (replaces the old single-rule hard-wiring): a
|
|
136
|
+
* declared goal-rule APPLIES to a request iff
|
|
137
|
+
* (1) every one of its epistemic sub-goal topics backward-chains to a
|
|
138
|
+
* capability IN the declared toolset (the closed-world groundability
|
|
139
|
+
* screen — the meta-level twin of "never select an out-of-set call"),
|
|
140
|
+
* (2) the deduced mode is one of the rule's declared modes, and
|
|
141
|
+
* (3) a scoped reading's bound focus is of the rule's declared focusClass.
|
|
142
|
+
* Pure over the goal model + registry: nothing here reads the request string.
|
|
143
|
+
* The caller REFUSES on zero matches (the open-world goal-generation seam) and
|
|
144
|
+
* on more than one (an ambiguous meta-goal — arbitration between meta-goals is
|
|
145
|
+
* undeclared, so guessing one would be an invented goal). */
|
|
146
|
+
export function applicableRules(declaredTools, focus, mode) {
|
|
147
|
+
const declared = Array.isArray(declaredTools) ? declaredTools : [];
|
|
148
|
+
return GOAL_RULES.filter((rule) =>
|
|
149
|
+
rule.modes.includes(mode)
|
|
150
|
+
&& (mode !== "scoped" || (focus != null && focus.class === rule.focusClass))
|
|
151
|
+
&& rule.subGoals.every((topic) => {
|
|
152
|
+
const cap = backwardChain(topic);
|
|
153
|
+
return Boolean(cap && declared.includes(cap.name));
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, proof: [], composed: null, driver, why });
|
|
158
|
+
|
|
159
|
+
/** THREATS lifted to the meta-level: any pending intention whose needed condition
|
|
160
|
+
* a candidate step's DELETE-effects would clobber (POP threats over the
|
|
161
|
+
* conjunction of active goals). Computed from the registry's delete-lists. In
|
|
162
|
+
* this read-only registry every capability's delete-list is empty, so this is
|
|
163
|
+
* provably [] — but we DERIVE it rather than assume it, so the guarantee holds
|
|
164
|
+
* the day a mutating capability is ever registered. Pure over the registry. */
|
|
165
|
+
export function threatsAmong(candidateName, _pending) {
|
|
166
|
+
const cap = capabilityByName(candidateName);
|
|
167
|
+
const del = cap ? effectsOf(cap.name).del : [];
|
|
168
|
+
return del.length ? [{ name: candidateName, deletes: del }] : [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Resolve the FOCUS the request scopes the goal model to — an entity binding
|
|
172
|
+
* (extractEntity + the graph oracle), NOT an intent keyword. Returns the bound
|
|
173
|
+
* individual or null (no bindable focus => a whole-graph / global goal). */
|
|
174
|
+
function focusOf(request, ctx) {
|
|
175
|
+
const term = extractEntity(String(request || ""));
|
|
176
|
+
if (!term || !ctx || !ctx.resolve) return null;
|
|
177
|
+
const r = ctx.resolve(term);
|
|
178
|
+
return r && r.match && !r.ambiguous ? r.match : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Ground ONE epistemic sub-goal (a topic + optional bound entity) into a
|
|
182
|
+
* grounded, EXECUTED call, or null when it is not groundable in the declared
|
|
183
|
+
* toolset (=> the meta-loop escalates). Backward-chains topic->capability, binds
|
|
184
|
+
* the entity, self-checks the same zero-hallucination gate the grader enforces,
|
|
185
|
+
* then dispatches. Mirrors the resolver/planner's honest-miss discipline. */
|
|
186
|
+
async function groundSubGoal(topic, entityLabel, tools, ctx) {
|
|
187
|
+
const cap = backwardChain(topic);
|
|
188
|
+
if (!cap || !tools.includes(cap.name)) return null; // no declared capability => escalate
|
|
189
|
+
// the arg grain: a no-arg coverage scan (untested) binds nothing; an entity
|
|
190
|
+
// topic binds the focus label to the capability's single slot.
|
|
191
|
+
const param = cap.parameters.find((p) => p.required);
|
|
192
|
+
if (param && !entityLabel) return null; // an entity topic with nothing to bind
|
|
193
|
+
const input = param && entityLabel ? { [param.arg]: entityLabel } : {};
|
|
194
|
+
const call = { name: cap.name, input };
|
|
195
|
+
if (hallucinationsIn(call, tools).length) return null; // never emit an unprovable call
|
|
196
|
+
const res = await ctx.dispatch(cap.name, input);
|
|
197
|
+
if (!res || !res.ok) return null; // honest miss at dispatch => escalate
|
|
198
|
+
return { call, result: Array.isArray(res.result) ? res.result : [] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** BDI DROP CONDITIONS (Rao & Georgeff): an intention persists until it is
|
|
202
|
+
* achieved / impossible / its goal lapses. `focusClass` is the SELECTED rule's
|
|
203
|
+
* declared focus class (never a literal here — the rule is the authority).
|
|
204
|
+
* Returns the reason string, or null to KEEP committing to it. Pure. */
|
|
205
|
+
export function dropCondition(intention, observed, mode, focus, focusClass) {
|
|
206
|
+
if (observed.has(intention.key)) return "achieved"; // fact now gathered
|
|
207
|
+
if (mode === "scoped" && (!focus || focus.class !== focusClass)) return "lapsed"; // focus moved
|
|
208
|
+
return null; // else keep the commitment
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** THE META-LOOP. deduce current goals -> plan-each (C1) -> threat-aware
|
|
212
|
+
* persistent first-step arbitration -> execute one -> observe -> repeat,
|
|
213
|
+
* HARD-BOUNDED. Returns a loopResult { calls, refused, terminated, proof, why,
|
|
214
|
+
* composed, driver } — a composed answer (the coverage-gap set for a focus, or
|
|
215
|
+
* the keystone module globally) or an HONEST REFUSE at the open-world
|
|
216
|
+
* goal-generation seam.
|
|
217
|
+
*
|
|
218
|
+
* ctx: { dispatch(name,input)->{ok,result}, resolve(term)->{match,ambiguous} }. */
|
|
219
|
+
export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" } = {}) {
|
|
220
|
+
const declared = Array.isArray(tools) ? tools : [];
|
|
221
|
+
|
|
222
|
+
// STEP 1 — deduce the goal scope from the DECLARED model + a bound focus,
|
|
223
|
+
// then SELECT the goal-rule by pure applicability (no request keyword ever):
|
|
224
|
+
// a bound focus reads scoped, no focus reads global (keystone arbitration).
|
|
225
|
+
const focus = focusOf(request, ctx);
|
|
226
|
+
const mode = focus ? "scoped" : "global";
|
|
227
|
+
|
|
228
|
+
// The open-world goal-generation seam, named honestly: a resolved focus whose
|
|
229
|
+
// class NO declared goal-rule scopes is REFUSED, never given an invented goal.
|
|
230
|
+
if (focus && !GOAL_RULES.some((r) => r.focusClass === focus.class)) {
|
|
231
|
+
const covered = [...new Set(GOAL_RULES.map((r) => r.focusClass))].join("/");
|
|
232
|
+
return refuse(`open-world: no declared goal-rule covers a ${focus.class} focus (the declared goal-rules are ${covered}-scoped) — escalate`, driver);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Rule selection is a DEDUCTION over the goal model + the declared toolset:
|
|
236
|
+
// 0 applicable rules => the same open-world seam (nothing declared grounds the
|
|
237
|
+
// request's scope in this toolset); >1 => an AMBIGUOUS meta-goal (arbitration
|
|
238
|
+
// between meta-goals is undeclared) — both are honest refusals, never a guess.
|
|
239
|
+
const applicable = applicableRules(declared, focus, mode);
|
|
240
|
+
if (!applicable.length) {
|
|
241
|
+
return refuse(`open-world: no declared goal-rule is applicable in ${mode} mode (each needs a sub-goal capability outside the declared toolset, or a scope it does not declare) — escalate`, driver);
|
|
242
|
+
}
|
|
243
|
+
if (applicable.length > 1) {
|
|
244
|
+
return refuse(`ambiguous meta-goal: ${applicable.length} declared goal-rules apply (${applicable.map((r) => r.id).join(", ")}) — meta-goal arbitration is undeclared, refuse rather than guess — escalate`, driver);
|
|
245
|
+
}
|
|
246
|
+
const rule = applicable[0];
|
|
247
|
+
|
|
248
|
+
// the glass-box WHY, citing the declared goal-rule by backward-chain (the C2
|
|
249
|
+
// twin of resolver.mjs's "backward-chain => <capability>" provenance).
|
|
250
|
+
const why = [
|
|
251
|
+
`goal-deduction: backward-chain (achieves ${rule.achieves}) => goal-rule "${rule.id}" (${rule.invariant})`,
|
|
252
|
+
`mode: ${mode}${focus ? ` (focus ${focus.label} [${focus.class}])` : " (whole-graph / keystone arbitration)"}`,
|
|
253
|
+
"threat-check: read-only registry => every capability delete-list empty => meta-level POP threats provably none",
|
|
254
|
+
];
|
|
255
|
+
const proof = [{ step: "goal-rule", rule: rule.id, achieves: rule.achieves, ok: true }];
|
|
256
|
+
const calls = [];
|
|
257
|
+
const observed = new Map(); // intention.key -> gathered result set
|
|
258
|
+
|
|
259
|
+
// STEP 2/3 — the pending INTENTIONS: the rule's epistemic sub-goals IN
|
|
260
|
+
// DECLARED ORDER (arbitration is least-commitment: min order first, the
|
|
261
|
+
// keystone selection over the gathered facts happens at compose). A topic
|
|
262
|
+
// binds the focus iff its capability declares a REQUIRED parameter (read from
|
|
263
|
+
// the registry, never special-cased by topic name); in global mode there is
|
|
264
|
+
// no focus to bind, so entity-scoped topics are deferred to the GDA expansion
|
|
265
|
+
// (gather the coverage scan first, then EXPAND to priority-of-each violator).
|
|
266
|
+
const bindsEntity = (topic) => {
|
|
267
|
+
const cap = backwardChain(topic);
|
|
268
|
+
return Boolean(cap && cap.parameters.some((p) => p.required));
|
|
269
|
+
};
|
|
270
|
+
const pending = rule.subGoals
|
|
271
|
+
.filter((topic) => mode === "scoped" || !bindsEntity(topic))
|
|
272
|
+
.map((topic, i) => (mode === "scoped" && bindsEntity(topic)
|
|
273
|
+
? { topic, of: focus.label, key: `${topic}:${focus.label}`, order: i }
|
|
274
|
+
: { topic, of: null, key: topic, order: i }));
|
|
275
|
+
|
|
276
|
+
let committed = null; // the persisted BDI intention (not re-derived each tick)
|
|
277
|
+
let expanded = false; // one-shot guard: the single bounded GDA expansion
|
|
278
|
+
let ticks = 0;
|
|
279
|
+
|
|
280
|
+
while (pending.length) {
|
|
281
|
+
// (1) HARD OUTER BOUND — mechanical, independent of the monotone invariant.
|
|
282
|
+
if (ticks >= MAX_TICKS) return refuse(`meta-loop tick budget exhausted (${MAX_TICKS}) — escalate`, driver);
|
|
283
|
+
ticks += 1;
|
|
284
|
+
|
|
285
|
+
// (3b) PERSISTENCE — keep the committed intention unless a BDI drop condition
|
|
286
|
+
// fires; only THEN re-arbitrate. This is the "commitment, not recomputed
|
|
287
|
+
// preference" that stops the loop thrashing.
|
|
288
|
+
if (committed && dropCondition(committed, observed, mode, focus, rule.focusClass)) committed = null;
|
|
289
|
+
if (!committed || !pending.includes(committed)) {
|
|
290
|
+
// (3a) FIRST-STEP ARBITRATION — least-commitment: the lowest declared order
|
|
291
|
+
// among pending. Threat-aware: skip a step that would clobber another
|
|
292
|
+
// live goal (provably never, read-only) before committing.
|
|
293
|
+
const admissible = pending.filter((i) => threatsAmong(backwardChain(i.topic)?.name, pending).length === 0);
|
|
294
|
+
if (!admissible.length) return refuse("all first steps are threatened (would clobber a live goal) — escalate", driver);
|
|
295
|
+
committed = admissible.slice().sort((a, b) => a.order - b.order)[0];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// (5) EXECUTE ONE, then OBSERVE (Steel & Ho monitor).
|
|
299
|
+
const grounded = await groundSubGoal(committed.topic, committed.of, declared, ctx);
|
|
300
|
+
if (!grounded) return refuse(`sub-goal (knows ${committed.topic}${committed.of ? ` ${committed.of}` : ""}) not groundable in the declared toolset — escalate`, driver);
|
|
301
|
+
calls.push(grounded.call);
|
|
302
|
+
observed.set(committed.key, grounded.result);
|
|
303
|
+
proof.push({ step: "causal-link", producer: "graph", condition: committed.of ?? committed.topic, consumer: `${committed.topic}:${grounded.call.name}`, ok: true });
|
|
304
|
+
|
|
305
|
+
// MONOTONE PROGRESS — this tick ACHIEVED exactly one intention: drop it.
|
|
306
|
+
const before = pending.length;
|
|
307
|
+
const achievedTopic = committed.topic;
|
|
308
|
+
pending.splice(pending.indexOf(committed), 1);
|
|
309
|
+
committed = null;
|
|
310
|
+
|
|
311
|
+
// GDA EXPANSION (monitor -> replan), ONCE: on observing the coverage set in
|
|
312
|
+
// global mode (guarded on the rule DECLARING a global reading), expand to
|
|
313
|
+
// the priority sub-goal for each violating module, so arbitration can rank
|
|
314
|
+
// them. Bounded by the finite coverage set and fired at most once (the
|
|
315
|
+
// `expanded` guard) => the pending set still converges.
|
|
316
|
+
let expandedThisTick = false;
|
|
317
|
+
if (mode === "global" && rule.modes.includes("global") && achievedTopic === rule.coverageTopic && !expanded) {
|
|
318
|
+
expanded = true;
|
|
319
|
+
expandedThisTick = true;
|
|
320
|
+
const violating = observed.get(rule.coverageTopic) || [];
|
|
321
|
+
violating.forEach((m, i) => pending.push({ topic: rule.priorityTopic, of: m, key: `${rule.priorityTopic}:${m}`, order: 100 + i }));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// the invariant, enforced mechanically: the pending set shrank by one this
|
|
325
|
+
// tick (progress) OR grew ONLY by the one-shot bounded expansion. Anything
|
|
326
|
+
// else is non-progress => HALT honestly rather than risk a livelock.
|
|
327
|
+
if (pending.length > before - 1 && !expandedThisTick) {
|
|
328
|
+
return refuse("meta-loop made no monotone progress — halting", driver);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// STEP 3a (the answer) — COMPOSE + arbitrate the keystone from the gathered
|
|
333
|
+
// facts (all INSIDE the driver's timeout guard; no unbounded post-work).
|
|
334
|
+
let composed;
|
|
335
|
+
if (mode === "scoped") {
|
|
336
|
+
// interpret the rule's DECLARATIVE compose spec: intersect two gathered
|
|
337
|
+
// sides, each a topic (optionally focus-bound, optionally with the focus
|
|
338
|
+
// itself unioned in — the change-footprint shape). ∅ is a real answer.
|
|
339
|
+
const sideSet = (side) => {
|
|
340
|
+
const key = side.of === "focus" ? `${side.topic}:${focus.label}` : side.topic;
|
|
341
|
+
const set = observed.get(key) || [];
|
|
342
|
+
return side.withFocus ? [focus.label, ...set] : set;
|
|
343
|
+
};
|
|
344
|
+
const sideDesc = (side) => (side.withFocus
|
|
345
|
+
? `({${focus.label}} ∪ ${side.topic})`
|
|
346
|
+
: side.of === "focus" ? `${side.topic}(${focus.label})` : side.topic);
|
|
347
|
+
const spec = rule.compose;
|
|
348
|
+
composed = intersect(sideSet(spec.a), sideSet(spec.b));
|
|
349
|
+
why.push(`compose: ${sideDesc(spec.a)} ∩ ${sideDesc(spec.b)} = ${spec.names} (${composed.length ? composed.join(", ") : `∅ — ${spec.empty}`})`);
|
|
350
|
+
} else {
|
|
351
|
+
// KEYSTONE arbitration: among the coverage violations, pick the highest
|
|
352
|
+
// declared priority — the widest |priority(m)| set — tie broken by label
|
|
353
|
+
// order. The single most-worth-covering module. Only a rule declaring a
|
|
354
|
+
// global mode ever reaches here (applicability screened on rule.modes).
|
|
355
|
+
const violating = observed.get(rule.coverageTopic) || [];
|
|
356
|
+
const ranked = violating
|
|
357
|
+
.map((m) => ({ m, weight: (observed.get(`${rule.priorityTopic}:${m}`) || []).length }))
|
|
358
|
+
.sort((a, b) => b.weight - a.weight || String(a.m).localeCompare(String(b.m)));
|
|
359
|
+
composed = ranked.length ? [ranked[0].m] : [];
|
|
360
|
+
why.push(`keystone: argmax |${rule.priorityTopic}| over ${violating.length} ${rule.coverageTopic} module(s) => ${composed.length ? `${composed[0]} (weight ${ranked[0].weight})` : "∅"}`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return { calls, refused: false, terminated: true, proof, why, composed, driver, observed: `goal(${mode}): ${calls.map((c) => c.name).join(" -> ")}` };
|
|
364
|
+
}
|
package/src/router/guardrail.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
// Pure over its inputs + ctx.resolve (the binding oracle). No network, no Date.now.
|
|
29
29
|
|
|
30
30
|
import { capabilityByName, preconditionsOf, PRECOND } from "./registry.mjs";
|
|
31
|
-
import { hallucinationsIn } from "
|
|
31
|
+
import { hallucinationsIn } from "./call-validator.mjs";
|
|
32
32
|
|
|
33
33
|
/** Validate a proposed tool_use. Returns a glass-box verdict:
|
|
34
34
|
* { ok, tool, denied:[{reason,detail}], steps:[{pred,ok,...}], provenance }
|
package/src/router/planner.mjs
CHANGED
|
@@ -74,7 +74,28 @@ export function decompose(request) {
|
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// METHOD 3 —
|
|
77
|
+
// METHOD 3 — the MEMBER-FILTER recipe: "which/what methods|members of X …
|
|
78
|
+
// (end up|eventually)? calling/reaching Y". A C1 surface-syntax recipe like the
|
|
79
|
+
// conditional and relative-filter methods above (the C1 discipline: a closed,
|
|
80
|
+
// authored shape — NOT the C2 goal-reasoner's deduction). Decomposes to
|
|
81
|
+
// [enumerate members(X), filter by bounded transitive call-reach of Y]. The
|
|
82
|
+
// second segment is the filter TARGET, role "member-filter": the DRIVER owns
|
|
83
|
+
// the per-member callees hop + the reachability fold (driver-resolver.mjs) —
|
|
84
|
+
// segment 2 is not a resolvable leaf sub-goal on its own.
|
|
85
|
+
const mem = raw.match(
|
|
86
|
+
/^(?:which|what)\s+(?:methods?|members?)\s+of\s+(.+?)\s+(?:(?:end\s+up|eventually)\s+)?(?:calls?|calling|reach(?:es|ing)?|invokes?|invoking)\s+(.+?)\s*\??$/i,
|
|
87
|
+
);
|
|
88
|
+
if (mem) {
|
|
89
|
+
return {
|
|
90
|
+
method: "member-filter",
|
|
91
|
+
segments: [
|
|
92
|
+
{ text: `members ${mem[1].trim()}`, role: "action", thread: false },
|
|
93
|
+
{ text: mem[2].trim(), role: "member-filter", thread: true },
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// METHOD 4 — SEQUENCING: split on the ordered connectives. Least commitment:
|
|
78
99
|
// we only split where a connective actually is.
|
|
79
100
|
const parts = raw.split(/\s*(?:,\s*then\s+|,\s+and\s+then\s+|\s+and\s+then\s+|\s+then\s+|,\s+|\s+and\s+)\s*/i)
|
|
80
101
|
.map((s) => s.replace(/^(?:then\s+|and\s+then\s+|and\s+|also\s+|check\s+|next\s+)/i, "").trim())
|
package/src/router/resolver.mjs
CHANGED
|
@@ -21,8 +21,13 @@
|
|
|
21
21
|
// the topic to the capability. This is the Stage-1 deliverable proper.
|
|
22
22
|
// 3. IMPERATIVE INTENT FRAMES (Stage 2, this module's FRAMES table) — curated
|
|
23
23
|
// phrasings the relational grammar does not carry ("blast radius of X",
|
|
24
|
-
// "who calls X", "search for X"
|
|
25
|
-
// chaining (topic -> capability), same
|
|
24
|
+
// "who calls X", "search for X", "the call edges of X", "explain X"): a
|
|
25
|
+
// regex -> {topic, arg}. Same backward chaining (topic -> capability), same
|
|
26
|
+
// resolveObject binding. This is the surface that lifts NL reach above the
|
|
27
|
+
// command register: it reaches tmct_calls (the raw call-edge dump — a grain
|
|
28
|
+
// the relational "call" verb collides with) via an EXPLICIT edge-dump frame,
|
|
29
|
+
// and it rescues a request whose NL parse selected an OUT-OF-SET capability
|
|
30
|
+
// by re-selecting a DECLARED one (resolveOne falls through to the frame).
|
|
26
31
|
//
|
|
27
32
|
// ENTITY BINDING is DELEGATED to `resolveObject` (ask.mjs — the tiered lemma/
|
|
28
33
|
// fuzzy binding oracle with honest ambiguity). This module NEVER re-implements
|
|
@@ -39,7 +44,7 @@ import { selectTool } from "../server-http.mjs";
|
|
|
39
44
|
import {
|
|
40
45
|
capabilities, capabilityByName, preconditionsOf, effectsOf, PRECOND,
|
|
41
46
|
} from "./registry.mjs";
|
|
42
|
-
import { hallucinationsIn } from "
|
|
47
|
+
import { hallucinationsIn } from "./call-validator.mjs";
|
|
43
48
|
|
|
44
49
|
// ---- the ask-kind -> epistemic-topic MAPPING (the Stage-1 core) --------------
|
|
45
50
|
// Keyed `${shape}:${kind}` off parseQuery's simple-clause output. The VALUE is
|
|
@@ -77,9 +82,17 @@ export const UNMAPPED_KINDS = Object.freeze({
|
|
|
77
82
|
// conformance test FAILS on an untagged gap; a genuinely-unreachable cap must be
|
|
78
83
|
// tagged HERE with the Stage it needs, so the ceiling is honest rather than a
|
|
79
84
|
// silent low-completion refuse. (Coordinator reinforcement 2.)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
//
|
|
86
|
+
// EMPTY as of Stage 2. tmct_calls — the raw call-edge dump that USED to sit here —
|
|
87
|
+
// is now reached by a DEDICATED imperative frame keyed on the "call edges / call
|
|
88
|
+
// graph / outgoing calls of X" phrasings the relational grammar does NOT carry
|
|
89
|
+
// (see FRAMES below). The collision the old tag named is real, so the frame does
|
|
90
|
+
// NOT touch the relational "call" verb (that still routes callers/callees); it
|
|
91
|
+
// opens a SECOND, distinct surface that names the edge-dump grain explicitly. With
|
|
92
|
+
// it every declared capability is NL/command/frame-reachable — the ceiling is
|
|
93
|
+
// genuinely empty, not a silenced gap. (The conformance test enforces both
|
|
94
|
+
// directions: an over-claimed tag would now fail, since tmct_calls IS reachable.)
|
|
95
|
+
export const NOT_NL_REACHABLE = Object.freeze({});
|
|
83
96
|
|
|
84
97
|
// ---- imperative intent FRAMES (Stage 2 — fills what the relational grammar and
|
|
85
98
|
// the command register both miss). regex -> { topic, arg | noArg }. `arg` names
|
|
@@ -90,7 +103,14 @@ export const NOT_NL_REACHABLE = Object.freeze({
|
|
|
90
103
|
export const FRAMES = Object.freeze([
|
|
91
104
|
{ re: /\buntested\b|\bwithout\s+(?:a\s+)?tests?\b|\bhas\s+no\s+tests?\b|\bneeds?\s+(?:a\s+)?tests?\b/i, topic: "untested", noArg: true },
|
|
92
105
|
{ re: /\bblast\s*radius\b|\bimpacts?\b|\bimpacted\b|what\s+(?:a\s+)?change.*(?:reach|affect|touch)|what\s+(?:depends?\s+on|dependents?)\b/i, topic: "impact", arg: "module" },
|
|
93
|
-
|
|
106
|
+
// tmct_calls (Stage 2 — the reachability win): the RAW call-edge dump, a grain
|
|
107
|
+
// the relational "call" verb collides with (which routes callers/callees). This
|
|
108
|
+
// frame does NOT use the bare verb — it keys on the EXPLICIT edge-dump nouns
|
|
109
|
+
// ("call edges", "call graph", "outgoing calls of X") the relational grammar
|
|
110
|
+
// never emits, so it opens a distinct surface without touching callers/callees.
|
|
111
|
+
// FIRST so its explicit phrasing wins before the callees/callers verb frames.
|
|
112
|
+
{ re: /\bcall[\s-]*edges?\b|\bcall[\s-]*graph\b|\boutgoing\s+calls?\b|\bcall[\s-]*sites?\s+(?:of|in|out|from)\b/i, topic: "calls", arg: "symbol" },
|
|
113
|
+
{ re: /\bcallees?\b|wh(?:at|o)\s+does\s+\S+\s+call\b/i, topic: "callees", arg: "symbol" },
|
|
94
114
|
{ re: /\bcallers?\b|who\s+calls\b|what\s+calls\b/i, topic: "callers", arg: "symbol" },
|
|
95
115
|
{ re: /\btests?\b.*\b(?:for|cover|covering|of)\b|which\s+tests?\b|covers?\b|covered\b|test\s+coverage\b/i, topic: "tests", arg: "symbol" },
|
|
96
116
|
{ re: /\bcochang|change[- ]coupl/i, topic: "cochanges", arg: "symbol" },
|
|
@@ -99,7 +119,7 @@ export const FRAMES = Object.freeze([
|
|
|
99
119
|
{ re: /\bmembers?\b|\bmethods?\s+of\b|\battributes?\s+of\b/i, topic: "members", arg: "class" },
|
|
100
120
|
{ re: /\bhistory\b|who\s+changed\b|commits?\s+(?:that\s+)?touch/i, topic: "history", arg: "symbol" },
|
|
101
121
|
{ re: /\bsignature\b/i, topic: "signature", arg: "symbol" },
|
|
102
|
-
{ re: /\bdescribe\b|what\s+is\b|tell\s+me\s+about\b|definition\s+of\b/i, topic: "description", arg: "symbol" },
|
|
122
|
+
{ re: /\bdescribe\b|\bexplain\b|what\s+is\b|tell\s+me\s+about\b|definition\s+of\b/i, topic: "description", arg: "symbol" },
|
|
103
123
|
{ re: /\bsearch\b|\bfind\b|look\s+for\b/i, topic: "matches", arg: "query" },
|
|
104
124
|
]);
|
|
105
125
|
|
|
@@ -129,6 +149,9 @@ const STOP = new Set([
|
|
|
129
149
|
"export", "exports", "history", "commit", "commits", "signature", "search", "find", "look",
|
|
130
150
|
"module", "modules", "class", "classes", "function", "functions", "symbol", "symbols",
|
|
131
151
|
"untested", "blast", "radius", "change", "changes", "changing", "reach", "reaches", "affect", "affects",
|
|
152
|
+
// Stage-2 edge-dump + imperative-verb tokens (tmct_calls frame + explain/outgoing
|
|
153
|
+
// phrasings): none names an entity, so keep them out of the slot-filler's pool.
|
|
154
|
+
"explain", "edge", "edges", "graph", "outgoing", "site", "sites", "invoke", "invokes", "run", "runs", "execute", "executes",
|
|
132
155
|
]);
|
|
133
156
|
|
|
134
157
|
/** Pull one entity token from a request (imperative-frame slot-filling). Prefer a
|
|
@@ -235,13 +258,28 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
|
|
|
235
258
|
// so we fall through and only surface the NL reason if the frame misses too.
|
|
236
259
|
let pick = commandCapability(request, declared);
|
|
237
260
|
let nlRefuse = null;
|
|
261
|
+
let nlUndeclared = null;
|
|
238
262
|
if (!pick) {
|
|
239
263
|
const mapped = mapParse(parseQuery(request));
|
|
240
|
-
if (mapped && !mapped.refuse)
|
|
241
|
-
|
|
264
|
+
if (mapped && !mapped.refuse) {
|
|
265
|
+
// An NL parse that selects a DECLARED capability is the Stage-1 answer. One
|
|
266
|
+
// that selects an OUT-OF-SET capability is NOT terminal (Stage-2 widening):
|
|
267
|
+
// an imperative FRAME may still reach a DECLARED capability for the SAME
|
|
268
|
+
// request (e.g. keyword-spot mis-routes "outgoing calls of X" toward an
|
|
269
|
+
// out-of-set callers/callees, but the calls-frame reaches the declared
|
|
270
|
+
// tmct_calls). Hold the out-of-set name and fall through; surface it only if
|
|
271
|
+
// the frame misses too. This can only turn a refuse into a grounded DECLARED
|
|
272
|
+
// call — the declared/hallucination gates below still apply, never a guess.
|
|
273
|
+
if (declared.has(mapped.name)) pick = mapped;
|
|
274
|
+
else nlUndeclared = mapped.name;
|
|
275
|
+
} else if (mapped && mapped.refuse) nlRefuse = mapped.reason;
|
|
242
276
|
}
|
|
243
277
|
if (!pick) pick = mapFrame(request);
|
|
244
|
-
if (!pick)
|
|
278
|
+
if (!pick) {
|
|
279
|
+
if (nlRefuse) return REFUSE(nlRefuse);
|
|
280
|
+
if (nlUndeclared) return REFUSE(`selected ${nlUndeclared} but it is not in the declared toolset`);
|
|
281
|
+
return REFUSE("no command, NL parse, or imperative frame selects a capability");
|
|
282
|
+
}
|
|
245
283
|
let why = pick.why ?? [];
|
|
246
284
|
if (!declared.has(pick.name)) return REFUSE(`selected ${pick.name} but it is not in the declared toolset`);
|
|
247
285
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/router/set-algebra.mjs — the COMPOSITION OPERATORS: pure set-algebra a
|
|
2
|
+
// multi-step plan needs to fold its threaded step result-sets into ONE composed
|
|
3
|
+
// answer. Shared by the product router (goal-reasoner's relative-filter fold)
|
|
4
|
+
// and the bench result-execution layer (agentbench/results.mjs re-exports
|
|
5
|
+
// these) — the bench imports the product, never the other way round. The
|
|
6
|
+
// resolver DRIVER picks the operator from the router's OWN HTN method
|
|
7
|
+
// (relative-filter -> intersect; conditional -> fallback/guard) and applies it;
|
|
8
|
+
// grade.mjs never imports these (it only value-compares the driver's composed
|
|
9
|
+
// answer to the STATIC expect.result literal — no circular re-derivation).
|
|
10
|
+
// No I/O, no Date.now, no LLM.
|
|
11
|
+
|
|
12
|
+
/** Dedupe + locale-stable sort — the canonical label-set normal form. */
|
|
13
|
+
export const uniqSort = (xs) => [...new Set(xs)].sort((a, b) => String(a).localeCompare(String(b)));
|
|
14
|
+
|
|
15
|
+
/** a ∩ b — the relative-filter fold ("of the <set>, which are <Y>"). */
|
|
16
|
+
export function intersect(a = [], b = []) {
|
|
17
|
+
const bs = new Set(b);
|
|
18
|
+
return uniqSort([...new Set(a)].filter((x) => bs.has(x)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** if a is empty -> b, else a — the conditional "… <action> instead" recipe
|
|
22
|
+
* ("if X has no tests, list what covers Y instead"). */
|
|
23
|
+
export function fallbackIfEmpty(a = [], b = []) {
|
|
24
|
+
return uniqSort(a.length ? a : b);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** if a is empty -> b, else ∅ — the guarded conditional ("if X is untested,
|
|
28
|
+
* <action> it"): the action's result fires ONLY when the guard set is empty. */
|
|
29
|
+
export function guardIfEmpty(a = [], b = []) {
|
|
30
|
+
return uniqSort(a.length ? [] : b);
|
|
31
|
+
}
|