@polycode-projects/the-mechanical-code-talker 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -3
- package/ROADMAP.md +412 -2
- package/bin/tmct.mjs +56 -1
- package/data/templates/grammar-rules.toml +97 -0
- package/data/templates/responses.jsonl +13 -0
- package/package.json +30 -2
- package/src/ask-nlp.mjs +8 -10
- package/src/ask-vocab.mjs +22 -0
- package/src/ask.mjs +170 -8
- package/src/chat.mjs +740 -53
- package/src/corpus/conceptnet.mjs +14 -2
- package/src/corpus/templates.mjs +94 -10
- package/src/finish.mjs +443 -0
- package/src/grammar/lexicon-core.json +8 -0
- package/src/hash.mjs +32 -0
- package/src/init.mjs +264 -0
- package/src/interpret/normalize.mjs +34 -0
- package/src/interpret/strategies/keywords.mjs +57 -1
- package/src/memory/blocks.mjs +23 -3
- package/src/memory/core.mjs +257 -16
- package/src/memory/fold.mjs +0 -0
- package/src/memory/inspect.mjs +141 -0
- package/src/memory/trust.mjs +113 -0
- package/src/prose-nlp.mjs +14 -16
- package/src/providers/bootstrap.mjs +24 -0
- package/src/providers/fixture.mjs +118 -0
- package/src/providers/graph-service.mjs +312 -0
- package/src/repository-interface.mjs +318 -0
- package/src/server.mjs +44 -28
- package/src/sessions.mjs +13 -2
- package/src/syllogise.mjs +0 -0
- package/src/toml-config.mjs +14 -0
- package/src/wink-model.mjs +74 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# grammar-rules.toml — the data-driven grammar-rule table (Phase 7, lever 2)
|
|
2
|
+
# (PLAN_RESPONSE_FINISHING.md, "The grammar pass (lever 2)").
|
|
3
|
+
#
|
|
4
|
+
# Each [[rule]] is a corrective grammar rule applied by applyGrammar() in
|
|
5
|
+
# src/finish.mjs over the PROSE spans of a segmented answer — NEVER the flat
|
|
6
|
+
# string, and NEVER a protected span (entity / path / number / code / provenance
|
|
7
|
+
# / receipt). The engine reads STRUCTURE, never guesses from surface: an article
|
|
8
|
+
# rule that would touch the word inside the following protected span refuses to
|
|
9
|
+
# fire, agreement reads the following number's value, and so on.
|
|
10
|
+
#
|
|
11
|
+
# CONTRACT (the plan's law): a rule's NEUTRAL behaviour is BYTE-STABLE. The only
|
|
12
|
+
# byte changes a rule may introduce are GENUINE fixes to defects tmct itself
|
|
13
|
+
# generates ("a artifact"). `kind` selects the built-in handler; `enabled=false`
|
|
14
|
+
# PARKS a rule out of the live answer path; the remaining keys are that handler's
|
|
15
|
+
# applicability conditions + parameters. Rules apply in file order; the set is
|
|
16
|
+
# chosen to commute so finish() is idempotent: finish(finish(x)) === finish(x).
|
|
17
|
+
#
|
|
18
|
+
# SEQUENCING (PLAN_RESPONSE_FINISHING.md, "one grammar rule per tuning cycle"):
|
|
19
|
+
# LIVE as of cycle 005: ARTICLE-SELECTION (cycle 4), plus the two SAFE defect
|
|
20
|
+
# fixes TERMINAL-PUNCTUATION and SUBJECT-VERB-AGREEMENT — each byte-stable when
|
|
21
|
+
# neutral, no product-voice change. CAPITALISATION and LIST are fully implemented
|
|
22
|
+
# and golden-tested IN ISOLATION but remain PARKED (enabled=false). The cycle-006
|
|
23
|
+
# judged A/B was assessed and DROPPED: `capitalise` rewrites the sentence-initial
|
|
24
|
+
# char, which regresses ~10 frozen v1 cases.jsonl whose case-sensitive answerMatch
|
|
25
|
+
# pins lowercase openers ("can't count", "no symbol matching", "assuming you meant",
|
|
26
|
+
# …) — the same sacred-case collision that reverted the voice-nit; and `list` has
|
|
27
|
+
# zero 3-item "X and Y and Z" targets in the judged set (a no-op). Deferred to a
|
|
28
|
+
# post-arc case-set refresh where the openers can be re-pinned deliberately. They
|
|
29
|
+
# rewrite established product bytes (tmct's
|
|
30
|
+
# lowercase openers and repeated "and" joins are an intentional VOICE, not a
|
|
31
|
+
# grammar defect), so activating them is a per-rule tuning-cycle decision with
|
|
32
|
+
# its own bench + showcase reconcile, not a blanket flip. `enabled=false` keeps
|
|
33
|
+
# them inert in finish(); the goldens force-enable each rule to prove its
|
|
34
|
+
# behaviour independent of the live flag.
|
|
35
|
+
|
|
36
|
+
# 1. Article selection — a/an by the following word's phonetic onset. The live
|
|
37
|
+
# defect this fixes: the assert echo "every module is a artifact" -> "an
|
|
38
|
+
# artifact". Reads the next word (whether in-span or the leading token of the
|
|
39
|
+
# following protected span); refuses at a boundary it cannot read safely.
|
|
40
|
+
[[rule]]
|
|
41
|
+
id = "article-selection"
|
|
42
|
+
kind = "article"
|
|
43
|
+
enabled = true
|
|
44
|
+
registers = [] # [] = every register
|
|
45
|
+
description = "a/an agreement with the following word's phonetic onset"
|
|
46
|
+
# Spelling-vowel words that begin with a CONSONANT sound (take 'a').
|
|
47
|
+
consonant_sound_vowels = ["uni", "use", "user", "usa", "usu", "ubi", "eu", "ewe", "one", "once"]
|
|
48
|
+
# Spelling-consonant words that begin with a VOWEL sound (take 'an'): silent h.
|
|
49
|
+
vowel_sound_consonants = ["hour", "honest", "honour", "honor", "heir", "herb"]
|
|
50
|
+
|
|
51
|
+
# 2. Subject–verb agreement — an existential copula agrees with the count that
|
|
52
|
+
# follows it ("there is 3 classes" -> "there are 3 classes"; "there are 1
|
|
53
|
+
# class" -> "there is 1 class"). Structure-driven: the plurality is READ from
|
|
54
|
+
# the following number span's value (or a protected span's explicit `plural`
|
|
55
|
+
# flag), never guessed. Neutral on already-correct agreement.
|
|
56
|
+
[[rule]]
|
|
57
|
+
id = "subject-verb-agreement"
|
|
58
|
+
kind = "agreement"
|
|
59
|
+
enabled = true # LIVE (cycle 005) — structure-driven existential agreement, byte-stable when neutral
|
|
60
|
+
registers = []
|
|
61
|
+
description = "existential copula agrees with the following count/plurality"
|
|
62
|
+
singular = ["is", "was", "has"]
|
|
63
|
+
plural = ["are", "were", "have"]
|
|
64
|
+
|
|
65
|
+
# 3. Sentence capitalisation — capitalise the first alphabetic character of a
|
|
66
|
+
# sentence-initial prose span. Never fires when the answer opens on a
|
|
67
|
+
# protected span (a path/entity opener is left exactly as grounded).
|
|
68
|
+
[[rule]]
|
|
69
|
+
id = "sentence-capitalisation"
|
|
70
|
+
kind = "capitalise"
|
|
71
|
+
enabled = false # PARKED — implemented + golden-tested, not live this cycle
|
|
72
|
+
registers = []
|
|
73
|
+
description = "capitalise the first alphabetic of a prose-initial span"
|
|
74
|
+
|
|
75
|
+
# 4. List punctuation — a series joined by repeated " and " connectives becomes
|
|
76
|
+
# a comma series with a single terminal conjunction ("a and b and c" -> "a, b
|
|
77
|
+
# and c"). Operates ONLY on the prose connective spans, never the entity spans
|
|
78
|
+
# they join; a two-item list ("a and b") is already correct and untouched.
|
|
79
|
+
[[rule]]
|
|
80
|
+
id = "list-punctuation"
|
|
81
|
+
kind = "list"
|
|
82
|
+
enabled = false # PARKED — implemented + golden-tested, not live this cycle
|
|
83
|
+
registers = []
|
|
84
|
+
description = "repeated 'and' joins in a 3+ item series become a comma series"
|
|
85
|
+
connective = " and "
|
|
86
|
+
separator = ", "
|
|
87
|
+
|
|
88
|
+
# 5. Terminal punctuation — exactly one sentence-final stop: a run of 2+ trailing
|
|
89
|
+
# stops in the final prose span collapses to one ("done.." -> "done."). Adds
|
|
90
|
+
# nothing where a fragment/list answer legitimately ends without a stop.
|
|
91
|
+
[[rule]]
|
|
92
|
+
id = "terminal-punctuation"
|
|
93
|
+
kind = "terminal"
|
|
94
|
+
enabled = true # LIVE (cycle 005) — pure defect fix, narrowest blast radius (trailing doubled stop)
|
|
95
|
+
registers = []
|
|
96
|
+
description = "collapse a run of trailing sentence stops to a single stop"
|
|
97
|
+
stops = [".", "!", "?"]
|
|
@@ -53,3 +53,16 @@
|
|
|
53
53
|
{"id":"nudge-precision","class":"nudge","register":"friendly","template":"The closer you get to a shape like \"{example}\", the sharper my answer gets."}
|
|
54
54
|
{"id":"nudge-commands","class":"nudge","register":"friendly","template":"If prose fails you, the slash commands always work — try {command}."}
|
|
55
55
|
{"id":"nudge-narrower","class":"nudge","register":"friendly","template":"That matched {count} things — too many to be useful. Narrow it with a module or class name."}
|
|
56
|
+
{"id":"conversational-greeting","class":"conversational","register":"friendly","template":"Hi. Ask me about this codebase — imports, calls, definitions, history — or /help."}
|
|
57
|
+
{"id":"conversational-greeting-hello-there","class":"conversational","register":"friendly","template":"Hello there. (A hollow voice says, \"fool.\") Ask me about this codebase, or /help."}
|
|
58
|
+
{"id":"conversational-greeting-good-morning","class":"conversational","register":"friendly","template":"Good morning. Ask me about this codebase, or /help."}
|
|
59
|
+
{"id":"conversational-greeting-good-afternoon","class":"conversational","register":"friendly","template":"Good afternoon. Ask me about this codebase, or /help."}
|
|
60
|
+
{"id":"conversational-greeting-good-evening","class":"conversational","register":"friendly","template":"Good evening. Ask me about this codebase, or /help."}
|
|
61
|
+
{"id":"conversational-thanks","class":"conversational","register":"friendly","template":"Any time. Ask another, or /help for what I can do."}
|
|
62
|
+
{"id":"conversational-farewell","class":"conversational","register":"friendly","template":"Bye — flushing the session log. Come back with a question any time."}
|
|
63
|
+
{"id":"orientation-friendly","class":"orientation","register":"friendly","template":"I answer questions about THIS codebase's structure — imports, calls, definitions,\nhistory and counts. For example:\n which modules import walk.mjs\n what calls buildContextBundle\n how many classes are there\n/help for commands, /stats for an overview of the graph."}
|
|
64
|
+
{"id":"miss-no-previous-answer","class":"miss","register":"friendly","template":"No previous answer to expand yet — ask me a question first, then say \"why\" or \"say more\"."}
|
|
65
|
+
{"id":"technical-density","class":"count","register":"technical","template":"{subject} carries {count} {noun} across {scope} — a concentration well above what a codebase of this size typically sustains ({provenance})."}
|
|
66
|
+
{"id":"technical-comparison","class":"count","register":"technical","template":"At {count} {noun}, {subject} sits {comparison} the comparable-project baseline, a divergence that reflects deliberate structure rather than measurement noise ({provenance})."}
|
|
67
|
+
{"id":"technical-superlative","class":"count","register":"technical","template":"No {noun} in {scope} is more {metric} than {subject}; it leads the next candidate by a clear margin of {count} ({provenance})."}
|
|
68
|
+
{"id":"technical-ratio","class":"count","register":"technical","template":"{subject} sustains a ratio of {count} {noun} per {unit}, placing it in the upper band for projects of comparable {scope} ({provenance})."}
|
package/package.json
CHANGED
|
@@ -1,12 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"chatbot",
|
|
9
|
+
"no-llm",
|
|
10
|
+
"offline",
|
|
11
|
+
"deterministic",
|
|
12
|
+
"eliza",
|
|
13
|
+
"parry",
|
|
14
|
+
"nlp",
|
|
15
|
+
"wink-nlp",
|
|
16
|
+
"owl",
|
|
17
|
+
"rdf",
|
|
18
|
+
"ontology",
|
|
19
|
+
"controlled-natural-language",
|
|
20
|
+
"ace",
|
|
21
|
+
"knowledge-graph",
|
|
22
|
+
"provenance",
|
|
23
|
+
"code-navigation",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
7
26
|
"license": "MPL-2.0",
|
|
8
27
|
"author": "Polycode Limited",
|
|
9
28
|
"homepage": "https://polycode-projects.gitlab.io/the-mechanical-code-talker/",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://gitlab.com/polycode-projects/the-mechanical-code-talker.git"
|
|
32
|
+
},
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://gitlab.com/polycode-projects/the-mechanical-code-talker/-/issues"
|
|
35
|
+
},
|
|
10
36
|
"engines": {
|
|
11
37
|
"node": ">=24"
|
|
12
38
|
},
|
|
@@ -47,7 +73,9 @@
|
|
|
47
73
|
"test": "node --test \"test/**/*.test.mjs\"",
|
|
48
74
|
"chat": "node bin/tmct.mjs",
|
|
49
75
|
"chatbench:run": "node chatbench/run.mjs",
|
|
50
|
-
"chatbench:judge": "node chatbench/judge.mjs"
|
|
76
|
+
"chatbench:judge": "node chatbench/judge.mjs",
|
|
77
|
+
"audit": "npm audit --audit-level=high",
|
|
78
|
+
"audit:fix": "npm audit fix"
|
|
51
79
|
},
|
|
52
80
|
"devDependencies": {
|
|
53
81
|
"ink-testing-library": "^4.0.0"
|
package/src/ask-nlp.mjs
CHANGED
|
@@ -9,13 +9,13 @@
|
|
|
9
9
|
// the bounded edit-distance tier still work, browser and Node alike). Keeping the
|
|
10
10
|
// ~1MB CJS model out of the page is the point of the split.
|
|
11
11
|
//
|
|
12
|
-
// wink-nlp and wink-eng-lite-web-model are
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// first use and failure is cached as null: a checkout without the optional
|
|
16
|
-
// installed answers exactly like the browser bundle, it never throws.
|
|
12
|
+
// wink-nlp and wink-eng-lite-web-model are loaded through the shared leaf loader
|
|
13
|
+
// src/wink-model.mjs (Node `createRequire` fallback + a browser registration seam),
|
|
14
|
+
// so this file no longer carries its own Node-only load block. The load happens
|
|
15
|
+
// lazily on first use and failure is cached as null: a checkout without the optional
|
|
16
|
+
// deps installed answers exactly like the browser bundle, it never throws.
|
|
17
17
|
|
|
18
|
-
import {
|
|
18
|
+
import { winkInstance } from "./wink-model.mjs";
|
|
19
19
|
|
|
20
20
|
let cached; // undefined = not tried yet; null = unavailable (tried once, honestly off)
|
|
21
21
|
|
|
@@ -25,10 +25,8 @@ let cached; // undefined = not tried yet; null = unavailable (tried once, honest
|
|
|
25
25
|
export function nlpAdapter() {
|
|
26
26
|
if (cached !== undefined) return cached;
|
|
27
27
|
try {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const model = require("wink-eng-lite-web-model");
|
|
31
|
-
const nlp = winkNLP(model);
|
|
28
|
+
const nlp = winkInstance();
|
|
29
|
+
if (!nlp) { cached = null; return cached; }
|
|
32
30
|
const its = nlp.its;
|
|
33
31
|
cached = {
|
|
34
32
|
/** Lowercase lemma of a single token ("imported" -> "import"); the word
|
package/src/ask-vocab.mjs
CHANGED
|
@@ -220,6 +220,28 @@ export const MODIFIER_TO_KIND = Object.freeze({
|
|
|
220
220
|
transitively: "transitive", indirectly: "transitive",
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
// ---- reversible-passive participles (Cycle 6, PLAN_CYCLE_4.md) — past participles ->
|
|
224
|
+
// relation kind, for the agent-marked passive "X is <participle> by Y". Kept SEPARATE
|
|
225
|
+
// from VERB_TO_KIND on purpose: these forms are NOT standalone active verbs in this
|
|
226
|
+
// grammar ("defined" belongs to the multi-word "is defined in" and to the WHERE_MARKERS
|
|
227
|
+
// location routing; bare "inherited" has no active key), so folding them into
|
|
228
|
+
// VERB_TO_KIND would silently re-route "where is X defined" and other queries. This
|
|
229
|
+
// table is consulted ONLY by the keyword strategy's passive path, which has already
|
|
230
|
+
// confirmed a passive auxiliary AND an agent-marking "by" — so an active query is never
|
|
231
|
+
// affected. Most common participles ("imported"/"tested"/"called"/"covered") already
|
|
232
|
+
// reach VERB_TO_KIND via the lemma tier; this table backfills the two families the lemma
|
|
233
|
+
// tier can't (defines/inherits) plus the obvious siblings, so the passive works
|
|
234
|
+
// adapter-free too. ----
|
|
235
|
+
export const PASSIVE_PARTICIPLE_TO_KIND = Object.freeze({
|
|
236
|
+
imported: "imports", called: "calls", used: "uses",
|
|
237
|
+
tested: "tests", covered: "tests", verified: "tests", exercised: "tests", checked: "tests",
|
|
238
|
+
defined: "defines", declared: "defines",
|
|
239
|
+
inherited: "inherits", extended: "inherits", subclassed: "inherits",
|
|
240
|
+
contained: "contains",
|
|
241
|
+
exported: "reexports", "re-exported": "reexports", exposed: "reexports",
|
|
242
|
+
touched: "touches", changed: "touches", modified: "touches", edited: "touches", updated: "touches",
|
|
243
|
+
});
|
|
244
|
+
|
|
223
245
|
// ---- §3.5 normalization — contractions/informal spellings that would otherwise
|
|
224
246
|
// block a match, expanded BEFORE parsing (BOTH the anchored-template strategy
|
|
225
247
|
// and the independent keyword-spotting strategy see the same normalized text —
|
package/src/ask.mjs
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
// grammar, split out of this file: normalization pre-pass, the two parsing
|
|
52
52
|
// strategies, and the bounded-fuzzy service. Re-exported below where existing
|
|
53
53
|
// callers/tests import them from here.
|
|
54
|
-
import { normalizeQuery, applyNegationFrames, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
|
|
54
|
+
import { normalizeQuery, applyNegationFrames, matchNegationSet, STOPWORDS, splitWords, wordsOf } from "./interpret/normalize.mjs";
|
|
55
55
|
import { editDistance, fuzzyBound } from "./interpret/fuzzy.mjs";
|
|
56
56
|
import { parseAnchored } from "./interpret/strategies/grammar.mjs";
|
|
57
57
|
import { parseKeywordSpot, findPhrase } from "./interpret/strategies/keywords.mjs";
|
|
@@ -244,7 +244,9 @@ function parseSimpleClause(text, nlp) {
|
|
|
244
244
|
function parseComposite(text, nlp) {
|
|
245
245
|
const w = splitWords(text);
|
|
246
246
|
const lc = w.map((x) => x.toLowerCase());
|
|
247
|
-
return
|
|
247
|
+
return parseNegation(text, nlp, 0)
|
|
248
|
+
|| parseForwardNegation(w, lc, nlp)
|
|
249
|
+
|| parseAnaphora(w, lc, nlp)
|
|
248
250
|
|| parseAggregate(w, lc, nlp)
|
|
249
251
|
|| parseSuperlative(w, lc, nlp)
|
|
250
252
|
|| parseList(w, lc, nlp, 0)
|
|
@@ -252,11 +254,129 @@ function parseComposite(text, nlp) {
|
|
|
252
254
|
|| parseRelationalOrQualified(w, lc, nlp, 0);
|
|
253
255
|
}
|
|
254
256
|
|
|
257
|
+
// B1 NEGATION (Cycle 5, PLAN_CYCLE_4.md) — the SET COMPLEMENT. "which X do not <verb>
|
|
258
|
+
// Y" / "X that don't <verb> Y" / "modules not importing Y" / "which X are not
|
|
259
|
+
// <qualifier>" compiles to allOfClass(kind) DIFFERENCE (the positive result set),
|
|
260
|
+
// reusing the EXISTING machinery: evalBoolean already folds a "difference" atom, and
|
|
261
|
+
// the allOfClass node is a ready-made bounded universe of a kind. The only new work is
|
|
262
|
+
// recognizing the negation marker (matchNegationSet, normalize.mjs) and assembling the
|
|
263
|
+
// boolean-difference AST — no new traversal primitive. Regression guards, all tested:
|
|
264
|
+
// (1) honest-empty stays honest — an EMPTY complement ("which functions are not
|
|
265
|
+
// exported", where the only function is exported) renders the standard honest
|
|
266
|
+
// "nothing matches" miss, never invents a member and never re-trips the literal-
|
|
267
|
+
// 'not' trap (the "not" is consumed here, so it can't leak into an object term);
|
|
268
|
+
// (2) BOUNDED UNIVERSE only — the universe is the queried kind within the loaded
|
|
269
|
+
// graph; the "Change" pseudo-type (ask-vocab.mjs) is a wildcard, not a stored
|
|
270
|
+
// enumerable class, so a complement over "changes" is REFUSED honestly rather
|
|
271
|
+
// than answered over an empty universe;
|
|
272
|
+
// (3) active-voice/positive queries are untouched — parseNegation returns null unless
|
|
273
|
+
// matchNegationSet finds an explicit set-negation marker.
|
|
274
|
+
function complementAst(entityType, diffAtom) {
|
|
275
|
+
return {
|
|
276
|
+
node: "boolean",
|
|
277
|
+
entityType,
|
|
278
|
+
atoms: [
|
|
279
|
+
{ op: "seed", kind: "set", ast: { node: "allOfClass", entityType } },
|
|
280
|
+
diffAtom,
|
|
281
|
+
],
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseNegation(text, nlp, depth = 0) {
|
|
286
|
+
const neg = matchNegationSet(text);
|
|
287
|
+
if (!neg) return null; // no set-negation marker → not this shape
|
|
288
|
+
const noun = entityNoun(neg.entWord);
|
|
289
|
+
// a set complement needs a CONCRETE, enumerable kind. A placeholder ("things") has no
|
|
290
|
+
// bounded universe; the "Change" pseudo-type is a wildcard over the touch traversal,
|
|
291
|
+
// never a stored class, so its complement is ill-defined and must be refused honestly.
|
|
292
|
+
if (!noun || noun.placeholder || !noun.entityType) return null;
|
|
293
|
+
const entityType = noun.entityType;
|
|
294
|
+
if (entityType === "Change") {
|
|
295
|
+
return { node: "miss", reason: `"${neg.entWord}" isn't an enumerable kind — a set complement needs a concrete kind (functions, classes, modules, …)` };
|
|
296
|
+
}
|
|
297
|
+
const predWords = splitWords(neg.predicate);
|
|
298
|
+
const predLc = predWords.map((x) => x.toLowerCase());
|
|
299
|
+
// (a) qualifier negation ("not tested" / "not exported"): difference the qualifier
|
|
300
|
+
// set off the class — equivalent to the negated qualifier, an honest empty when none.
|
|
301
|
+
if (predLc.length && predLc.every((x) => QUALIFIERS[x])) {
|
|
302
|
+
return complementAst(entityType, { op: "difference", kind: "qual", filters: predLc });
|
|
303
|
+
}
|
|
304
|
+
const vh = findPhrase(predLc, VERB_TO_KIND);
|
|
305
|
+
if (!vh) return { node: "miss", reason: "a negated set query needs a known relation verb (import, call, inherit from, test, …)" };
|
|
306
|
+
const objWords = predWords.filter((_, i) => (i < vh.start || i >= vh.end) && !STOPWORDS.has(predLc[i]) && predLc[i] !== "from");
|
|
307
|
+
// (b) existential object ("do not import anything" / "define nothing"): the complement
|
|
308
|
+
// is the class MINUS the subjects that have ANY edge of this kind.
|
|
309
|
+
if (!objWords.length) {
|
|
310
|
+
return complementAst(entityType, { op: "difference", kind: "set", ast: { node: "existsEdge", entityType, kind: vh.kind } });
|
|
311
|
+
}
|
|
312
|
+
// (c) concrete object ("do not import a.mjs"): the class MINUS the POSITIVE result
|
|
313
|
+
// set, parsed through the existing clause/relational machinery (never re-negating —
|
|
314
|
+
// the reconstructed positive text carries no "not").
|
|
315
|
+
const positive = parseSetPhrase(`which ${neg.entWord} ${neg.predicate}`, nlp, depth + 1);
|
|
316
|
+
if (!positive || positive.node === "miss") {
|
|
317
|
+
return { node: "miss", reason: (positive && positive.reason) || "the negated clause didn't parse" };
|
|
318
|
+
}
|
|
319
|
+
return complementAst(entityType, { op: "difference", kind: "set", ast: positive });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// B1 FORWARD NEGATION (Cycle 5, pron+neg) — the SUBJECT-side complement's mirror: "what
|
|
323
|
+
// does[n't] <subj> <verb>" ("what doesn't it import", "what does app/lib/e.mjs not import")
|
|
324
|
+
// is every individual of the verb's OBJECT grain that <subj> does NOT reach via that verb.
|
|
325
|
+
// Distinct from parseNegation (which negates a queried KIND — "which modules do not import
|
|
326
|
+
// X"): here the negation sits on a FORWARD clause whose subject is a named term or a focus
|
|
327
|
+
// pronoun, so the universe is inferred from the verb's own edges (imports → Module) rather
|
|
328
|
+
// than a stated kind noun. The subject is resolved LATE (at eval, through the same
|
|
329
|
+
// contextId a plain "it" uses), so pronoun-binding composes with the complement for free.
|
|
330
|
+
// Refused honestly (empty) when the verb's object grain is ambiguous or the subject can't
|
|
331
|
+
// resolve — never a guess. Runs AFTER parseNegation, so the stated-kind form is unaffected.
|
|
332
|
+
const FWD_NEG_FRAME = new Set(["what", "which", "thing", "things", "one", "ones", "stuff"]);
|
|
333
|
+
function parseForwardNegation(w, lc, nlp) {
|
|
334
|
+
let i = 0;
|
|
335
|
+
while (i < lc.length && FWD_NEG_FRAME.has(lc[i])) i += 1;
|
|
336
|
+
if (!["do", "does", "did"].includes(lc[i])) return null; // need the auxiliary lead
|
|
337
|
+
i += 1;
|
|
338
|
+
const rest = w.slice(i);
|
|
339
|
+
const restLc = lc.slice(i);
|
|
340
|
+
const notIdx = restLc.indexOf("not");
|
|
341
|
+
if (notIdx < 0) return null; // no negation → not this shape
|
|
342
|
+
const vh = findPhrase(restLc, VERB_TO_KIND);
|
|
343
|
+
if (!vh) return null; // no relation verb → not this shape
|
|
344
|
+
// the subject term is whatever survives after removing "not", the verb phrase, "from",
|
|
345
|
+
// and question scaffolding — a bare pronoun "it" (not a stopword) survives and binds to
|
|
346
|
+
// the focus at eval time; a named module/symbol survives and resolves directly.
|
|
347
|
+
const subjTokens = rest.filter((_, j) => j !== notIdx && (j < vh.start || j >= vh.end)
|
|
348
|
+
&& restLc[j] !== "from" && !STOPWORDS.has(restLc[j]));
|
|
349
|
+
const subjectTerm = subjTokens.join(" ").trim();
|
|
350
|
+
if (!subjectTerm) return null;
|
|
351
|
+
return { node: "forwardComplement", kind: vh.kind, subjectTerm };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The single OBJECT class a forward relation kind points at across the loaded graph
|
|
355
|
+
* (imports → Module), or null when its objects span more than one class (an ambiguous
|
|
356
|
+
* grain the complement's universe can't be pinned to). Ext: endpoints have no individual,
|
|
357
|
+
* so they don't muddy the class vote. Used by the forwardComplement evaluator to bound
|
|
358
|
+
* the universe it differences the positive forward set out of. */
|
|
359
|
+
function kindObjectClass(graph, kind) {
|
|
360
|
+
const classes = new Set();
|
|
361
|
+
for (const k of kindsFor(kind)) {
|
|
362
|
+
for (const e of edgesOfKind(graph, k)) {
|
|
363
|
+
const o = graph.byId.get(e.object);
|
|
364
|
+
if (o && o.class) classes.add(o.class);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return classes.size === 1 ? [...classes][0] : null;
|
|
368
|
+
}
|
|
369
|
+
|
|
255
370
|
/** A set-producing sub-expression (used for nested inner clauses, boolean branches,
|
|
256
371
|
* and count restrictors): nested first, then the relational/qualifier/boolean
|
|
257
372
|
* parser, then a bare simple clause. Carries `depth` for the nesting cap. */
|
|
258
373
|
function parseSetPhrase(text, nlp, depth) {
|
|
259
374
|
if (depth > MAX_COMPOSE_DEPTH) return { node: "miss", reason: "too deep to resolve" };
|
|
375
|
+
// a set-negation clause can appear as a count restrictor ("how many classes are not
|
|
376
|
+
// tested"), a list filter, or a boolean branch — try the complement frame first so
|
|
377
|
+
// those compositions get the bounded-complement for free.
|
|
378
|
+
const negated = parseNegation(text, nlp, depth);
|
|
379
|
+
if (negated) return negated;
|
|
260
380
|
const w = splitWords(text);
|
|
261
381
|
const lc = w.map((x) => x.toLowerCase());
|
|
262
382
|
const nested = parseNested(w, lc, nlp, depth);
|
|
@@ -302,12 +422,21 @@ function parseNested(w, lc, nlp, depth) {
|
|
|
302
422
|
* uncompilable), or null. */
|
|
303
423
|
function parseAnaphora(w, lc, nlp) {
|
|
304
424
|
let p = -1;
|
|
425
|
+
let viaOf = false;
|
|
305
426
|
for (let i = 1; i < lc.length; i += 1) {
|
|
306
|
-
if (ANAPHORA_TRIGGERS.includes(lc[i])
|
|
427
|
+
if (!ANAPHORA_TRIGGERS.includes(lc[i])) continue;
|
|
428
|
+
if (lc[i - 1] === "of") { p = i; viaOf = true; break; } // "how many of those", "which of them"
|
|
429
|
+
// BARE anaphoric pronoun as the FINAL word, directly after a count/list trigger
|
|
430
|
+
// ("count them", "count those", "list them") — the discourse-reference count/list over
|
|
431
|
+
// the previous answer with no "of" (Cycle 5, disc+count). Pinned to the terminal
|
|
432
|
+
// position so a mid-sentence "these"/"those" used as a determiner ("list these
|
|
433
|
+
// functions") is left for the ordinary list/clause path, not seized as an anaphor.
|
|
434
|
+
const headSoFar = lc.slice(0, i).join(" ");
|
|
435
|
+
if (i === lc.length - 1 && (AGGREGATE_TRIGGERS.includes(headSoFar) || LIST_TRIGGERS.includes(headSoFar))) { p = i; break; }
|
|
307
436
|
}
|
|
308
437
|
if (p < 0) return null;
|
|
309
|
-
const head = lc.slice(0, p - 1).join(" ");
|
|
310
|
-
const mode = /^(how many|how much|count)\b/.test(head) ? "count" : "list";
|
|
438
|
+
const head = (viaOf ? lc.slice(0, p - 1) : lc.slice(0, p)).join(" ");
|
|
439
|
+
const mode = AGGREGATE_TRIGGERS.includes(head) || /^(how many|how much|count|number|quantity|total)\b/.test(head) ? "count" : "list";
|
|
311
440
|
const filter = parsePredicateFilter(w.slice(p + 1), nlp);
|
|
312
441
|
if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
|
|
313
442
|
return { node: "anaphora", mode, filter };
|
|
@@ -694,6 +823,23 @@ function evalSet(graph, ast, opts) {
|
|
|
694
823
|
switch (ast.node) {
|
|
695
824
|
case "clause": return traverse(graph, ast.clause, opts).matches || [];
|
|
696
825
|
case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
|
|
826
|
+
// the SUBJECTS that have ANY edge of a kind (the existential "modules that import
|
|
827
|
+
// anything") — the positive set an existential negation ("do not import anything")
|
|
828
|
+
// differences off allOfClass to yield "modules that import nothing".
|
|
829
|
+
case "existsEdge": {
|
|
830
|
+
const subs = new Set(kindsFor(ast.kind).flatMap((k) => edgesOfKind(graph, k)).map((e) => e.subject));
|
|
831
|
+
return graph.individuals.filter((i) => subs.has(i.id) && (!ast.entityType || i.class === ast.entityType));
|
|
832
|
+
}
|
|
833
|
+
// forward complement: the verb's object-grain universe MINUS what the (late-resolved,
|
|
834
|
+
// focus-bindable) subject reaches via that verb — "what doesn't it import".
|
|
835
|
+
case "forwardComplement": {
|
|
836
|
+
const r = resolveTermOrContext(graph, ast.subjectTerm, opts && opts.contextId);
|
|
837
|
+
if (!r.match) return []; // unresolved subject / focus-less pronoun → honest empty
|
|
838
|
+
const universeType = kindObjectClass(graph, ast.kind);
|
|
839
|
+
if (!universeType) return []; // ambiguous object grain → refuse honestly
|
|
840
|
+
const positive = new Set(forwardOverSet(graph, ast.kind, new Set([r.match.id])).map((x) => x.id));
|
|
841
|
+
return graph.individuals.filter((i) => i.class === universeType && !positive.has(i.id));
|
|
842
|
+
}
|
|
697
843
|
case "reverseSet": {
|
|
698
844
|
const ids = new Set(evalSet(graph, ast.inner, opts).map((i) => i.id));
|
|
699
845
|
return reverseOverSet(graph, ast.kind, ast.entityType, ids);
|
|
@@ -1323,11 +1469,23 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
|
|
|
1323
1469
|
};
|
|
1324
1470
|
}
|
|
1325
1471
|
|
|
1326
|
-
// reverse: "which <entityType> R <objMatch>"
|
|
1472
|
+
// reverse: "which <entityType> R <objMatch>". GRAIN-AWARE (Cycle 5, lever 3): a kind
|
|
1473
|
+
// that carries a symbol-grain sibling reads off the SIBLING when a fine SUBJECT grain
|
|
1474
|
+
// was asked for ("which functions call X" → callsSymbol). Additionally, for `touches`
|
|
1475
|
+
// specifically, when the RESOLVED OBJECT is itself a fine symbol the answer MUST read off
|
|
1476
|
+
// touchesSymbol: touches is Commit→Module (module-coarse) and can NEVER point at a
|
|
1477
|
+
// symbol, so "how many commits touched Widget.render" used to scan the module-grain
|
|
1478
|
+
// edges and return a false 0 — the count belongs at symbol grain. This object-driven
|
|
1479
|
+
// switch is scoped to touches on purpose: calls/callsSymbol already resolve a fine
|
|
1480
|
+
// object through the fine-entityType branch, and a null-entityType "what calls <fn>"
|
|
1481
|
+
// deliberately keeps its module-coarse `calls` receipt (the honest-empty showcase pins
|
|
1482
|
+
// it), so widening the switch to calls would silently change that answer.
|
|
1327
1483
|
const symbolKind = SYMBOL_GRAIN_SIBLING[kind];
|
|
1328
|
-
|
|
1484
|
+
const objIsTouchedSymbol = kind === "touches" && !!(objMatch.class && FINE_ENTITY_TYPES.has(objMatch.class));
|
|
1485
|
+
if (symbolKind && (FINE_ENTITY_TYPES.has(entityType) || objIsTouchedSymbol)) {
|
|
1329
1486
|
const edges = edgesOfKind(graph, symbolKind).filter((e) => e.object === objMatch.id);
|
|
1330
|
-
const
|
|
1487
|
+
const subjects = uniqueById(edges.map((e) => graph.byId.get(e.subject)).filter(Boolean));
|
|
1488
|
+
const matches = (!entityType || entityType === "Change") ? subjects : subjects.filter((i) => i.class === entityType);
|
|
1331
1489
|
return { matches, objMatch, candidates, traversal: `${symbolKind} edges where object = ${objMatch.label}`, ambiguous, matchedVia };
|
|
1332
1490
|
}
|
|
1333
1491
|
|
|
@@ -1584,6 +1742,10 @@ function renderCore(parsed, result) {
|
|
|
1584
1742
|
miss: true, ambiguous: false,
|
|
1585
1743
|
};
|
|
1586
1744
|
}
|
|
1745
|
+
// NOTE (Cycle 5): a voice-nit rephrasing ("that directly <verb>") was reverted —
|
|
1746
|
+
// the frozen v1 cases.jsonl pins the "whose module directly <verb>s X" wording
|
|
1747
|
+
// (hm-empty-result-calls / tf-wat-calls / ns-wondering), and the case set is
|
|
1748
|
+
// append-only/sacred mid-arc, so the honest-miss phrasing stays as-is.
|
|
1587
1749
|
const entityWord = nounFor(parsed.entityType || "Module", 2);
|
|
1588
1750
|
return {
|
|
1589
1751
|
content: `No ${entityWord} found whose module directly ${verbFor(parsed.kind)} ${parsed.object}. (traversal: ${result.traversal || "no traversal resolved"})`,
|