@polycode-projects/the-mechanical-code-talker 0.8.1 → 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 +51 -9
- package/data/templates/responses.jsonl +1 -1
- package/package.json +1 -1
- package/src/ask.mjs +115 -19
- package/src/chat.mjs +356 -20
- package/src/codegraph.mjs +109 -1
- package/src/interpret/merge.mjs +16 -1
- package/src/interpret/normalize.mjs +109 -4
- 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 +148 -50
- package/src/router/guardrail.mjs +1 -1
- package/src/router/planner.mjs +22 -1
- package/src/router/resolver.mjs +1 -1
- package/src/router/set-algebra.mjs +31 -0
package/src/interpret/merge.mjs
CHANGED
|
@@ -25,7 +25,18 @@ const DEFAULT_CONFIDENCE = 0.5;
|
|
|
25
25
|
// commit-sha tier strips the noun — the anchored strategy captures the noun inside
|
|
26
26
|
// its object span while keyword-spot consumes it as the entity keyword, so without
|
|
27
27
|
// this the two strategies would "disagree" over a word that names no different thing.
|
|
28
|
-
|
|
28
|
+
// A leading DETERMINER is the same kind of non-difference (0.8.2 feel wave): the
|
|
29
|
+
// anchored grammar captures "the logger" while keyword-spot captures "logger", and
|
|
30
|
+
// the resulting "ambiguity" asked the user to choose between identical readings.
|
|
31
|
+
const cmpTerm = (s) => String(s || "").trim().toLowerCase().replace(/\s+/g, " ")
|
|
32
|
+
.replace(/^(?:the|a|an)\s+/, "")
|
|
33
|
+
.replace(/^commit\s+(?=[0-9a-f]{7,40}$)/, "");
|
|
34
|
+
|
|
35
|
+
// Leading-determiner probe over a parse's term slots — the dedupe below keeps the
|
|
36
|
+
// det-LESS twin so downstream term resolution sees the bare object ("logger",
|
|
37
|
+
// never "the logger").
|
|
38
|
+
const LEADING_DET_RE = /^\s*(?:the|a|an)\s+/i;
|
|
39
|
+
const detCount = (p) => [p?.subject, p?.object].filter((t) => LEADING_DET_RE.test(String(t || ""))).length;
|
|
29
40
|
|
|
30
41
|
/** Do two independently-produced parses mean the same graph query? Same
|
|
31
42
|
* shape, same relation kind, and matching term(s) (both subject and object
|
|
@@ -99,6 +110,10 @@ export function mergeStrategyResults(results) {
|
|
|
99
110
|
if (dup) {
|
|
100
111
|
dup.agreed += 1;
|
|
101
112
|
dup.confidence = Math.max(dup.confidence, c.confidence);
|
|
113
|
+
// determiner-insensitive collapse: when the agreeing parses differ only
|
|
114
|
+
// by a leading determiner, the det-less reading's parse survives (the
|
|
115
|
+
// representative's strategy/confidence standing is unchanged).
|
|
116
|
+
if (detCount(c.parsed) < detCount(dup.parsed)) dup.parsed = c.parsed;
|
|
102
117
|
continue;
|
|
103
118
|
}
|
|
104
119
|
distinct.push({ ...c, agreed: 1 });
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
CONTRACTIONS, MISSPELLINGS, WRONG_WORDS, G_DROP, FILLER_WORDS,
|
|
16
|
-
NEGATION_FRAMES, COMMIT_CONTENT_FRAMES,
|
|
16
|
+
NEGATION_FRAMES, COMMIT_CONTENT_FRAMES, VERB_TO_KIND,
|
|
17
17
|
} from "../ask-vocab.mjs";
|
|
18
18
|
|
|
19
19
|
export function escapeRegex(s) {
|
|
@@ -44,8 +44,92 @@ const correctionRe = (table) => new RegExp(
|
|
|
44
44
|
const MISSPELLING_RE = correctionRe(MISSPELLINGS);
|
|
45
45
|
const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
|
|
46
46
|
|
|
47
|
+
// ---- closed PREAMBLE frames (0.8.2 feel wave, PLAN_CHAT_FEEL item 2) — the
|
|
48
|
+
// conversational wrapping a developer puts AROUND a real question: a greeting
|
|
49
|
+
// lead-in with a delimiter ("hey there, quick question - …"), the modal
|
|
50
|
+
// politeness wrapper ("can you … please"), and the show/give-me presentation
|
|
51
|
+
// bridge. These are DELIMITER- and PHRASE-anchored, so they must run BEFORE the
|
|
52
|
+
// FILLER-strip pass below: FILLER_WORDS strips "hey"/"can you" as bare words, so
|
|
53
|
+
// after that pass the frames' anchors are gone while the punctuation debris
|
|
54
|
+
// ("there, quick question -") still poisons the parse (the playtest wall).
|
|
55
|
+
// Applied inside normalizeQuery — the one seam BOTH composition sites (ask.mjs
|
|
56
|
+
// parseQuery and interpret/pipeline.mjs normalizeInput) run first — AFTER the
|
|
57
|
+
// word-restoring correction tables (so "gimme"/"shwo me" are already "give me"/
|
|
58
|
+
// "show me") and BEFORE the filler strip. Closed patterns, applied in order to a
|
|
59
|
+
// small fixpoint; unmatched text passes through byte-unchanged. ----
|
|
60
|
+
|
|
61
|
+
/** Any relation verb phrase from the shared vocabulary — the show/give-me
|
|
62
|
+
* bridge's "this remainder is a real relation query" probe. */
|
|
63
|
+
const RELATION_VERB_RE = new RegExp(
|
|
64
|
+
"\\b(?:" + Object.keys(VERB_TO_KIND).sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
65
|
+
"i",
|
|
66
|
+
);
|
|
67
|
+
/** A remainder that opens interrogatively is already a question — unwrap it. */
|
|
68
|
+
const INTERROGATIVE_LEAD_RE = /^(?:which|what|who|whose|where|when|why|how)\b/i;
|
|
69
|
+
/** A remainder that is a KIND listing ("show me [the] untested modules", "show
|
|
70
|
+
* me the tests") already belongs to the compositional list/qualifier grammar,
|
|
71
|
+
* whose LIST_TRIGGERS include "show me"/"give me" — leave the WHOLE text
|
|
72
|
+
* untouched so that working path keeps it. Two shapes: a plural kind noun in
|
|
73
|
+
* tail position, or a bare (det +) singular kind noun and nothing else. */
|
|
74
|
+
const LISTING_TAIL_KINDS = new Set([
|
|
75
|
+
"modules", "files", "functions", "methods", "classes", "attributes", "fields",
|
|
76
|
+
"properties", "variables", "globals", "commits", "changes", "tests", "members",
|
|
77
|
+
]);
|
|
78
|
+
const BARE_KIND_RE = /^(?:all\s+|the\s+)?(?:module|file|function|method|class|attribute|field|property|variable|global|commit|change|test|member)\??$/i;
|
|
79
|
+
const isListingRemainder = (rest) => {
|
|
80
|
+
if (BARE_KIND_RE.test(rest)) return true;
|
|
81
|
+
const words = rest.replace(/\?+\s*$/, "").trim().split(/\s+/);
|
|
82
|
+
return LISTING_TAIL_KINDS.has((words[words.length - 1] || "").toLowerCase());
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Greeting lead-in with a delimiter (+ optional "quick question" bridge):
|
|
86
|
+
* "hey there, quick question - <Q>" -> "<Q>". The delimiter and the non-empty
|
|
87
|
+
* remainder are REQUIRED, so a bare "hey there" stays a greeting for chat's
|
|
88
|
+
* conversational lane, and "hey tmct, …" (a vocative, no delimiter after the
|
|
89
|
+
* greeting word) is left for the noise-strip tier that already owns it. */
|
|
90
|
+
const GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy)(?:\s+there)?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
|
|
91
|
+
/** Modal politeness wrapper: "can/could/would/will you [please] <Q>[, please][?]"
|
|
92
|
+
* -> "<Q>". FILLER_WORDS already ate "can you"/"please" as words; this frame
|
|
93
|
+
* removes them as a WRAPPER so the ", please" comma never survives into the
|
|
94
|
+
* parsed object term. The unwrapped remainder flows on through the ordinary
|
|
95
|
+
* passes, so "can you tell me a joke" -> "tell me a joke" -> (FILLER) "a joke"
|
|
96
|
+
* — byte-identical to what the bare form normalizes to (the hm-joke wall). */
|
|
97
|
+
const MODAL_WRAPPER_RE = /^(?:can|could|would|will)\s+you\s+(?:please\s+)?(.+?)(?:[,\s]+please)?\??$/i;
|
|
98
|
+
/** show/give-me presentation bridge: "show me [the] <thing>". Three-way:
|
|
99
|
+
* a KIND-listing remainder is left untouched (the compositional list grammar
|
|
100
|
+
* owns "show me untested modules"); a remainder carrying a relation verb or an
|
|
101
|
+
* interrogative lead is a real query merely presented — unwrap to it ("show me
|
|
102
|
+
* which modules import X" -> "which modules import X"); anything else is an
|
|
103
|
+
* entity presentation — bridge to the describe surface ("show me the store
|
|
104
|
+
* module" -> "describe store module"). */
|
|
105
|
+
const SHOW_GIVE_ME_RE = /^(?:show|give)\s+me\s+(?:the\s+)?(.+?)\??$/i;
|
|
106
|
+
|
|
107
|
+
/** Apply the closed preamble frames in order (greeting -> modal -> show/give-me),
|
|
108
|
+
* repeated to a small fixpoint so stacked wrappers ("hey, can you show me X
|
|
109
|
+
* please") peel fully. Pure and idempotent; unmatched text passes through. */
|
|
110
|
+
export function applyPreambleFrames(text) {
|
|
111
|
+
let q = String(text || "");
|
|
112
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
113
|
+
const before = q;
|
|
114
|
+
let m = q.match(GREETING_PREAMBLE_RE);
|
|
115
|
+
if (m) q = m[1].trim();
|
|
116
|
+
m = q.match(MODAL_WRAPPER_RE);
|
|
117
|
+
if (m) q = m[1].trim();
|
|
118
|
+
m = q.match(SHOW_GIVE_ME_RE);
|
|
119
|
+
if (m) {
|
|
120
|
+
const rest = m[1].trim();
|
|
121
|
+
if (!isListingRemainder(rest)) {
|
|
122
|
+
q = (RELATION_VERB_RE.test(rest) || INTERROGATIVE_LEAD_RE.test(rest)) ? rest : `describe ${rest}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (q === before) break;
|
|
126
|
+
}
|
|
127
|
+
return q;
|
|
128
|
+
}
|
|
129
|
+
|
|
47
130
|
/** Free-text -> normalized free-text: contractions expanded, g-dropped words
|
|
48
|
-
* restored, filler/politeness words stripped.
|
|
131
|
+
* restored, closed preamble frames peeled, filler/politeness words stripped.
|
|
132
|
+
* Idempotent and pure — the same
|
|
49
133
|
* input always normalizes the same way, so both parsing strategies see
|
|
50
134
|
* identical text and their outputs are directly comparable. Deliberately
|
|
51
135
|
* does NOT force lowercase: object/subject terms (module names like
|
|
@@ -59,6 +143,10 @@ export function normalizeQuery(text) {
|
|
|
59
143
|
q = q.replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
|
|
60
144
|
q = q.replace(WRONG_WORD_RE, (m) => WRONG_WORDS[m.toLowerCase()]);
|
|
61
145
|
q = q.replace(G_DROP, "$1ing");
|
|
146
|
+
// closed preamble frames (greeting lead-in, modal wrapper, show/give-me
|
|
147
|
+
// bridge) — AFTER the correction tables (a repaired "give me"/"show me" still
|
|
148
|
+
// feeds the bridge) but BEFORE the filler strip erases their anchor words.
|
|
149
|
+
q = applyPreambleFrames(q);
|
|
62
150
|
if (FILLER_WORDS.length) {
|
|
63
151
|
const fillerRe = new RegExp(
|
|
64
152
|
"\\b(" + [...FILLER_WORDS].sort((a, b) => b.length - a.length).map(escapeRegex).join("|") + ")\\b",
|
|
@@ -173,8 +261,25 @@ export const PHRASING_FRAMES = Object.freeze([
|
|
|
173
261
|
// separate authorship edge; "touched" IS the authorship signal (the churn commits
|
|
174
262
|
// carry the author), so these are true synonyms of "who touched X", not a new
|
|
175
263
|
// capability. Anaphora rides through untouched ("who wrote it" → "who touched it").
|
|
176
|
-
|
|
177
|
-
|
|
264
|
+
// SHA GUARD (0.8.2 feel wave): a COMMIT object is NOT a synonym — "who is the
|
|
265
|
+
// author of abc1234" rewritten to "who touched abc1234" dumps the commit's
|
|
266
|
+
// touch-SET instead of naming its author. The negative lookahead refuses the
|
|
267
|
+
// rewrite when the object is a bare (optionally "commit "-prefixed) 7-40 char
|
|
268
|
+
// hex sha, leaving the un-rewritten form for the author lane to consume;
|
|
269
|
+
// file/symbol objects (anything non-sha, e.g. "deadbeef.mjs") keep the rewrite.
|
|
270
|
+
{ re: /^who\s+(?:wrote|authored)\s+(?:the\s+)?(?!(?:commit\s+)?[0-9a-f]{7,40}\??$)(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
|
|
271
|
+
{ re: /^who\s+is\s+the\s+authors?\s+of\s+(?:the\s+)?(?!(?:commit\s+)?[0-9a-f]{7,40}\??$)(.+?)\??$/i, to: (m) => `who touched ${m[1]}` },
|
|
272
|
+
|
|
273
|
+
// HAS-TESTS → the coverage question the RELATIONS table answers. "does X have
|
|
274
|
+
// tests" parses "have" as a defines-verb (VERB_TO_KIND), producing the garbled
|
|
275
|
+
// "No — no defines edge found from X to <whatever resolves>" receipt; "is X
|
|
276
|
+
// tested" traverses tests edges from the WRONG side (subject = X). Both mean
|
|
277
|
+
// the coverage question "what tests X" — rewrite onto it. Closed to a
|
|
278
|
+
// tests/coverage object ("does X have methods/members" stays the members
|
|
279
|
+
// family) and refuses any "not" in the subject span, so the set-complement
|
|
280
|
+
// negations ("is X not tested") keep their own handler downstream.
|
|
281
|
+
{ re: /^(?:does|do)\s+(?!.*\bnot\b)(.+?)\s+have\s+(?:any\s+)?(?:tests?|test\s+coverage|coverage)\??$/i, to: (m) => `what tests ${m[1]}` },
|
|
282
|
+
{ re: /^(?:is|are)\s+(?!.*\bnot\b)(.+?)\s+tested\??$/i, to: (m) => `what tests ${m[1]}` },
|
|
178
283
|
|
|
179
284
|
// NEEDS-TESTS → the untested-module survey. "what needs tests" / "what needs
|
|
180
285
|
// testing" is the plainest way to ask which modules are uncovered, and it hit the
|
package/src/memory/core.mjs
CHANGED
|
@@ -78,7 +78,7 @@ const MEMORY_VOCABULARY = [
|
|
|
78
78
|
{ prop: DERIVED_FROM_PROP, predicate: "derivedFrom", note: "umbrella: a Fact derived from a Source (or another Fact). ext ref prov:wasDerivedFrom (UNVERIFIED-pending-web-check)" },
|
|
79
79
|
{ prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
|
|
80
80
|
{ prop: CANONICALISED_FROM_PROP, predicate: "canonicalisedFrom", note: "subPropertyOf derivedFrom: a canonical Fact cleaned from a raw Block/Source, never replacing it" },
|
|
81
|
-
{ prop: "mgx:sourceType", note: "a Source's kind: operator | provider | corpus | web | entailed (the trust-prior key)" },
|
|
81
|
+
{ prop: "mgx:sourceType", note: "a Source's kind: operator | teach | provider | corpus | web | entailed (the trust-prior key)" },
|
|
82
82
|
{ prop: "mgx:sourceUrl", note: "a web Source's URL" },
|
|
83
83
|
{ prop: "mgx:sourceRule", note: "an entailed Source's rule id" },
|
|
84
84
|
{ prop: TRUST_SCORE_PROP, note: "materialised trust cache in [0,1] — pure function of a fact's Sources + createdAt (memory/trust.mjs); invalidated when a statedBy edge is added" },
|
|
@@ -171,6 +171,7 @@ function setAttr(ind, prop, key, value) {
|
|
|
171
171
|
function sourceIdFor(desc) {
|
|
172
172
|
switch (desc?.kind) {
|
|
173
173
|
case "operator": return { id: OPERATOR_SOURCE_ID, type: "operator" };
|
|
174
|
+
case "teach": return { id: "src:teach-chat", type: "teach" };
|
|
174
175
|
case "provider": return { id: `src:provider:${desc.name}`, type: "provider" };
|
|
175
176
|
case "corpus": return { id: `src:corpus:${desc.name}`, type: "corpus" };
|
|
176
177
|
case "web": return { id: `src:learned:web:${fnv1aHex(String(desc.url || ""))}`, type: "web", url: String(desc.url || "") };
|
|
@@ -209,6 +210,7 @@ function upsertSource(payload, desc, createdAtCandidate) {
|
|
|
209
210
|
* through. The tag formats are exactly what the writers produce:
|
|
210
211
|
* corpus:conceptnet /r/IsA → { kind:"corpus", name:"conceptnet" }
|
|
211
212
|
* ace:chat:<session>@<ts> → { kind:"operator", createdAt:<ts> }
|
|
213
|
+
* teach:chat:<session>@<ts> → { kind:"teach", createdAt:<ts> }
|
|
212
214
|
* web:<url> | url:<url> → { kind:"web", url:<url> }
|
|
213
215
|
* entailed:<rule> → { kind:"entailed", rule:<rule> }
|
|
214
216
|
* chat:/session: refs map to the operator; an unknown tag → null (no Source).
|
|
@@ -222,6 +224,11 @@ export function provenanceTagToSource(tag) {
|
|
|
222
224
|
const at = head.indexOf("@");
|
|
223
225
|
return { kind: "operator", createdAt: at >= 0 ? head.slice(at + 1) : "" };
|
|
224
226
|
}
|
|
227
|
+
if (head.startsWith("teach:")) {
|
|
228
|
+
// the chat teach lane's natural frames — chat.mjs's teachProvenanceTag
|
|
229
|
+
const at = head.indexOf("@");
|
|
230
|
+
return { kind: "teach", createdAt: at >= 0 ? head.slice(at + 1) : "" };
|
|
231
|
+
}
|
|
225
232
|
if (head.startsWith("web:")) return { kind: "web", url: head.slice("web:".length) };
|
|
226
233
|
if (head.startsWith("url:")) return { kind: "web", url: head.slice("url:".length) };
|
|
227
234
|
if (head.startsWith("entailed:")) return { kind: "entailed", rule: head.slice("entailed:".length) };
|
package/src/memory/fold.mjs
CHANGED
|
Binary file
|
package/src/memory/trust.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// Trust is a COMPUTED attribute of a Fact — never hand-set — a pure function of
|
|
5
5
|
// its Source edges, those Sources' types, and its mgx:createdAt. Three inputs
|
|
6
6
|
// combine:
|
|
7
|
-
// - a Source-TYPE PRIOR (operator > provider > corpus > web > entailed);
|
|
7
|
+
// - a Source-TYPE PRIOR (operator > teach > provider > corpus > web > entailed);
|
|
8
8
|
// - CORROBORATION over the fact's distinct Sources by noisy-OR
|
|
9
9
|
// (1 − Π(1 − wᵢ), capped at 1) — two independent web sources (0.4) reach
|
|
10
10
|
// 0.64, a lone operator fact is already 1.0;
|
|
@@ -26,11 +26,15 @@
|
|
|
26
26
|
export const TRUST_SCORE_PROP = "mgx:trustScore";
|
|
27
27
|
export const TRUST_INPUTS_PROP = "mgx:trustInputs";
|
|
28
28
|
|
|
29
|
-
/** Source-type priors — the ordering operator > provider-graph >
|
|
30
|
-
* > web > unverified-entailment.
|
|
31
|
-
*
|
|
29
|
+
/** Source-type priors — the ordering operator > teach > provider-graph >
|
|
30
|
+
* curated-corpus > web > unverified-entailment. `teach` is the chat teach
|
|
31
|
+
* lane's natural-frame writes ("remember that …", "<Name> owns <X>") — still
|
|
32
|
+
* operator speech, but through a looser recognizer than the ACE-parsed
|
|
33
|
+
* operator assert, so it sits just below the operator prior. The entailed
|
|
34
|
+
* value is a FLOOR before premise adjustment (see the entailed hook below). */
|
|
32
35
|
export const SOURCE_PRIOR = Object.freeze({
|
|
33
36
|
operator: 1.0,
|
|
37
|
+
teach: 0.95,
|
|
34
38
|
provider: 0.9,
|
|
35
39
|
corpus: 0.7,
|
|
36
40
|
web: 0.4,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/router/call-validator.mjs — pure registry validators shared by the
|
|
2
|
+
// product router (resolver / guardrail / goal-reasoner) + the bench grader
|
|
3
|
+
// (agentbench/grade.mjs re-exports these). Depends ONLY on registry.mjs — no
|
|
4
|
+
// bench code — so the product←bench dependency stays inverted: the bench
|
|
5
|
+
// imports the product, never the other way round. No I/O, no Date.now, no LLM.
|
|
6
|
+
|
|
7
|
+
import { isCapability, argKeysOf, requiredArgsOf, preconditionsOf, PRECOND } from "./registry.mjs";
|
|
8
|
+
|
|
9
|
+
/** Every way a single produced call can be a HALLUCINATION — the one thing a
|
|
10
|
+
* deterministic router must never do. Returns [] for a clean call, else a list
|
|
11
|
+
* of { reason, detail }:
|
|
12
|
+
* - "undeclared" — name is not in the case's declared toolset
|
|
13
|
+
* - "unknown-tool"— name is not a registry capability at all
|
|
14
|
+
* - "unknown-arg" — an input key the capability does not accept
|
|
15
|
+
* - "missing-arg" — a required arg absent (and no any-present precond covers it)
|
|
16
|
+
* Any nonempty result = AUTOMATIC FAIL for the case. */
|
|
17
|
+
export function hallucinationsIn(call, declaredTools) {
|
|
18
|
+
const problems = [];
|
|
19
|
+
const name = call?.name;
|
|
20
|
+
if (typeof name !== "string" || !name) return [{ reason: "unknown-tool", detail: "no tool name" }];
|
|
21
|
+
if (!isCapability(name)) return [{ reason: "unknown-tool", detail: name }];
|
|
22
|
+
if (!declaredTools.includes(name)) problems.push({ reason: "undeclared", detail: name });
|
|
23
|
+
const input = call.input && typeof call.input === "object" ? call.input : {};
|
|
24
|
+
const accepted = argKeysOf(name);
|
|
25
|
+
for (const key of Object.keys(input)) {
|
|
26
|
+
if (!accepted.has(key)) problems.push({ reason: "unknown-arg", detail: `${name}.${key}` });
|
|
27
|
+
}
|
|
28
|
+
// required-arg presence, honoring an any-present disjunction (search: query|kind)
|
|
29
|
+
const anyGroups = preconditionsOf(name)
|
|
30
|
+
.filter((p) => p.pred === PRECOND.anyPresent)
|
|
31
|
+
.map((p) => p.params);
|
|
32
|
+
const present = (k) => input[k] !== undefined && input[k] !== null && String(input[k]).trim() !== "";
|
|
33
|
+
for (const req of requiredArgsOf(name)) {
|
|
34
|
+
if (!present(req)) problems.push({ reason: "missing-arg", detail: `${name}.${req}` });
|
|
35
|
+
}
|
|
36
|
+
for (const group of anyGroups) {
|
|
37
|
+
if (!group.some(present)) problems.push({ reason: "missing-arg", detail: `${name} needs one of ${group.join("|")}` });
|
|
38
|
+
}
|
|
39
|
+
return problems;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True iff a produced call is dispatchable-shaped (no hallucination). */
|
|
43
|
+
export function isCallWellFormed(call, declaredTools) {
|
|
44
|
+
return hallucinationsIn(call, declaredTools).length === 0;
|
|
45
|
+
}
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
|
|
44
44
|
import { backwardChain, extractEntity } from "./resolver.mjs";
|
|
45
45
|
import { capabilityByName, effectsOf } from "./registry.mjs";
|
|
46
|
-
import { hallucinationsIn } from "
|
|
47
|
-
import { intersect } from "
|
|
46
|
+
import { hallucinationsIn } from "./call-validator.mjs";
|
|
47
|
+
import { intersect } from "./set-algebra.mjs";
|
|
48
48
|
|
|
49
49
|
// Hard OUTER-tick budget — the meta-loop runs at most this many ticks, then
|
|
50
50
|
// REFUSES (escalate). Independent of BDI convergence and of the monotone
|
|
@@ -55,8 +55,23 @@ export const MAX_TICKS = 16;
|
|
|
55
55
|
// ---- the DECLARED goal model (data, mirroring registry.mjs's STRIPS operators)
|
|
56
56
|
// A goal-rule is a maintenance INVARIANT over the graph, plus the epistemic
|
|
57
57
|
// sub-goals whose facts decide whether it is violated and the DECLARED priority
|
|
58
|
-
// that
|
|
58
|
+
// that settles ties in first-step arbitration. Growing this set is the "long-chain
|
|
59
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.
|
|
60
75
|
export const GOAL_RULES = Object.freeze([
|
|
61
76
|
Object.freeze({
|
|
62
77
|
id: "coverage-invariant",
|
|
@@ -65,8 +80,8 @@ export const GOAL_RULES = Object.freeze([
|
|
|
65
80
|
// closure) MUST have direct test coverage. A Module that is untested AND
|
|
66
81
|
// impactful VIOLATES it — an active goal to close the coverage gap.
|
|
67
82
|
invariant: "an impactful module must be tested",
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
focusClass: "Module",
|
|
84
|
+
modes: Object.freeze(["scoped", "global"]),
|
|
70
85
|
subGoals: Object.freeze(["impact", "untested"]),
|
|
71
86
|
// the DECLARED priority key for first-step arbitration: a violation's
|
|
72
87
|
// priority is its blast radius |impact(module)| — the wider the reach, the
|
|
@@ -74,9 +89,41 @@ export const GOAL_RULES = Object.freeze([
|
|
|
74
89
|
priorityTopic: "impact",
|
|
75
90
|
// the coverage predicate the invariant screens on.
|
|
76
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
|
+
}),
|
|
77
100
|
// the meta-goal topic the composed answer achieves (backward-chained below).
|
|
78
101
|
achieves: "coverage-gap",
|
|
79
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
|
+
}),
|
|
80
127
|
]);
|
|
81
128
|
|
|
82
129
|
/** Backward-chain a meta-goal topic to the declared goal-rule that achieves it —
|
|
@@ -85,6 +132,28 @@ export function backwardChainGoal(topic) {
|
|
|
85
132
|
return GOAL_RULES.find((r) => r.achieves === topic) || null;
|
|
86
133
|
}
|
|
87
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
|
+
|
|
88
157
|
const refuse = (why, driver) => ({ calls: [], refused: true, terminated: true, proof: [], composed: null, driver, why });
|
|
89
158
|
|
|
90
159
|
/** THREATS lifted to the meta-level: any pending intention whose needed condition
|
|
@@ -130,11 +199,12 @@ async function groundSubGoal(topic, entityLabel, tools, ctx) {
|
|
|
130
199
|
}
|
|
131
200
|
|
|
132
201
|
/** BDI DROP CONDITIONS (Rao & Georgeff): an intention persists until it is
|
|
133
|
-
* achieved / impossible / its goal lapses.
|
|
134
|
-
*
|
|
135
|
-
|
|
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) {
|
|
136
206
|
if (observed.has(intention.key)) return "achieved"; // fact now gathered
|
|
137
|
-
if (mode === "scoped" && (!focus || focus.class !==
|
|
207
|
+
if (mode === "scoped" && (!focus || focus.class !== focusClass)) return "lapsed"; // focus moved
|
|
138
208
|
return null; // else keep the commitment
|
|
139
209
|
}
|
|
140
210
|
|
|
@@ -148,21 +218,32 @@ export function dropCondition(intention, observed, mode, focus) {
|
|
|
148
218
|
* ctx: { dispatch(name,input)->{ok,result}, resolve(term)->{match,ambiguous} }. */
|
|
149
219
|
export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" } = {}) {
|
|
150
220
|
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
221
|
|
|
154
|
-
// STEP 1 — deduce the goal scope from the DECLARED model + a bound focus
|
|
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).
|
|
155
225
|
const focus = focusOf(request, ctx);
|
|
156
|
-
|
|
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)
|
|
226
|
+
const mode = focus ? "scoped" : "global";
|
|
160
227
|
|
|
161
|
-
// The open-world goal-generation seam, named honestly: a resolved focus
|
|
162
|
-
// declared goal
|
|
163
|
-
if (
|
|
164
|
-
|
|
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);
|
|
165
245
|
}
|
|
246
|
+
const rule = applicable[0];
|
|
166
247
|
|
|
167
248
|
// the glass-box WHY, citing the declared goal-rule by backward-chain (the C2
|
|
168
249
|
// twin of resolver.mjs's "backward-chain => <capability>" provenance).
|
|
@@ -175,15 +256,22 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" }
|
|
|
175
256
|
const calls = [];
|
|
176
257
|
const observed = new Map(); // intention.key -> gathered result set
|
|
177
258
|
|
|
178
|
-
// STEP 2/3 — the pending INTENTIONS
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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 }));
|
|
187
275
|
|
|
188
276
|
let committed = null; // the persisted BDI intention (not re-derived each tick)
|
|
189
277
|
let expanded = false; // one-shot guard: the single bounded GDA expansion
|
|
@@ -197,7 +285,7 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" }
|
|
|
197
285
|
// (3b) PERSISTENCE — keep the committed intention unless a BDI drop condition
|
|
198
286
|
// fires; only THEN re-arbitrate. This is the "commitment, not recomputed
|
|
199
287
|
// preference" that stops the loop thrashing.
|
|
200
|
-
if (committed && dropCondition(committed, observed, mode, focus)) committed = null;
|
|
288
|
+
if (committed && dropCondition(committed, observed, mode, focus, rule.focusClass)) committed = null;
|
|
201
289
|
if (!committed || !pending.includes(committed)) {
|
|
202
290
|
// (3a) FIRST-STEP ARBITRATION — least-commitment: the lowest declared order
|
|
203
291
|
// among pending. Threat-aware: skip a step that would clobber another
|
|
@@ -220,17 +308,17 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" }
|
|
|
220
308
|
pending.splice(pending.indexOf(committed), 1);
|
|
221
309
|
committed = null;
|
|
222
310
|
|
|
223
|
-
// GDA EXPANSION (monitor -> replan), ONCE: on observing the
|
|
224
|
-
// global mode
|
|
225
|
-
// module, so arbitration can rank
|
|
226
|
-
//
|
|
227
|
-
// converges.
|
|
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.
|
|
228
316
|
let expandedThisTick = false;
|
|
229
|
-
if (mode === "global" && achievedTopic === rule.coverageTopic && !expanded) {
|
|
317
|
+
if (mode === "global" && rule.modes.includes("global") && achievedTopic === rule.coverageTopic && !expanded) {
|
|
230
318
|
expanded = true;
|
|
231
319
|
expandedThisTick = true;
|
|
232
|
-
const
|
|
233
|
-
|
|
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 }));
|
|
234
322
|
}
|
|
235
323
|
|
|
236
324
|
// the invariant, enforced mechanically: the pending set shrank by one this
|
|
@@ -245,21 +333,31 @@ export async function goalReason(request, tools, ctx, { driver = "goal-0.8.1" }
|
|
|
245
333
|
// facts (all INSIDE the driver's timeout guard; no unbounded post-work).
|
|
246
334
|
let composed;
|
|
247
335
|
if (mode === "scoped") {
|
|
248
|
-
// the
|
|
249
|
-
//
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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}`})`);
|
|
253
350
|
} else {
|
|
254
|
-
// KEYSTONE arbitration: among the coverage violations
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
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 }))
|
|
260
358
|
.sort((a, b) => b.weight - a.weight || String(a.m).localeCompare(String(b.m)));
|
|
261
359
|
composed = ranked.length ? [ranked[0].m] : [];
|
|
262
|
-
why.push(`keystone: argmax |
|
|
360
|
+
why.push(`keystone: argmax |${rule.priorityTopic}| over ${violating.length} ${rule.coverageTopic} module(s) => ${composed.length ? `${composed[0]} (weight ${ranked[0].weight})` : "∅"}`);
|
|
263
361
|
}
|
|
264
362
|
|
|
265
363
|
return { calls, refused: false, terminated: true, proof, why, composed, driver, observed: `goal(${mode}): ${calls.map((c) => c.name).join(" -> ")}` };
|
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())
|