@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.
@@ -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
+ }
@@ -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 "../../agentbench/grade.mjs";
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 }
@@ -74,7 +74,28 @@ export function decompose(request) {
74
74
  };
75
75
  }
76
76
 
77
- // METHOD 3 — SEQUENCING: split on the ordered connectives. Least commitment:
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())
@@ -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"): a regex -> {topic, arg}. Same backward
25
- // chaining (topic -> capability), same resolveObject binding.
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 "../../agentbench/grade.mjs";
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
- export const NOT_NL_REACHABLE = Object.freeze({
81
- tmct_calls: "the raw call-edge dump collides with tmct_callees on every NL phrasing (\"what does X call\" -> callees) and has no command verb; distinguishing it needs a Stage-2 intent frame we have not authored",
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
- { re: /\bcallees?\b|what\s+does\s+\S+\s+call\b/i, topic: "callees", arg: "symbol" },
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) pick = mapped;
241
- else if (mapped && mapped.refuse) nlRefuse = mapped.reason;
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) return REFUSE(nlRefuse || "no command, NL parse, or imperative frame selects a capability");
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
+ }