@polycode-projects/the-mechanical-code-talker 1.4.1 → 1.5.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/README.md +1 -1
- package/ROADMAP.md +76 -11
- package/corpus/README.md +23 -22
- package/corpus/seon/README.md +7 -6
- package/corpus/seon/concepts.jsonl +119 -0
- package/corpus/tier2/general.jsonl +49 -0
- package/corpus/tier2/generate.mjs +68 -0
- package/corpus/tier2/manifest.json +14 -0
- package/data/templates/constructions/agent-noun-relations.toml +98 -0
- package/data/templates/responses.jsonl +1 -0
- package/package.json +5 -1
- package/src/ask-vocab.mjs +39 -1
- package/src/ask.mjs +278 -32
- package/src/chat.mjs +681 -212
- package/src/completions/complete.mjs +138 -0
- package/src/completions/group.mjs +171 -0
- package/src/completions/infer.mjs +395 -0
- package/src/completions/prune.mjs +156 -0
- package/src/completions/rank.mjs +154 -0
- package/src/completions/search.mjs +85 -0
- package/src/corpus/conceptnet.mjs +36 -3
- package/src/corpus/unknown-ingest.mjs +209 -0
- package/src/extensions.mjs +14 -4
- package/src/finish.mjs +61 -18
- package/src/grammar/ace.mjs +24 -338
- package/src/grammar/lexicon.mjs +37 -194
- package/src/interpret/pipeline.mjs +23 -2
- package/src/interpret/strategies/constructions.mjs +207 -0
- package/src/interpret/strategies/grammar.mjs +24 -3
- package/src/interpret/strategies/keywords.mjs +34 -0
- package/src/memory/blocks.mjs +7 -2
- package/src/memory/core.mjs +283 -20
- package/src/memory/shacl.mjs +114 -0
- package/src/prose.mjs +5 -1
- package/src/syllogise.mjs +0 -0
- package/src/grammar/lexicon-core.json +0 -302
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# data/templates/constructions/agent-noun-relations.toml — construction-grammar
|
|
2
|
+
# template bank (PLAN_ADVANCED_GRAMMAR.md track (d)): per-construction closed
|
|
3
|
+
# template families as DATA, loaded by src/interpret/strategies/constructions.mjs
|
|
4
|
+
# beside grammar.mjs's anchored T1-T10 grammar (this file's constructions are the
|
|
5
|
+
# next numbers, T11-T13 — see the `id` field on each [[construction]] below; do
|
|
6
|
+
# not renumber grammar.mjs's own T1-T10, this is an ADDITIVE, own-class strategy).
|
|
7
|
+
#
|
|
8
|
+
# The linguistic point (Construction Grammar, Goldberg 1995/2006): the SAME
|
|
9
|
+
# underlying relation ("things that import X") is realized by a developer through
|
|
10
|
+
# several distinct surface CONSTRUCTIONS — a prepositional NP ("importers of X"),
|
|
11
|
+
# a genitive-'s NP ("X's importers"), and a bare compound-juxtaposition NP ("X
|
|
12
|
+
# importers") — each its own grammatical FORM pairing with the same MEANING. This
|
|
13
|
+
# is a genuine gap, not a duplicate of grammar.mjs's T1-T10 or normalize.mjs's
|
|
14
|
+
# PHRASING_FRAMES: the "of"-NP form already reaches the graph correctly via the
|
|
15
|
+
# keyword-spot strategy's decomposition (interpret/strategies/keywords.mjs), but
|
|
16
|
+
# the genitive/compound forms do NOT — keyword-spot mis-parses "store.mjs's
|
|
17
|
+
# importers" / "store.mjs importers" as shape "forward" (reading store.mjs as the
|
|
18
|
+
# grammatical SUBJECT doing the importing) instead of "reverse" (store.mjs is the
|
|
19
|
+
# OBJECT being imported; the agent noun names WHO does the verb TO it) — confirmed
|
|
20
|
+
# live before this file existed. These three constructions fix that, and route
|
|
21
|
+
# the "of" form through the same anchored, closed table for consistency.
|
|
22
|
+
#
|
|
23
|
+
# Two [[relation]] tables define the closed AGENT-NOUN vocabulary (a plural
|
|
24
|
+
# nominalization of a relation verb -> {kind, entityType?}), validated at load
|
|
25
|
+
# time by the strategy against RELATIONS' own kind vocabulary (ask-vocab.mjs) and
|
|
26
|
+
# ENTITY_TO_TYPE's canonical class names — an unrecognized kind/entityType is
|
|
27
|
+
# REJECTED (the entry is dropped, never guessed into the nearest match), same
|
|
28
|
+
# "closed is deliberate" discipline as ask-vocab.mjs's RELATIONS table itself.
|
|
29
|
+
#
|
|
30
|
+
# Deliberately narrow, hand-curated set (not exhaustive): each noun is one a
|
|
31
|
+
# developer plausibly types for THIS codebase's relations, same judgment-call
|
|
32
|
+
# discipline ask-vocab.mjs's file header already states. "testers"/"containers"/
|
|
33
|
+
# "exporters"/"definers" were considered and left out — too easily confused with
|
|
34
|
+
# unrelated code-domain nouns (a Docker "container", a DI "container", a test
|
|
35
|
+
# "runner") or simply not natural developer phrasing; a false-positive match on
|
|
36
|
+
# an unrelated identifier is worse than an honest miss, so the set stays small.
|
|
37
|
+
|
|
38
|
+
[[relation]]
|
|
39
|
+
noun = "importers"
|
|
40
|
+
kind = "imports"
|
|
41
|
+
entityType = "Module" # the imports edge is module-grain only
|
|
42
|
+
|
|
43
|
+
[[relation]]
|
|
44
|
+
noun = "callers"
|
|
45
|
+
kind = "calls"
|
|
46
|
+
# no entityType: calls resolves at function/method OR module grain (ask.mjs's
|
|
47
|
+
# own fine/coarse call-edge handling) — leaving this unset means entityType null,
|
|
48
|
+
# "any grain", exactly like grammar.mjs's own bare "what calls X" (T3 forward).
|
|
49
|
+
|
|
50
|
+
[[relation]]
|
|
51
|
+
noun = "users"
|
|
52
|
+
kind = "uses"
|
|
53
|
+
# uses is itself a query-side union (imports + calls + callsSymbol, ask-vocab.mjs)
|
|
54
|
+
# — no single entityType is correct, so this stays grain-agnostic too.
|
|
55
|
+
|
|
56
|
+
[[relation]]
|
|
57
|
+
noun = "subclasses"
|
|
58
|
+
kind = "inherits"
|
|
59
|
+
entityType = "Class" # inherits is Class -> Class only
|
|
60
|
+
|
|
61
|
+
# ---- constructions: pattern -> AST skeleton -------------------------------
|
|
62
|
+
# Pattern DSL (src/interpret/strategies/constructions.mjs compiles this):
|
|
63
|
+
# <AGENT> one of the [[relation]] nouns above (closed alternation, longest
|
|
64
|
+
# noun first so e.g. a hypothetical multi-word noun would never be
|
|
65
|
+
# shadowed by a shorter one sharing a prefix)
|
|
66
|
+
# <TERM> a free-text object term, non-greedy — the SAME "capture the rest,
|
|
67
|
+
# let resolveObject do real matching" contract every anchored
|
|
68
|
+
# template in grammar.mjs already uses
|
|
69
|
+
# any other text is a literal (case-insensitive, whitespace-normalized)
|
|
70
|
+
# Every construction here compiles to shape="reverse": the AGENT noun names the
|
|
71
|
+
# relation's SUBJECT side, <TERM> is the OBJECT being related-to — precisely
|
|
72
|
+
# grammar.mjs's own T2 "reverse" shape (entityType/modifier/kind/object), just
|
|
73
|
+
# reached from a different surface form. A future construction needing a
|
|
74
|
+
# different `shape` is free to declare one; the strategy loader supports the
|
|
75
|
+
# full anchored-template shape vocabulary (ask/reverse/forward/where/when/
|
|
76
|
+
# meta/mentions), not "reverse" exclusively — this bank simply doesn't need the
|
|
77
|
+
# others yet.
|
|
78
|
+
|
|
79
|
+
[[construction]]
|
|
80
|
+
id = "T11"
|
|
81
|
+
name = "agent-noun-of"
|
|
82
|
+
comment = "\"importers of X\" -> reverse imports X. Also reached (correctly) by keyword-spot today; kept here for a single closed, anchored source of truth and byte-identical precedence with the other two surface forms below."
|
|
83
|
+
pattern = "<AGENT> of <TERM>"
|
|
84
|
+
shape = "reverse"
|
|
85
|
+
|
|
86
|
+
[[construction]]
|
|
87
|
+
id = "T12"
|
|
88
|
+
name = "agent-noun-genitive"
|
|
89
|
+
comment = "\"X's importers\" -> reverse imports X. The genitive-'s NP; keyword-spot currently mis-reads this as forward (X importing things) — this construction is the fix."
|
|
90
|
+
pattern = "<TERM>'s <AGENT>"
|
|
91
|
+
shape = "reverse"
|
|
92
|
+
|
|
93
|
+
[[construction]]
|
|
94
|
+
id = "T13"
|
|
95
|
+
name = "agent-noun-compound"
|
|
96
|
+
comment = "\"X importers\" (bare compound-noun juxtaposition, no possessive marker or preposition) -> reverse imports X. The same mis-parse as T12's genitive form, without the apostrophe; a developer drops the 's as often as they keep it."
|
|
97
|
+
pattern = "<TERM> <AGENT>"
|
|
98
|
+
shape = "reverse"
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
{"id":"orientation-empty","class":"orientation","register":"friendly","template":"I'm tmct — a deterministic, offline chat assistant (no LLM). {vocabHint} /memory for what I remember.\nFor code structure (imports, calls, definitions) point me at a repo: `--repo <path>`, or try the shipped example `npm run example:mini`. tmct reads graphs; it doesn't index code itself.\n/help for commands."}
|
|
67
67
|
{"id":"identity-self","class":"conversational","register":"friendly","template":"I'm tmct — a deterministic, offline chat assistant. No LLM: wink-nlp parsing over a seeded ontology/lexicon, plus a code graph when you point me at a repo with `--repo <path>`. /help for commands, /stats for an overview."}
|
|
68
68
|
{"id":"identity-not-an-llm","class":"conversational","register":"friendly","template":"No — no LLM involved. tmct is deterministic: wink-nlp parsing over a graph/ontology, not a language model. /help for commands."}
|
|
69
|
+
{"id":"identity-no-feelings","class":"conversational","register":"friendly","template":"No — I don't have feelings, opinions, or consciousness. tmct is deterministic: wink-nlp parsing over a graph/ontology, not a mind. /help for commands."}
|
|
69
70
|
{"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})."}
|
|
70
71
|
{"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})."}
|
|
71
72
|
{"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})."}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.2",
|
|
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.",
|
|
@@ -36,6 +36,9 @@
|
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": ">=24"
|
|
38
38
|
},
|
|
39
|
+
"workspaces": [
|
|
40
|
+
"packages/*"
|
|
41
|
+
],
|
|
39
42
|
"bin": {
|
|
40
43
|
"tmct": "./bin/tmct.mjs"
|
|
41
44
|
},
|
|
@@ -68,6 +71,7 @@
|
|
|
68
71
|
"access": "public"
|
|
69
72
|
},
|
|
70
73
|
"dependencies": {
|
|
74
|
+
"@polycode-projects/ace-owl": "^0.1.0",
|
|
71
75
|
"ink": "^7.1.0",
|
|
72
76
|
"react": "^19.2.7",
|
|
73
77
|
"smol-toml": "^1.7.0",
|
package/src/ask-vocab.mjs
CHANGED
|
@@ -307,6 +307,40 @@ export function stripTrailingScopeFiller(text) {
|
|
|
307
307
|
return text.replace(TRAILING_SCOPE_FILLER_RE, "").trim();
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
+
/** Trailing bare discourse tags — the same curated "then"/"though" pair
|
|
311
|
+
* ask.mjs's PRED_LEAD_SKIP already recognizes as a trailing discourse tag on
|
|
312
|
+
* an otherwise-bare follow-up ("how many of those then"). A "what is X"
|
|
313
|
+
* meta-whatis question wasn't tolerant of this yet: "what is a component
|
|
314
|
+
* then" captured the literal unknown term "component then" instead of
|
|
315
|
+
* "component" (HANDOVER.md 2026-07-10, item 8). Stripped the same
|
|
316
|
+
* closed-list way as TRAILING_SCOPE_FILLER, just above. "too" (playtest
|
|
317
|
+
* sprint round 2): "is UserController a validator too then" — a STACKED
|
|
318
|
+
* pair of trailing tags — needed both "too" added to the set AND the strip
|
|
319
|
+
* applied twice (below), since a single pass only ever removes the
|
|
320
|
+
* outermost tag. */
|
|
321
|
+
export const TRAILING_DISCOURSE_TAG = Object.freeze(["then", "though", "too"]);
|
|
322
|
+
|
|
323
|
+
const TRAILING_DISCOURSE_TAG_RE = new RegExp(
|
|
324
|
+
`\\s+(?:${TRAILING_DISCOURSE_TAG.join("|")})\\s*[?.!]*$`, "i",
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
/** Strip trailing bare discourse tags (TRAILING_DISCOURSE_TAG, above) off the
|
|
328
|
+
* end of a captured meta-whatis term — "what is a component then" resolves
|
|
329
|
+
* the same term as "what is a component". Applied up to twice (a stacked
|
|
330
|
+
* "too then"/"then too" is the only worked case that ever needs a second
|
|
331
|
+
* pass; no phrasing seen so far stacks a third), mirroring
|
|
332
|
+
* stripTrailingScopeFiller's own single-clause discipline for the common
|
|
333
|
+
* single-tag case while still covering the rarer double-tag one. */
|
|
334
|
+
export function stripTrailingDiscourseTag(text) {
|
|
335
|
+
let out = text;
|
|
336
|
+
for (let pass = 0; pass < 2; pass += 1) {
|
|
337
|
+
const next = out.replace(TRAILING_DISCOURSE_TAG_RE, "").trim();
|
|
338
|
+
if (next === out) break;
|
|
339
|
+
out = next;
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
|
|
310
344
|
/** relation token -> flat verb-phrase list, the shape ask.mjs's VERB_TO_KIND
|
|
311
345
|
* table needs (phrase -> kind), derived once from RELATIONS. */
|
|
312
346
|
export const VERB_TO_KIND = Object.freeze(
|
|
@@ -319,7 +353,11 @@ export const ENTITY_TO_TYPE = Object.freeze({
|
|
|
319
353
|
function: "Function", functions: "Function",
|
|
320
354
|
method: "Method", methods: "Method",
|
|
321
355
|
class: "Class", classes: "Class",
|
|
322
|
-
|
|
356
|
+
// "mod"/"mods" (HANDOVER.md 2026-07-10 item 10): a rushed-dev abbreviation
|
|
357
|
+
// prefix ("mod store.mjs imports") used to land in disambiguation instead of
|
|
358
|
+
// resolving cleanly, since nothing recognized "mod" as this same Module noun
|
|
359
|
+
// — same alias-of-Module trade "file"/"files" already make just above.
|
|
360
|
+
module: "Module", modules: "Module", mod: "Module", mods: "Module", file: "Module", files: "Module",
|
|
323
361
|
attribute: "Attribute", attributes: "Attribute", field: "Attribute", fields: "Attribute",
|
|
324
362
|
variable: "GlobalVariable", variables: "GlobalVariable", global: "GlobalVariable", globals: "GlobalVariable",
|
|
325
363
|
// "changes" in a touch question ("which changes touch commit <sha>") means the
|
package/src/ask.mjs
CHANGED
|
@@ -324,6 +324,7 @@ function parseComposite(text, nlp) {
|
|
|
324
324
|
|| parseNegation(text, nlp, 0)
|
|
325
325
|
|| parseForwardNegation(w, lc, nlp)
|
|
326
326
|
|| parseTemporal(w, lc, nlp, 0)
|
|
327
|
+
|| parseCommitFilter(w, lc)
|
|
327
328
|
|| parseAnaphora(w, lc, nlp)
|
|
328
329
|
|| parseAggregate(w, lc, nlp)
|
|
329
330
|
|| parseSuperlative(w, lc, nlp)
|
|
@@ -533,6 +534,61 @@ function parseTemporal(w, lc, nlp, depth = 0) {
|
|
|
533
534
|
return { node: "temporal", inner, entityType: (noun && noun.entityType) || null };
|
|
534
535
|
}
|
|
535
536
|
|
|
537
|
+
// COMMIT FILTER (Track 1 temporal lever, PLAN_CHAT_FEEL item 6 remainder) — "what
|
|
538
|
+
// changed since/before/after/on <date-or-commit>": a date-qualified SURVEY of every
|
|
539
|
+
// recorded commit (distinct from the flat when-shape above, which dates ONE named
|
|
540
|
+
// entity's touch history). The pivot is either a literal ISO-8601 date (yyyy-mm-dd,
|
|
541
|
+
// mgx:commitDate's own format, so a lexical compare is a chronological one) or a
|
|
542
|
+
// named commit — resolved at EVAL time (graph-dependent), whose own recorded date
|
|
543
|
+
// becomes the pivot and who is excluded from its own before/after comparison (never
|
|
544
|
+
// "before/after itself"). "in"/"during" are deliberately NOT among the qualifiers:
|
|
545
|
+
// "what changed in <commit>" already means something else (that commit's own touch-
|
|
546
|
+
// set — the commit-as-subject flip elsewhere in this file), and this recognizer must
|
|
547
|
+
// never shadow it.
|
|
548
|
+
const COMMIT_FILTER_OPS = new Set(["since", "before", "after", "on"]);
|
|
549
|
+
function parseCommitFilter(w, lc) {
|
|
550
|
+
if (lc[0] !== "what" || lc[1] !== "changed") return null;
|
|
551
|
+
let i = 2;
|
|
552
|
+
if (lc[i] === "ever") i += 1;
|
|
553
|
+
if (!COMMIT_FILTER_OPS.has(lc[i])) return null;
|
|
554
|
+
const op = lc[i];
|
|
555
|
+
const pivotRaw = w.slice(i + 1).join(" ").trim();
|
|
556
|
+
if (!pivotRaw) return { node: "miss", reason: `"what changed ${op}" needs a date or commit afterward` };
|
|
557
|
+
return { node: "commitFilter", op, pivotRaw };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Minimal code-identifier token shape — dotted paths ("app/lib/e.mjs"), Capitalized
|
|
561
|
+
// symbols ("Store"), or lowerCamelCase symbols ("fnAlpha"). Intentionally DUPLICATED
|
|
562
|
+
// from chat.mjs's own NAME_TOKEN_RE (chat.mjs ~line 4998, used there for exactly this
|
|
563
|
+
// kind of code-identifier detection in discourseRewrite) rather than imported:
|
|
564
|
+
// chat.mjs only ever imports ask.mjs LAZILY (dynamic `await import("./ask.mjs")`,
|
|
565
|
+
// per chat.mjs's own top-of-file comment), so a static ask.mjs -> chat.mjs import
|
|
566
|
+
// would invert that layering for the sake of one regex. Keep the two in sync by
|
|
567
|
+
// hand if either changes.
|
|
568
|
+
const ANAPHORA_NAME_TOKEN_RE = /\b[\w-]+(?:[/.][\w-]+)+\b|\b[A-Z][A-Za-z0-9_]*\b|\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/;
|
|
569
|
+
|
|
570
|
+
/** Distinct code-identifier-shaped tokens in `words` (original case preserved —
|
|
571
|
+
* the regex cares about case), in first-occurrence order, de-duplicated
|
|
572
|
+
* case-insensitively. Feeds parseAnaphora's in-sentence candidate-set fix
|
|
573
|
+
* (HANDOVER.md item 1, C2 pronoun-binding): "which of them <filter>" doesn't
|
|
574
|
+
* ALWAYS mean "the previous turn's answer set" — when the SAME sentence already
|
|
575
|
+
* named 2+ real candidates before the trigger ("app/lib/e.mjs ... app/lib/f.mjs
|
|
576
|
+
* ... which of them ...", a single turn, no prior turn at all), THAT'S the
|
|
577
|
+
* referent. Ordinary English words (even repeated nouns) never match this
|
|
578
|
+
* regex, so this never widens beyond genuine code identifiers. */
|
|
579
|
+
function inSentenceNameTokens(words) {
|
|
580
|
+
const seen = new Set();
|
|
581
|
+
const out = [];
|
|
582
|
+
for (const raw of words) {
|
|
583
|
+
if (!raw || !ANAPHORA_NAME_TOKEN_RE.test(raw)) continue;
|
|
584
|
+
const key = raw.toLowerCase();
|
|
585
|
+
if (seen.has(key)) continue;
|
|
586
|
+
seen.add(key);
|
|
587
|
+
out.push(raw);
|
|
588
|
+
}
|
|
589
|
+
return out;
|
|
590
|
+
}
|
|
591
|
+
|
|
536
592
|
/** ANAPHORA over the previous result set: "which of those/them <filter>", "how many
|
|
537
593
|
* of those <filter>". Requires "of <pronoun>" (so a bare "those" in a term never
|
|
538
594
|
* fires). Returns a {node:"anaphora"} (mode count|list), a miss (filter present but
|
|
@@ -569,7 +625,19 @@ function parseAnaphora(w, lc, nlp) {
|
|
|
569
625
|
const mode = AGGREGATE_TRIGGERS.includes(head) || /^(how many|how much|count|number|quantity|total)\b/.test(head) ? "count" : "list";
|
|
570
626
|
const filter = parsePredicateFilter(w.slice(p + 1), nlp);
|
|
571
627
|
if (filter === undefined) return { node: "miss", reason: "the follow-up filter didn't parse" };
|
|
572
|
-
|
|
628
|
+
// in-sentence candidate set (HANDOVER.md item 1): if the SAME utterance already
|
|
629
|
+
// named 2+ real code identifiers BEFORE the "of them"/"of those" trigger — e.g.
|
|
630
|
+
// "app/lib/e.mjs ... because it imports app/lib/f.mjs — which of them imports
|
|
631
|
+
// app/lib/f.mjs" — those are the referent, not a previous turn's cached result
|
|
632
|
+
// set. evalAnaphora tries this FIRST and only falls back to opts.prev when it's
|
|
633
|
+
// absent or resolves to fewer than 2 real graph entities, so an ordinary
|
|
634
|
+
// multi-turn follow-up (no named entities in the current utterance at all) is
|
|
635
|
+
// completely unaffected.
|
|
636
|
+
const cutIdx = viaOf ? p - 1 : p;
|
|
637
|
+
const candidateTerms = inSentenceNameTokens(w.slice(0, cutIdx));
|
|
638
|
+
const ast = { node: "anaphora", mode, filter };
|
|
639
|
+
if (candidateTerms.length >= 2) ast.candidateTerms = candidateTerms;
|
|
640
|
+
return ast;
|
|
573
641
|
}
|
|
574
642
|
|
|
575
643
|
/** Parse a trailing filter (for anaphora, and any "of those that …" tail) into
|
|
@@ -1053,6 +1121,22 @@ function parseRelationalOrQualified(w, lc, nlp, depth) {
|
|
|
1053
1121
|
if (RECENT_COMMIT_LEAD.has(lc[i]) && nextNoun && nextNoun.entityType === "Commit" && i + 2 === lc.length) {
|
|
1054
1122
|
return { node: "recentCommits" };
|
|
1055
1123
|
}
|
|
1124
|
+
// Track 1 temporal lever (remainder) — the SAME bare lead, past an optional
|
|
1125
|
+
// copula + determiner ("what IS THE newest commit", "what WAS THE latest
|
|
1126
|
+
// commit"): FRAME_WORDS only strips "what"/"which"/…, so "is the"/"was the"
|
|
1127
|
+
// left `i` sitting on the copula, never reaching the check above at all. A
|
|
1128
|
+
// narrow lookahead (never mutating `i`, so every OTHER branch here is
|
|
1129
|
+
// byte-identical) that re-tries the exact same closed RECENT_COMMIT_LEAD
|
|
1130
|
+
// check past those two filler words only — an honest decline (falls through)
|
|
1131
|
+
// the instant either word doesn't match, never a guess.
|
|
1132
|
+
if (COPULA_WORDS.has(lc[i])) {
|
|
1133
|
+
let j = i + 1;
|
|
1134
|
+
if (j < lc.length && (lc[j] === "the" || lc[j] === "a" || lc[j] === "an")) j += 1;
|
|
1135
|
+
const leadNoun = j + 1 < lc.length ? entityNoun(lc[j + 1]) : null;
|
|
1136
|
+
if (RECENT_COMMIT_LEAD.has(lc[j]) && leadNoun && leadNoun.entityType === "Commit" && j + 2 === lc.length) {
|
|
1137
|
+
return { node: "recentCommits" };
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1056
1140
|
// CASCADE_NOISE_SET excluded alongside STOPWORDS (Tier-2 playtest, cycle 8):
|
|
1057
1141
|
// "what about classes"/"how about the modules" used to reach here with
|
|
1058
1142
|
// "about" sitting right where a real qualifying adjective would ("payment"
|
|
@@ -1343,6 +1427,42 @@ function moduleIdOf(graph, ind) {
|
|
|
1343
1427
|
return qualSets(graph).moduleOfSymbol.get(ind.id) || null;
|
|
1344
1428
|
}
|
|
1345
1429
|
|
|
1430
|
+
/** META FALLBACK TO REAL ENTITIES (0.8.2 WS1; widened + extracted HANDOVER.md
|
|
1431
|
+
* 2026-07-10 item 6): "what is a Record" used to say "'Record' isn't a term in
|
|
1432
|
+
* this graph's own vocabulary" even when Record is a real code-graph entity —
|
|
1433
|
+
* after a SchemaClass/SchemaPredicate miss, an exact case-insensitive UNIQUE
|
|
1434
|
+
* label match against a small set of real code-entity classes (Class/Function/
|
|
1435
|
+
* Method/GlobalVariable/Attribute — not just Class; CHATBENCH g-a2-naming-6:
|
|
1436
|
+
* "what does fnAlpha mean", a Function, hit this same false vocabulary-miss
|
|
1437
|
+
* wall). Uniqueness is GLOBAL across all these classes together, not per-class:
|
|
1438
|
+
* a name colliding across two different classes stays an honest miss, never a
|
|
1439
|
+
* guess at which one was meant. Extracted (not just inlined in traverse()'s own
|
|
1440
|
+
* meta branch below) so chat.mjs's BARE "what is X" last-resort lane — no
|
|
1441
|
+
* article, so T5's structural parse never even produces a meta shape to reach
|
|
1442
|
+
* traverse() at all (CHATBENCH g-a2-naming-2: "what is Widget") — can reuse the
|
|
1443
|
+
* exact same lookup + wording, rather than risk the two silently drifting
|
|
1444
|
+
* apart. Returns null on anything less than a unique exact hit. */
|
|
1445
|
+
const META_FALLBACK_CLASSES = new Set(["Class", "Function", "Method", "GlobalVariable", "Attribute"]);
|
|
1446
|
+
export function metaFallbackEntityAnswer(graph, term) {
|
|
1447
|
+
const termLc = String(term || "").trim().toLowerCase();
|
|
1448
|
+
if (!termLc) return null;
|
|
1449
|
+
const hits = (graph?.individuals || []).filter((i) => META_FALLBACK_CLASSES.has(i.class) && String(i.label).toLowerCase() === termLc);
|
|
1450
|
+
if (hits.length !== 1) return null;
|
|
1451
|
+
const hit = hits[0];
|
|
1452
|
+
const mid = moduleIdOf(graph, hit);
|
|
1453
|
+
const modLabel = (mid && graph.byId.get(mid)?.label)
|
|
1454
|
+
|| String((hit.attributes || []).find((a) => a.key === "site")?.value || "").split(":")[0]
|
|
1455
|
+
|| null;
|
|
1456
|
+
const noun = nounFor(hit.class, 1);
|
|
1457
|
+
const article = noun === "attribute" ? "an" : "a";
|
|
1458
|
+
const definedIn = modLabel ? `, defined in ${modLabel}` : "";
|
|
1459
|
+
const followUp = hit.class === "Class" ? ` or "which classes inherit from ${hit.label}"` : "";
|
|
1460
|
+
return {
|
|
1461
|
+
text: `${hit.label} is ${article} ${noun} in this codebase${definedIn} — try "describe ${hit.label}"${followUp}.`,
|
|
1462
|
+
hit, modLabel,
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1346
1466
|
// ---- MEMBERSHIP inheritance cascade (HANDOVER item 6) — "<kind> of <owner>" walks
|
|
1347
1467
|
// UP `inherits` when the owner's own surface has nothing, exactly the way
|
|
1348
1468
|
// computeFind's narrow-then-broaden pass does for predicate-find, below. ----
|
|
@@ -1704,12 +1824,36 @@ function evalBoolean(graph, ast, opts) {
|
|
|
1704
1824
|
return acc;
|
|
1705
1825
|
}
|
|
1706
1826
|
|
|
1707
|
-
/**
|
|
1708
|
-
*
|
|
1827
|
+
/** Resolve parseAnaphora's in-sentence candidateTerms (HANDOVER.md item 1) to real
|
|
1828
|
+
* graph entities, de-duplicated by resolved id. Returns null (not just []) when
|
|
1829
|
+
* fewer than 2 resolve, so the caller can tell "no in-sentence candidates" apart
|
|
1830
|
+
* from "named candidates that happen to fail resolution" and fall back to
|
|
1831
|
+
* opts.prev either way — never a guess, same discipline as everywhere else in
|
|
1832
|
+
* this function. */
|
|
1833
|
+
function resolveInSentenceCandidates(graph, terms) {
|
|
1834
|
+
if (!Array.isArray(terms) || terms.length < 2) return null;
|
|
1835
|
+
const seen = new Set();
|
|
1836
|
+
const resolved = [];
|
|
1837
|
+
for (const term of terms) {
|
|
1838
|
+
const r = resolveObject(graph, term);
|
|
1839
|
+
if (r && r.match && !seen.has(r.match.id)) { seen.add(r.match.id); resolved.push(r.match); }
|
|
1840
|
+
}
|
|
1841
|
+
return resolved.length >= 2 ? resolved : null;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** Anaphora over the candidate set: EITHER the current utterance's own in-sentence
|
|
1845
|
+
* named entities (parseAnaphora's candidateTerms — a single turn, no prior turn
|
|
1846
|
+
* needed at all, HANDOVER.md item 1), tried first, OR ask()'s `prev` id array (a
|
|
1847
|
+
* genuine previous-turn follow-up), filtered/counted the same way either way. No
|
|
1848
|
+
* candidate set at all → honest miss (never a guess), like an unresolved pronoun. */
|
|
1709
1849
|
function evalAnaphora(graph, ast, opts) {
|
|
1710
|
-
const
|
|
1711
|
-
|
|
1712
|
-
|
|
1850
|
+
const inSentence = resolveInSentenceCandidates(graph, ast.candidateTerms);
|
|
1851
|
+
let baseItems = inSentence;
|
|
1852
|
+
if (!baseItems) {
|
|
1853
|
+
const prev = opts && opts.prev;
|
|
1854
|
+
if (!Array.isArray(prev) || !prev.length) return { compositeMiss: true, reason: "no-prev", matches: [] };
|
|
1855
|
+
baseItems = prev.map((id) => graph.byId.get(id)).filter(Boolean);
|
|
1856
|
+
}
|
|
1713
1857
|
let items = baseItems;
|
|
1714
1858
|
const f = ast.filter;
|
|
1715
1859
|
if (f && f.type === "qual") {
|
|
@@ -1770,6 +1914,41 @@ function evalRecentCommits(graph) {
|
|
|
1770
1914
|
return { compositeKind: "recentCommits", matches: commits };
|
|
1771
1915
|
}
|
|
1772
1916
|
|
|
1917
|
+
const COMMIT_FILTER_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
1918
|
+
/** COMMIT FILTER eval — resolves the pivot (a literal ISO date, or a NAMED commit
|
|
1919
|
+
* whose own recorded date becomes the pivot — resolved here, graph-dependent, per
|
|
1920
|
+
* the parser's own doc), then filters every Commit individual's date against it.
|
|
1921
|
+
* A pivot that IS itself a commit is excluded from its own comparison. An
|
|
1922
|
+
* unresolvable pivot (neither a date nor a known commit) declines honestly
|
|
1923
|
+
* (pivotResolved:false) rather than guessing an empty result. */
|
|
1924
|
+
function evalCommitFilter(graph, ast) {
|
|
1925
|
+
const { op, pivotRaw } = ast;
|
|
1926
|
+
const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "").slice(0, 10);
|
|
1927
|
+
let pivotDate = null;
|
|
1928
|
+
let pivotId = null;
|
|
1929
|
+
if (COMMIT_FILTER_DATE_RE.test(pivotRaw)) {
|
|
1930
|
+
pivotDate = pivotRaw;
|
|
1931
|
+
} else {
|
|
1932
|
+
const { match, ambiguous } = resolveObject(graph, pivotRaw, { expectedClass: "Commit" });
|
|
1933
|
+
if (match && !ambiguous && match.class === "Commit" && dateOf(match)) {
|
|
1934
|
+
pivotId = match.id;
|
|
1935
|
+
pivotDate = dateOf(match);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
if (!pivotDate) return { compositeKind: "commitFilter", op, pivotRaw, pivotResolved: false, matches: [] };
|
|
1939
|
+
const matches = graph.individuals
|
|
1940
|
+
.filter((i) => i.class === "Commit" && i.id !== pivotId && dateOf(i))
|
|
1941
|
+
.filter((c) => {
|
|
1942
|
+
const d = dateOf(c);
|
|
1943
|
+
if (op === "since") return d >= pivotDate;
|
|
1944
|
+
if (op === "before") return d < pivotDate;
|
|
1945
|
+
if (op === "after") return d > pivotDate;
|
|
1946
|
+
return d === pivotDate; // "on"
|
|
1947
|
+
})
|
|
1948
|
+
.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
|
|
1949
|
+
return { compositeKind: "commitFilter", op, pivotRaw, pivotDate, pivotResolved: true, matches };
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1773
1952
|
/** TEMPORAL over a nested set (lever 3) — the commits that touched ANY member of the
|
|
1774
1953
|
* inner set, newest commit date first. Reuses the SAME touches→commit→date-sort the
|
|
1775
1954
|
* flat when-shape runs (mgx:commitDate is ISO-8601, so a lexical sort IS a date sort;
|
|
@@ -1898,6 +2077,7 @@ export function evalComposite(graph, ast, opts = {}) {
|
|
|
1898
2077
|
if (ast.node === "superlative") return evalSuperlative(graph, ast);
|
|
1899
2078
|
if (ast.node === "temporal") return evalTemporal(graph, ast, opts);
|
|
1900
2079
|
if (ast.node === "recentCommits") return evalRecentCommits(graph);
|
|
2080
|
+
if (ast.node === "commitFilter") return evalCommitFilter(graph, ast);
|
|
1901
2081
|
if (ast.node === "anaphora") return evalAnaphora(graph, ast, opts);
|
|
1902
2082
|
// membership inheritance cascade (HANDOVER item 6), TOP-LEVEL: a bare "<kind> of
|
|
1903
2083
|
// <owner>" node, or a qualifier wrapping one ("public methods of <owner>") — see
|
|
@@ -2069,6 +2249,33 @@ function renderComposite(parsed, result) {
|
|
|
2069
2249
|
miss: false, ambiguous: false, matches: result.matches,
|
|
2070
2250
|
};
|
|
2071
2251
|
}
|
|
2252
|
+
// Track 1 temporal lever (remainder): "what changed since/before/after/on
|
|
2253
|
+
// <date-or-commit>" — same dated-list rendering convention as recentCommits just
|
|
2254
|
+
// above, scoped to the resolved pivot. An unresolvable pivot names itself and the
|
|
2255
|
+
// two supported pivot shapes (never a silent guess); a resolved pivot with no
|
|
2256
|
+
// qualifying commits is an honest empty (never the generic orientation blurb).
|
|
2257
|
+
if (result.compositeKind === "commitFilter") {
|
|
2258
|
+
if (!result.pivotResolved) {
|
|
2259
|
+
return {
|
|
2260
|
+
content: `"${result.pivotRaw}" isn't a recognized date (yyyy-mm-dd) or a known commit — try "what changed since 2026-06-01" or "what changed before <commit>".`,
|
|
2261
|
+
miss: true, ambiguous: false, matches: [],
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
if (!result.matches.length) {
|
|
2265
|
+
return { content: `no commits recorded ${result.op} ${result.pivotRaw}.`, miss: true, ambiguous: false, matches: [] };
|
|
2266
|
+
}
|
|
2267
|
+
const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
|
|
2268
|
+
const shown = result.matches.slice(0, HISTORY_CAP).map((c) => {
|
|
2269
|
+
const day = dateOf(c).slice(0, 10);
|
|
2270
|
+
const msg = (c.attributes || []).find((a) => a.key === "message")?.value || "";
|
|
2271
|
+
return `${c.label}${day ? ` (${day})` : ""}${msg ? ` — ${msg}` : ""}`;
|
|
2272
|
+
});
|
|
2273
|
+
const tail = result.matches.length > HISTORY_CAP ? ` …+${result.matches.length - HISTORY_CAP} more` : "";
|
|
2274
|
+
return {
|
|
2275
|
+
content: `${result.matches.length} commit(s) changed ${result.op} ${result.pivotRaw}: ${shown.join(", ")}${tail}.`,
|
|
2276
|
+
miss: false, ambiguous: false, matches: result.matches,
|
|
2277
|
+
};
|
|
2278
|
+
}
|
|
2072
2279
|
if (result.compositeKind === "superlative") {
|
|
2073
2280
|
if (!result.matches.length) return { content: `no ${nounFor(result.entityType, 2)} to rank in this index.`, miss: true, ambiguous: false };
|
|
2074
2281
|
const lead = result.extreme === "most" ? "the most" : "the fewest";
|
|
@@ -2733,24 +2940,17 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
|
|
|
2733
2940
|
return token && String(token).toLowerCase() === termLc;
|
|
2734
2941
|
});
|
|
2735
2942
|
if (!match) {
|
|
2736
|
-
// META FALLBACK TO REAL ENTITIES (0.8.2 WS1
|
|
2737
|
-
//
|
|
2738
|
-
//
|
|
2739
|
-
//
|
|
2740
|
-
//
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
const classHits = (graph.individuals || []).filter((i) => i.class === "Class" && String(i.label).toLowerCase() === termLc);
|
|
2744
|
-
if (classHits.length === 1) {
|
|
2745
|
-
const hit = classHits[0];
|
|
2746
|
-
const mid = moduleIdOf(graph, hit);
|
|
2747
|
-
const modLabel = (mid && graph.byId.get(mid)?.label)
|
|
2748
|
-
|| String((hit.attributes || []).find((a) => a.key === "site")?.value || "").split(":")[0]
|
|
2749
|
-
|| null;
|
|
2943
|
+
// META FALLBACK TO REAL ENTITIES (0.8.2 WS1; widened + extracted to
|
|
2944
|
+
// metaFallbackEntityAnswer, HANDOVER.md 2026-07-10 item 6) — see that
|
|
2945
|
+
// function's own docblock for the full "what is a Record"/"what does
|
|
2946
|
+
// fnAlpha mean" history. A unique hit renders straight from its own text
|
|
2947
|
+
// (render()'s metaCodeClass branch just passes it through).
|
|
2948
|
+
const fallback = metaFallbackEntityAnswer(graph, term);
|
|
2949
|
+
if (fallback) {
|
|
2750
2950
|
return {
|
|
2751
|
-
matches: [hit], objMatch: hit, candidates: [], ambiguous: false,
|
|
2752
|
-
metaCodeClass: true,
|
|
2753
|
-
traversal: `schema lookup for "${term}" (miss), then unique
|
|
2951
|
+
matches: [fallback.hit], objMatch: fallback.hit, candidates: [], ambiguous: false,
|
|
2952
|
+
metaCodeClass: true, metaFallbackText: fallback.text,
|
|
2953
|
+
traversal: `schema lookup for "${term}" (miss), then unique code-entity individual by label`,
|
|
2754
2954
|
};
|
|
2755
2955
|
}
|
|
2756
2956
|
return { matches: [], objMatch: null, candidates: [], traversal: `schema lookup for "${term}"`, ambiguous: false };
|
|
@@ -2862,6 +3062,30 @@ export function traverse(graph, parsed, { contextId = null, prev = null } = {})
|
|
|
2862
3062
|
};
|
|
2863
3063
|
}
|
|
2864
3064
|
|
|
3065
|
+
// who-last (HANDOVER.md 2026-07-10 item 5): "who last touched X" — the SAME
|
|
3066
|
+
// newest-commit-first resolution as "when" just above (single most-recent
|
|
3067
|
+
// toucher, not the full touch history), rendered as the commit's AUTHOR
|
|
3068
|
+
// instead of its date. A dedicated shape rather than reusing "when" outright:
|
|
3069
|
+
// render() needs to know to answer with "who", not "when", off the same
|
|
3070
|
+
// sorted commit list.
|
|
3071
|
+
if (shape === "whoLast") {
|
|
3072
|
+
const dateOf = (c) => String((c.attributes || []).find((a) => a.key === "date")?.value || "");
|
|
3073
|
+
const edges = ["touches", "touchesSymbol"].flatMap((k) => edgesOfKind(graph, k)).filter((e) => e.object === objMatch.id);
|
|
3074
|
+
const seen = new Set();
|
|
3075
|
+
const commits = [];
|
|
3076
|
+
for (const e of edges) {
|
|
3077
|
+
if (seen.has(e.subject)) continue;
|
|
3078
|
+
seen.add(e.subject);
|
|
3079
|
+
const c = graph.byId.get(e.subject);
|
|
3080
|
+
if (c && c.class === "Commit") commits.push(c);
|
|
3081
|
+
}
|
|
3082
|
+
commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
|
|
3083
|
+
return {
|
|
3084
|
+
matches: commits, objMatch, candidates, ambiguous, matchedVia, whoLastShape: true,
|
|
3085
|
+
traversal: `touches+touchesSymbol edges where object = ${objMatch.label}, newest commit's author`,
|
|
3086
|
+
};
|
|
3087
|
+
}
|
|
3088
|
+
|
|
2865
3089
|
// commit-as-subject flip: touches edges are stored commit -> entity, so when the
|
|
2866
3090
|
// RESOLVED term of a touches question is itself a Commit — "which changes touch
|
|
2867
3091
|
// commit ef74e44e25c8" (reverse), "what did commit abc1234 touch" (forward),
|
|
@@ -3171,16 +3395,14 @@ function renderCore(parsed, result) {
|
|
|
3171
3395
|
miss: true, ambiguous: false,
|
|
3172
3396
|
};
|
|
3173
3397
|
}
|
|
3174
|
-
// meta fallback hit (0.8.2 WS1,
|
|
3175
|
-
//
|
|
3176
|
-
//
|
|
3398
|
+
// meta fallback hit (0.8.2 WS1, widened + extracted to metaFallbackEntityAnswer,
|
|
3399
|
+
// HANDOVER.md 2026-07-10 item 6, see traverse's meta branch): the term is not
|
|
3400
|
+
// schema vocabulary but IS a unique code-graph entity (Class/Function/Method/
|
|
3401
|
+
// GlobalVariable/Attribute) — its pre-rendered describe-style one-liner is
|
|
3402
|
+
// passed straight through, so this stays byte-identical to whatever chat.mjs's
|
|
3403
|
+
// bare "what is X" last-resort lane produces by calling the SAME function.
|
|
3177
3404
|
if (result.metaCodeClass) {
|
|
3178
|
-
|
|
3179
|
-
const definedIn = result.metaModuleLabel ? `, defined in ${result.metaModuleLabel}` : "";
|
|
3180
|
-
return {
|
|
3181
|
-
content: `${label} is a class in this codebase${definedIn} — try "describe ${label}" or "which classes inherit from ${label}".`,
|
|
3182
|
-
miss: false, ambiguous: false, matches: result.matches,
|
|
3183
|
-
};
|
|
3405
|
+
return { content: result.metaFallbackText, miss: false, ambiguous: false, matches: result.matches };
|
|
3184
3406
|
}
|
|
3185
3407
|
const doc = (result.objMatch.attributes || []).find((a) => a.key === "doc")?.value || "";
|
|
3186
3408
|
const kindWord = result.objMatch.class === "SchemaClass" ? "a class in the graph's schema" : "a predicate (relation) in the graph's schema";
|
|
@@ -3285,6 +3507,30 @@ function renderCore(parsed, result) {
|
|
|
3285
3507
|
miss: false, ambiguous: false, matches: result.matches,
|
|
3286
3508
|
};
|
|
3287
3509
|
}
|
|
3510
|
+
// who-last: newest touching commit's AUTHOR (HANDOVER.md 2026-07-10 item 5) — the
|
|
3511
|
+
// superlative "who" mirror of whenShape just above. "who last touched X" used to
|
|
3512
|
+
// fall into the ordinary reverse-list render below and name EVERY toucher; this
|
|
3513
|
+
// answers with the single most recent one instead. Unlike whenShape, no date is
|
|
3514
|
+
// needed to answer "who" — an undated-but-authored commit still resolves.
|
|
3515
|
+
if (result.whoLastShape) {
|
|
3516
|
+
const subject = result.objMatch.label;
|
|
3517
|
+
if (!result.matches.length) {
|
|
3518
|
+
return { content: `no recorded commit touches ${subject} in this index.`, miss: true, ambiguous: false };
|
|
3519
|
+
}
|
|
3520
|
+
const newest = result.matches[0];
|
|
3521
|
+
const author = (newest.attributes || []).find((a) => a.key === "author")?.value;
|
|
3522
|
+
if (!author) {
|
|
3523
|
+
return {
|
|
3524
|
+
content: `commit ${newest.label} last touched ${subject}, but this index records no commit author — regenerate the graph to attach mgx:commitAuthor.`,
|
|
3525
|
+
miss: true, ambiguous: false,
|
|
3526
|
+
};
|
|
3527
|
+
}
|
|
3528
|
+
const more = result.matches.length - 1;
|
|
3529
|
+
return {
|
|
3530
|
+
content: `${subject} was last touched by ${author} (commit ${newest.label})${more ? `; ${more} earlier commit${more === 1 ? "" : "s"} recorded` : ""}.`,
|
|
3531
|
+
miss: false, ambiguous: false, matches: result.matches,
|
|
3532
|
+
};
|
|
3533
|
+
}
|
|
3288
3534
|
// commit-as-subject answers ("which changes touch commit X", "what did commit X
|
|
3289
3535
|
// touch"): cite the commit, group the touched entities by CLASS — modules and
|
|
3290
3536
|
// symbols are different grains of the same answer, and flattening them into one
|