@polycode-projects/the-mechanical-code-talker 1.5.2 → 1.5.3

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 CHANGED
@@ -1052,19 +1052,25 @@ candidate for the plan's next spike, not a claimed result. Everything else is de
1052
1052
  plan's open questions, where the relevance problem is named as the open research risk it is.
1053
1053
 
1054
1054
  ### Open-source the ACE-OWL parser as a standalone library
1055
- > **STATUS: deferred follow-up** not yet started; still gated on the Phase 8 library-surface
1056
- > work settling the extraction boundary. See `PLAN_OSS_ACE_PARSER.md`.
1055
+ > **STATUS: tried, reverted (2026-07-10).** A session extracted `ace.mjs`/`lexicon.mjs` into
1056
+ > `packages/ace-owl`, a new npm workspace, and pointed tmct's own `package.json` at it as a
1057
+ > registry dependency — but never published the package. That broke `npm install` for tmct
1058
+ > itself (`@polycode-projects/ace-owl` 404s on the public registry, since it only ever existed as
1059
+ > a local workspace symlink). Folded back into `src/grammar/` the same day, on operator
1060
+ > instruction, once the break was found. If this is worth doing again, publish the package FIRST
1061
+ > (or in the same change), never split the two steps across a batch. See `PLAN_OSS_ACE_PARSER.md`.
1057
1062
 
1058
1063
  *(Operator-specified 2026-07-05, from the dependency audit's publish-not-replace finding.)* The
1059
1064
  pure-JS, ESM, dependency-free ACE-OWL controlled-grammar parser (`src/grammar/ace.mjs` +
1060
1065
  `lexicon.mjs`) that turns controlled-English sentences into OWL-labelled triples is a RARE thing:
1061
1066
  the reference implementation (APE) is GPL + SWI-Prolog (native), so there is no permissive,
1062
1067
  browser-capable, npm-installable ACE→OWL parser in the JS ecosystem. tmct's is exactly that.
1063
- Extract it to its own MPL-2.0 package (tmct depends on it back), so the wider RDF/OWL/semantic-web
1064
- JS community gains a controlled-natural-language front-end that runs in the browser. Gated on the
1065
- Repository Interface library-surface work (Phase 8) settling the extraction boundary; see
1066
- `PLAN_OSS_ACE_PARSER.md`. Sibling publish-candidates (the bounded-Damerau fuzzy matcher, the
1067
- PageRank+IDF block ranker) may follow the same path if there is demand.
1068
+ Extracting it to its own MPL-2.0 package (tmct depending on it back) would give the wider
1069
+ RDF/OWL/semantic-web JS community a controlled-natural-language front-end that runs in the
1070
+ browser genuine value, but only once actually published; see `PLAN_OSS_ACE_PARSER.md` for the
1071
+ full design and the note above for what went wrong the first attempt. Sibling publish-candidates
1072
+ (the bounded-Damerau fuzzy matcher, the PageRank+IDF block ranker) are lower priority than getting
1073
+ this one right before trying another.
1068
1074
 
1069
1075
  ## Phase 10 — Conversational competence & onboarding (0.6.0 → 0.7.0)
1070
1076
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
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,9 +36,6 @@
36
36
  "engines": {
37
37
  "node": ">=24"
38
38
  },
39
- "workspaces": [
40
- "packages/*"
41
- ],
42
39
  "bin": {
43
40
  "tmct": "./bin/tmct.mjs"
44
41
  },
@@ -71,7 +68,6 @@
71
68
  "access": "public"
72
69
  },
73
70
  "dependencies": {
74
- "@polycode-projects/ace-owl": "^0.1.0",
75
71
  "ink": "^7.1.0",
76
72
  "react": "^19.2.7",
77
73
  "smol-toml": "^1.7.0",
@@ -83,6 +79,11 @@
83
79
  "chat": "node bin/tmct.mjs",
84
80
  "chat:repo": "node bin/tmct.mjs chat --repo",
85
81
  "init": "node bin/tmct.mjs init",
82
+ "init:persona:code": "node bin/tmct.mjs init --with-persona code",
83
+ "init:corpus:general": "node bin/tmct.mjs init --corpus general",
84
+ "init:corpus:aws": "node bin/tmct.mjs init --corpus aws",
85
+ "init:corpus:python": "node bin/tmct.mjs init --corpus python",
86
+ "init:corpus:java": "node bin/tmct.mjs init --corpus java",
86
87
  "memory": "node bin/tmct.mjs memory",
87
88
  "syllogise": "node bin/tmct.mjs syllogise",
88
89
  "example:mini": "node bin/tmct.mjs chat --repo examples/mini-webapp --ephemeral",
@@ -1,27 +1,375 @@
1
- // grammar/ace.mjs — thin re-export shim over @polycode-projects/ace-owl
2
- // (PLAN_OSS_ACE_PARSER.md / PLAN_AGENTS.md §3's "ace-owl open-source
3
- // extraction"). tmct's ACE-OWL sub-fragment parser the 8 controlled-
4
- // English sentence patterns of docs/references/schemas/ace-owl-fragment.md
5
- // is now the extracted package's ace.mjs (packages/ace-owl/src/ace.mjs);
6
- // this file exists ONLY to default `lexicon` to tmct's own namespace-bound
7
- // loadLexicon() (see ./lexicon.mjs), so every existing call site in this
8
- // repo (chat.mjs, grammar/assert.mjs, the grammar tests, …) that calls
9
- // `parseAce(sentence)` with no lexicon argument keeps getting "tmct:"-
10
- // prefixed triples, byte-identical to before the extraction.
1
+ // grammar/ace.mjs — tmct's deterministic ACE-OWL sub-fragment parser (ROADMAP
2
+ // Phase 2, item 2). Implements the 8 controlled-English sentence patterns of
3
+ // docs/references/schemas/ace-owl-fragment.md and nothing more: fitting the
4
+ // grammar is a strong signal, missing it is a FEATURE — parseAce returns null
5
+ // (or an empty-triples result carrying the unknown words as `residue`) and the
6
+ // interpretation pipeline (src/interpret/) falls through to the tolerant
7
+ // strategies. No NLP dependency: tokenization is whitespace + trailing
8
+ // punctuation, morphology is the lexicon's suffix fold.
11
9
  //
12
- // tokenize() has no lexicon/namespace dependency at all, so it re-exports
13
- // unchanged. See packages/ace-owl/README.md for the parser's full contract
14
- // (pattern table, triple shape, the null-is-a-feature miss discipline).
15
- import { parseAce as parseAceLib, tokenize } from "@polycode-projects/ace-owl";
16
- import { loadLexicon } from "./lexicon.mjs";
17
-
18
- export { tokenize };
19
-
20
- /** parseAce(sentence, lexicon?) `lexicon` defaults to this module's own
21
- * loadLexicon() (the "tmct:"-namespaced core), not the package's neutral
22
- * default. Every other call site (grammar/assert.mjs, chat.mjs, tests) is
23
- * free to pass its own already-namespaced lexicon (e.g. from
24
- * extensions.mjs's mergedLexiconExtra) exactly as before. */
10
+ // parseAce(sentence, lexicon) { pattern, triples, residue } | null
11
+ // pattern one of the PATTERNS below (also exported individually).
12
+ // triples [{ subject, predicate, object, kind, n? }] — OWL-labelled string
13
+ // triples shaped for src/memory/core.mjs's appendFact (which
14
+ // normalizes subject/object via normFactTerm: "tmct:module" is
15
+ // stored as "module"; the predicate keeps its vocabulary casing).
16
+ // residue [] on a clean parse; the unknown tokens when the sentence FITS a
17
+ // pattern structurally but uses undeclared words (triples is then
18
+ // empty feeds the pipeline's "if you mean X…" surround).
19
+ // null the sentence does not fit the fragment at all.
20
+ //
21
+ // Term style: classes/individuals are `tmct:<lexeme>` CURIEs (lexicon lemma
22
+ // for nouns, canonical spelling for proper names, the literal token for
23
+ // code-shaped references like chat.mjs); predicates are the OWL/RDF(S)
24
+ // vocabulary terms or the lexicon verb's tmct:<3sg> predicate
25
+ // (lexicon.mjs's predicateOf). Restriction and intersection class
26
+ // expressions get READABLE deterministic node names (tmct:some-imports-test,
27
+ // tmct:module-that-imports-test) instead of blank nodes, so the same
28
+ // sentence always re-emits the same triples and appendFact stays idempotent.
29
+ // An intersection is flattened to repeated owl:intersectionOf triples (one
30
+ // per member) — the flat-JSON stand-in for an RDF list, documented in
31
+ // ontology/tmct-core.ttl. `lexicon.ns` is always "tmct:" here (lexicon.mjs's
32
+ // DEFAULT_NS) — every term this module mints is namespaced off `lexicon.ns`
33
+ // rather than a hardcoded literal purely so a caller can supply its own
34
+ // already-namespaced lexicon (extensions.mjs's mergedLexiconExtra); tmct
35
+ // itself only ever runs one namespace.
36
+
37
+ import {
38
+ loadLexicon, lookupNoun, lookupVerb, lookupAdjective, lookupProperName,
39
+ predicateOf, numberOf, classify,
40
+ } from "./lexicon.mjs";
41
+
42
+ export const PATTERN_SUB_CLASS_OF = "subClassOf";
43
+ export const PATTERN_TYPE_ASSERTION = "typeAssertion";
44
+ export const PATTERN_RELATION = "relation";
45
+ export const PATTERN_SOME_VALUES_FROM = "someValuesFrom";
46
+ export const PATTERN_CARDINALITY = "cardinality";
47
+ export const PATTERN_DISJOINT_WITH = "disjointWith";
48
+ export const PATTERN_POSSESSIVE = "possessive";
49
+ export const PATTERN_ADJECTIVE = "adjective";
50
+
51
+ /** The pattern field's full domain, in the README's table order. */
52
+ export const PATTERNS = Object.freeze([
53
+ PATTERN_SUB_CLASS_OF, PATTERN_TYPE_ASSERTION, PATTERN_RELATION, PATTERN_SOME_VALUES_FROM,
54
+ PATTERN_CARDINALITY, PATTERN_DISJOINT_WITH, PATTERN_POSSESSIVE, PATTERN_ADJECTIVE,
55
+ ]);
56
+
57
+ const DET = new Set(["a", "an", "the"]);
58
+ // A token SHAPED like a code reference (a path, file, symbol or CURIE) is an
59
+ // individual by form — a deterministic tokenizer rule, not a guess: declared
60
+ // proper names cover words; this covers chat.mjs, src/ask.mjs, Foo#bar.
61
+ const CODE_REF = /[./\\#:@]/;
62
+
63
+ /** Whitespace tokenizer: curly quotes normalized, commas/semicolons dropped,
64
+ * ONE trailing punctuation run stripped (so "chat.mjs." keeps its dots). */
65
+ export function tokenize(sentence) {
66
+ return String(sentence ?? "")
67
+ .replace(/[‘’]/g, "'")
68
+ .replace(/[,;]/g, " ")
69
+ .replace(/[?!.]+\s*$/, "")
70
+ .trim()
71
+ .split(/\s+/)
72
+ .filter(Boolean);
73
+ }
74
+
75
+ /** Strip a lexicon's own namespace prefix off a term, for use inside a
76
+ * synthesized deterministic node name (so "${ns}some-${ns}imports-${ns}test"
77
+ * reads as "${ns}some-imports-test"). A term outside the lexicon's own
78
+ * namespace (a rare cross-namespace reference) is returned unchanged. */
79
+ function local(lexicon, term) {
80
+ const s = String(term);
81
+ const ns = lexicon.ns;
82
+ return ns && s.startsWith(ns) ? s.slice(ns.length) : s;
83
+ }
84
+
85
+ const stripDet = (tokens) =>
86
+ tokens.length > 1 && DET.has(tokens[0].toLowerCase()) ? tokens.slice(1) : tokens;
87
+
88
+ /** Resolve a 1–2 word noun phrase: PROPERNAME | code-ref | NOUN | ADJ NOUN.
89
+ * Returns { term, individual, extras, unknown } — `term` null on a miss with
90
+ * the undeclared tokens in `unknown` (empty `unknown` = structurally
91
+ * unparseable phrase → the caller returns a hard null). `extras` carries the
92
+ * pattern-8 adjective triples (subclass axioms / hasValue restriction). */
93
+ function resolveNP(lexicon, tokensIn) {
94
+ const ns = lexicon.ns;
95
+ const tokens = stripDet(tokensIn);
96
+ if (tokens.length === 1) {
97
+ const t = tokens[0];
98
+ const proper = lookupProperName(lexicon, t);
99
+ if (proper) return { term: `${ns}${proper}`, individual: true, extras: [], unknown: [] };
100
+ if (CODE_REF.test(t)) return { term: `${ns}${t}`, individual: true, extras: [], unknown: [] };
101
+ const noun = lookupNoun(lexicon, t);
102
+ if (noun) return { term: `${ns}${noun.lemma}`, individual: false, noun, extras: [], unknown: [] };
103
+ return { term: null, individual: false, extras: [], unknown: [t] };
104
+ }
105
+ if (tokens.length === 2) {
106
+ const adj = lookupAdjective(lexicon, tokens[0]);
107
+ const noun = lookupNoun(lexicon, tokens[1]);
108
+ if (adj && noun) {
109
+ const term = `${ns}${adj.lemma}-${noun.lemma}`;
110
+ const extras = [
111
+ { subject: term, predicate: "rdfs:subClassOf", object: `${ns}${noun.lemma}`, kind: "rdfs:subClassOf" },
112
+ ];
113
+ if (adj.type === "subclass") {
114
+ // the adjective itself denotes a class: legacy-module ⊑ module, ⊑ legacy
115
+ extras.push({ subject: term, predicate: "rdfs:subClassOf", object: `${ns}${adj.lemma}`, kind: "rdfs:subClassOf" });
116
+ } else {
117
+ // data adjective: subclass-with-restriction on the boolean-ish property
118
+ const r = `${ns}has-${adj.lemma}`;
119
+ extras.push(
120
+ { subject: r, predicate: "rdf:type", object: "owl:Restriction", kind: "owl:hasValue" },
121
+ { subject: r, predicate: "owl:onProperty", object: adj.property || `${ns}${adj.lemma}`, kind: "owl:hasValue" },
122
+ { subject: r, predicate: "owl:hasValue", object: adj.value ?? "true", kind: "owl:hasValue" },
123
+ { subject: term, predicate: "rdfs:subClassOf", object: r, kind: "owl:hasValue" },
124
+ );
125
+ }
126
+ return { term, individual: false, noun, extras, unknown: [] };
127
+ }
128
+ // only genuinely undeclared words are residue — a declared word in the
129
+ // wrong slot ("GitLab pipeline") is a structural miss, not an unknown
130
+ const unknown = tokens.filter((t) => !classify(t, lexicon));
131
+ return { term: null, individual: false, extras: [], unknown };
132
+ }
133
+ // 0 or 3+ tokens: not a fragment NP. Name the undeclared words if any.
134
+ return { term: null, individual: false, extras: [], unknown: tokens.filter((t) => !classify(t, lexicon)) };
135
+ }
136
+
137
+ /** The shared miss result: a structural fit with undeclared words returns the
138
+ * pattern + residue (triples empty); a fit with only declared-but-unusable
139
+ * phrasing returns null — the honest fall-through either way. */
140
+ function missOrNull(pattern, nps, extraUnknown = []) {
141
+ const residue = [...extraUnknown, ...nps.flatMap((np) => np.unknown)];
142
+ return residue.length ? { pattern, triples: [], residue } : null;
143
+ }
144
+
145
+ const hit = (pattern, nps, triples, more = {}) => ({
146
+ pattern,
147
+ triples: [...nps.flatMap((np) => np.extras), ...triples],
148
+ residue: [],
149
+ ...more,
150
+ });
151
+
152
+ /** Pattern 3 — "N1 VERB N2" / "PROPERNAME VERBs PROPERNAME" → object-property
153
+ * assertion. Also the no-declared-verb 3-token shape: both ends resolvable →
154
+ * residue names the middle token (the future "if you mean X…" hook). */
155
+ function parseRelation(lexicon, toks, lower) {
156
+ for (let i = 1; i < toks.length - 1; i += 1) {
157
+ const verb = lookupVerb(lexicon, lower[i]);
158
+ if (!verb) continue;
159
+ let objStart = i + 1;
160
+ if (verb.prep) {
161
+ if (lower[objStart] !== verb.prep) continue;
162
+ objStart += 1;
163
+ if (objStart >= toks.length) continue;
164
+ }
165
+ const np1 = resolveNP(lexicon, toks.slice(0, i));
166
+ const np2 = resolveNP(lexicon, toks.slice(objStart));
167
+ if (np1.term == null || np2.term == null) return missOrNull(PATTERN_RELATION, [np1, np2]);
168
+ return hit(PATTERN_RELATION, [np1, np2], [
169
+ { subject: np1.term, predicate: predicateOf(verb, lexicon.ns), object: np2.term, kind: "owl:ObjectProperty" },
170
+ ]);
171
+ }
172
+ const content = toks.filter((t) => !DET.has(t.toLowerCase()));
173
+ if (content.length === 3 && !classify(content[1], lexicon)) {
174
+ const np1 = resolveNP(lexicon, [content[0]]);
175
+ const np2 = resolveNP(lexicon, [content[2]]);
176
+ if (np1.term != null && np2.term != null) return { pattern: PATTERN_RELATION, triples: [], residue: [content[1]] };
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /** Pattern 8 (copula arm) — "X is ADJ": data adjective → datatype-property
182
+ * assertion; subclass adjective → rdf:type (individual) / rdfs:subClassOf. */
183
+ function adjectiveCopula(lexicon, pattern, np1, adj) {
184
+ const ns = lexicon.ns;
185
+ if (np1.term == null) return missOrNull(pattern, [np1]);
186
+ if (adj.type === "data") {
187
+ return hit(pattern, [np1], [
188
+ { subject: np1.term, predicate: adj.property || `${ns}${adj.lemma}`, object: adj.value ?? "true", kind: "owl:DatatypeProperty" },
189
+ ]);
190
+ }
191
+ const predicate = np1.individual ? "rdf:type" : "rdfs:subClassOf";
192
+ return hit(pattern, [np1], [
193
+ { subject: np1.term, predicate, object: `${ns}${adj.lemma}`, kind: predicate },
194
+ ]);
195
+ }
196
+
197
+ /** Pattern 4 — "every N1 that VERBs a N2 is a N3" → someValuesFrom restriction:
198
+ * (N1 ⊓ ∃VERB.N2) ⊑ N3, flattened onto readable deterministic node names. */
199
+ function parseRestriction(lexicon, toks, lower, thatIdx) {
200
+ const ns = lexicon.ns;
201
+ const isIdx = lower.indexOf("is", thatIdx + 2);
202
+ if (isIdx < 0 || thatIdx + 1 >= isIdx) return null;
203
+ const verb = lookupVerb(lexicon, lower[thatIdx + 1]);
204
+ const np1 = resolveNP(lexicon, toks.slice(1, thatIdx));
205
+ let objStart = thatIdx + 2;
206
+ if (verb?.prep) {
207
+ if (lower[objStart] !== verb.prep) return null;
208
+ objStart += 1;
209
+ }
210
+ const np2 = resolveNP(lexicon, toks.slice(objStart, isIdx));
211
+ const np3 = resolveNP(lexicon, toks.slice(isIdx + 1));
212
+ if (!verb) return missOrNull(PATTERN_SOME_VALUES_FROM, [np1, np2, np3], [toks[thatIdx + 1]]);
213
+ if (np1.term == null || np2.term == null || np3.term == null) {
214
+ return missOrNull(PATTERN_SOME_VALUES_FROM, [np1, np2, np3]);
215
+ }
216
+ if (np1.individual || np2.individual || np3.individual) return null; // class-level pattern only
217
+ const pred = predicateOf(verb, ns);
218
+ const k = "owl:someValuesFrom";
219
+ const r = `${ns}some-${local(lexicon, pred)}-${local(lexicon, np2.term)}`;
220
+ const inter = `${ns}${local(lexicon, np1.term)}-that-${local(lexicon, pred)}-${local(lexicon, np2.term)}`;
221
+ return hit(PATTERN_SOME_VALUES_FROM, [np1, np2, np3], [
222
+ { subject: r, predicate: "rdf:type", object: "owl:Restriction", kind: k },
223
+ { subject: r, predicate: "owl:onProperty", object: pred, kind: k },
224
+ { subject: r, predicate: "owl:someValuesFrom", object: np2.term, kind: k },
225
+ { subject: inter, predicate: "owl:intersectionOf", object: np1.term, kind: k },
226
+ { subject: inter, predicate: "owl:intersectionOf", object: r, kind: k },
227
+ { subject: inter, predicate: "rdfs:subClassOf", object: np3.term, kind: k },
228
+ ]);
229
+ }
230
+
231
+ /** Pattern 5 — "every N has at least|at most|exactly n N2" → cardinality
232
+ * restriction on `${ns}has` (owl:onClass records the counted class — the
233
+ * qualified-form question is left as a documented open point, see README). */
234
+ function parseCardinality(lexicon, toks, lower, hasIdx) {
235
+ const ns = lexicon.ns;
236
+ let kind = null;
237
+ let nIdx = -1;
238
+ if (lower[hasIdx + 1] === "at" && lower[hasIdx + 2] === "least") { kind = "owl:minCardinality"; nIdx = hasIdx + 3; }
239
+ else if (lower[hasIdx + 1] === "at" && lower[hasIdx + 2] === "most") { kind = "owl:maxCardinality"; nIdx = hasIdx + 3; }
240
+ else if (lower[hasIdx + 1] === "exactly") { kind = "owl:cardinality"; nIdx = hasIdx + 2; }
241
+ else return null;
242
+ const n = numberOf(lower[nIdx]);
243
+ if (n == null || nIdx + 1 >= toks.length) return null;
244
+ const np1 = resolveNP(lexicon, toks.slice(1, hasIdx));
245
+ const np2 = resolveNP(lexicon, toks.slice(nIdx + 1));
246
+ if (np1.term == null || np2.term == null) return missOrNull(PATTERN_CARDINALITY, [np1, np2]);
247
+ if (np1.individual || np2.individual) return null;
248
+ const tag = { "owl:minCardinality": "min", "owl:maxCardinality": "max", "owl:cardinality": "exactly" }[kind];
249
+ const r = `${ns}${tag}-${n}-${local(lexicon, np2.term)}`;
250
+ return hit(PATTERN_CARDINALITY, [np1, np2], [
251
+ { subject: r, predicate: "rdf:type", object: "owl:Restriction", kind },
252
+ { subject: r, predicate: "owl:onProperty", object: `${ns}has`, kind },
253
+ { subject: r, predicate: kind, object: String(n), kind, n },
254
+ { subject: r, predicate: "owl:onClass", object: np2.term, kind },
255
+ { subject: np1.term, predicate: "rdfs:subClassOf", object: r, kind },
256
+ ], { n });
257
+ }
258
+
259
+ /** Patterns 1, 4, 5 and 8's "every …" arm. */
260
+ function parseEvery(lexicon, toks, lower) {
261
+ const thatIdx = lower.indexOf("that");
262
+ if (thatIdx > 1) return parseRestriction(lexicon, toks, lower, thatIdx);
263
+ const hasIdx = lower.indexOf("has");
264
+ if (hasIdx > 1 && (lower[hasIdx + 1] === "at" || lower[hasIdx + 1] === "exactly")) {
265
+ return parseCardinality(lexicon, toks, lower, hasIdx);
266
+ }
267
+ const isIdx = lower.indexOf("is");
268
+ if (isIdx <= 1 || isIdx === toks.length - 1) return null;
269
+ const np1 = resolveNP(lexicon, toks.slice(1, isIdx));
270
+ const rest = toks.slice(isIdx + 1);
271
+ if (rest.length === 1) {
272
+ const adj = lookupAdjective(lexicon, rest[0]);
273
+ if (adj) return adjectiveCopula(lexicon, PATTERN_ADJECTIVE, np1, adj);
274
+ }
275
+ const np2 = resolveNP(lexicon, rest);
276
+ if (np1.term == null || np2.term == null) return missOrNull(PATTERN_SUB_CLASS_OF, [np1, np2]);
277
+ if (np1.individual || np2.individual) return null; // "every X is chat.mjs" — not the fragment
278
+ return hit(PATTERN_SUB_CLASS_OF, [np1, np2], [
279
+ { subject: np1.term, predicate: "rdfs:subClassOf", object: np2.term, kind: "rdfs:subClassOf" },
280
+ ]);
281
+ }
282
+
283
+ /** Pattern 6 — "no N1 is a N2" → owl:disjointWith. */
284
+ function parseDisjoint(lexicon, toks, lower) {
285
+ const isIdx = lower.indexOf("is");
286
+ if (isIdx <= 1 || isIdx === toks.length - 1) return null;
287
+ const np1 = resolveNP(lexicon, toks.slice(1, isIdx));
288
+ const np2 = resolveNP(lexicon, toks.slice(isIdx + 1));
289
+ if (np1.term == null || np2.term == null) return missOrNull(PATTERN_DISJOINT_WITH, [np1, np2]);
290
+ if (np1.individual || np2.individual) return null;
291
+ return hit(PATTERN_DISJOINT_WITH, [np1, np2], [
292
+ { subject: np1.term, predicate: "owl:disjointWith", object: np2.term, kind: "owl:disjointWith" },
293
+ ]);
294
+ }
295
+
296
+ /** Pattern 7 — "N1's N2 is VALUE" / "the N2 of N1 is VALUE": data or object
297
+ * property assertion per the possessive noun's DECLARED typing (undeclared
298
+ * typing defaults to data — a literal value is the honest floor). */
299
+ function buildPossessive(lexicon, ownerToks, headToks, valueToks) {
300
+ const ns = lexicon.ns;
301
+ const owner = resolveNP(lexicon, ownerToks);
302
+ if (headToks.length !== 1) return null;
303
+ const head = lookupNoun(lexicon, headToks[0]);
304
+ if (!head) return missOrNull(PATTERN_POSSESSIVE, [owner], [headToks[0]]);
305
+ if (owner.term == null) return missOrNull(PATTERN_POSSESSIVE, [owner]);
306
+ if (!valueToks.length) return null;
307
+ const predicate = `${ns}${head.lemma}`;
308
+ if ((head.property || "data") === "object") {
309
+ const value = resolveNP(lexicon, valueToks);
310
+ if (value.term == null) return missOrNull(PATTERN_POSSESSIVE, [owner, value]);
311
+ return hit(PATTERN_POSSESSIVE, [owner, value], [
312
+ { subject: owner.term, predicate, object: value.term, kind: "owl:ObjectProperty" },
313
+ ]);
314
+ }
315
+ return hit(PATTERN_POSSESSIVE, [owner], [
316
+ { subject: owner.term, predicate, object: valueToks.join(" "), kind: "owl:DatatypeProperty" },
317
+ ]);
318
+ }
319
+
320
+ function parsePossessive(lexicon, toks, lower) {
321
+ const ownerRaw = toks[0].replace(/'s$/i, "");
322
+ const isIdx = lower.indexOf("is");
323
+ if (isIdx < 2 || !ownerRaw) return null;
324
+ return buildPossessive(lexicon, [ownerRaw], toks.slice(1, isIdx), toks.slice(isIdx + 1));
325
+ }
326
+
327
+ function parseOfForm(lexicon, toks, lower) {
328
+ const ofIdx = lower.indexOf("of");
329
+ const isIdx = lower.indexOf("is", ofIdx + 1);
330
+ if (ofIdx < 2 || isIdx < ofIdx + 2) return null;
331
+ return buildPossessive(lexicon, toks.slice(ofIdx + 1, isIdx), toks.slice(1, ofIdx), toks.slice(isIdx + 1));
332
+ }
333
+
334
+ /** Patterns 2 (class assertion), 1's bare-copula variant, and 8's copula arm. */
335
+ function parseCopula(lexicon, toks, lower, isIdx) {
336
+ const np1 = resolveNP(lexicon, toks.slice(0, isIdx));
337
+ const rest = toks.slice(isIdx + 1);
338
+ if (!rest.length) return null;
339
+ if (rest.length === 1) {
340
+ const adj = lookupAdjective(lexicon, rest[0]);
341
+ if (adj) return adjectiveCopula(lexicon, PATTERN_ADJECTIVE, np1, adj);
342
+ }
343
+ const np2 = resolveNP(lexicon, rest);
344
+ if (np1.term == null || np2.term == null) {
345
+ return missOrNull(np1.individual ? PATTERN_TYPE_ASSERTION : PATTERN_SUB_CLASS_OF, [np1, np2]);
346
+ }
347
+ if (np2.individual) return null; // "chat.mjs is sessions.mjs" — identity is not in the fragment
348
+ if (np1.individual) {
349
+ return hit(PATTERN_TYPE_ASSERTION, [np1, np2], [
350
+ { subject: np1.term, predicate: "rdf:type", object: np2.term, kind: "rdf:type" },
351
+ ]);
352
+ }
353
+ return hit(PATTERN_SUB_CLASS_OF, [np1, np2], [
354
+ { subject: np1.term, predicate: "rdfs:subClassOf", object: np2.term, kind: "rdfs:subClassOf" },
355
+ ]);
356
+ }
357
+
358
+ /** Parse one sentence against the 8-pattern ACE-OWL sub-fragment. See the file
359
+ * header for the result contract; `lexicon` defaults to the committed core
360
+ * under the library's own neutral DEFAULT_NS ("ex:") when the caller doesn't
361
+ * supply one. */
25
362
  export function parseAce(sentence, lexicon = loadLexicon()) {
26
- return parseAceLib(sentence, lexicon);
363
+ const toks = tokenize(sentence);
364
+ if (toks.length < 3) return null;
365
+ const lower = toks.map((t) => t.toLowerCase());
366
+ if (lower[0] === "every") return parseEvery(lexicon, toks, lower);
367
+ if (lower[0] === "no") return parseDisjoint(lexicon, toks, lower);
368
+ if (/'s$/.test(lower[0]) && lower[0].length > 2) return parsePossessive(lexicon, toks, lower);
369
+ if (lower[0] === "the" && lower.includes("of") && lower.includes("is")) {
370
+ return parseOfForm(lexicon, toks, lower);
371
+ }
372
+ const isIdx = lower.indexOf("is");
373
+ if (isIdx > 0) return parseCopula(lexicon, toks, lower, isIdx);
374
+ return parseRelation(lexicon, toks, lower);
27
375
  }
@@ -0,0 +1,302 @@
1
+ {
2
+ "comment": "tmct's starter software-domain lexicon (ROADMAP Phase 2, item 2). Every word the ACE-OWL sub-fragment parser (src/grammar/ace.mjs) is allowed to understand is DECLARED here — tmct never guesses a word's category. Nouns may declare a possessive property typing ('data' or 'object', pattern 7); adjectives MUST declare a type ('subclass' forms a class, 'data' asserts a boolean-ish datatype property, pattern 8); verbs may declare a preposition ('depend' + 'on' → dependsOn). Extend via loadLexicon(extra) with this same shape.",
3
+ "nouns": {
4
+ "module": {},
5
+ "class": {},
6
+ "function": {},
7
+ "method": {},
8
+ "attribute": {},
9
+ "variable": {},
10
+ "constant": {},
11
+ "test": {},
12
+ "suite": {},
13
+ "service": {},
14
+ "repository": {},
15
+ "branch": {},
16
+ "commit": {},
17
+ "tag": {},
18
+ "release": {},
19
+ "package": {},
20
+ "library": {},
21
+ "framework": {},
22
+ "api": {},
23
+ "endpoint": {},
24
+ "route": {},
25
+ "handler": {},
26
+ "controller": {},
27
+ "model": {},
28
+ "view": {},
29
+ "template": {},
30
+ "component": {},
31
+ "interface": {},
32
+ "type": {},
33
+ "category": {},
34
+ "kind": {},
35
+ "artifact": {},
36
+ "routine": {},
37
+ "part": {},
38
+ "helper": {},
39
+ "operation": {},
40
+ "change": {},
41
+ "schema": {},
42
+ "database": {},
43
+ "table": {},
44
+ "query": {},
45
+ "index": { "plural": "indices" },
46
+ "cache": {},
47
+ "queue": {},
48
+ "worker": {},
49
+ "job": {},
50
+ "task": {},
51
+ "bug": {},
52
+ "defect": {},
53
+ "issue": {},
54
+ "ticket": {},
55
+ "feature": {},
56
+ "requirement": {},
57
+ "specification": {},
58
+ "document": {},
59
+ "developer": {},
60
+ "engineer": {},
61
+ "user": {},
62
+ "visitor": {},
63
+ "team": {},
64
+ "project": {},
65
+ "codebase": {},
66
+ "file": {},
67
+ "directory": {},
68
+ "folder": {},
69
+ "line": {},
70
+ "symbol": {},
71
+ "identifier": {},
72
+ "comment": {},
73
+ "docstring": {},
74
+ "string": {},
75
+ "number": {},
76
+ "list": {},
77
+ "array": {},
78
+ "graph": {},
79
+ "node": {},
80
+ "edge": {},
81
+ "triple": {},
82
+ "fact": {},
83
+ "ontology": {},
84
+ "lexicon": {},
85
+ "grammar": {},
86
+ "sentence": {},
87
+ "utterance": {},
88
+ "session": {},
89
+ "response": {},
90
+ "request": {},
91
+ "question": {},
92
+ "answer": {},
93
+ "error": {},
94
+ "exception": {},
95
+ "warning": {},
96
+ "log": {},
97
+ "metric": {},
98
+ "benchmark": {},
99
+ "pipeline": {},
100
+ "build": {},
101
+ "deployment": {},
102
+ "environment": {},
103
+ "config": {},
104
+ "configuration": {},
105
+ "setting": {},
106
+ "option": {},
107
+ "flag": {},
108
+ "argument": {},
109
+ "parameter": {},
110
+ "value": {},
111
+ "result": {},
112
+ "output": {},
113
+ "input": {},
114
+ "server": {},
115
+ "client": {},
116
+ "protocol": {},
117
+ "message": {},
118
+ "event": {},
119
+ "hook": {},
120
+ "plugin": {},
121
+ "script": {},
122
+ "tool": {},
123
+ "command": {},
124
+ "prompt": {},
125
+ "token": {},
126
+ "parser": {},
127
+ "compiler": {},
128
+ "linter": {},
129
+ "formatter": {},
130
+ "runtime": {},
131
+ "process": {},
132
+ "thread": {},
133
+ "loop": {},
134
+ "statement": {},
135
+ "expression": {},
136
+ "keyword": {},
137
+ "scope": {},
138
+ "callback": {},
139
+ "promise": {},
140
+ "iterator": {},
141
+ "generator": {},
142
+ "unit": {},
143
+ "risk": {},
144
+ "prototype": {},
145
+ "milestone": {},
146
+ "sprint": {},
147
+ "backlog": {},
148
+ "roadmap": {},
149
+ "phase": {},
150
+ "pattern": {},
151
+ "smell": {},
152
+ "coverage": {},
153
+ "mock": {},
154
+ "stub": {},
155
+ "fixture": {},
156
+ "assertion": {},
157
+ "snapshot": {},
158
+ "regression": {},
159
+ "migration": {},
160
+ "refactor": {},
161
+ "review": {},
162
+ "merge": {},
163
+ "license": { "property": "data" },
164
+ "version": { "property": "data" },
165
+ "name": { "property": "data" },
166
+ "path": { "property": "data" },
167
+ "size": { "property": "data" },
168
+ "status": { "property": "data" },
169
+ "language": { "property": "data" },
170
+ "extension": { "property": "data" },
171
+ "churn": { "property": "data" },
172
+ "impact": { "property": "data" },
173
+ "complexity": { "property": "data" },
174
+ "latency": { "property": "data" },
175
+ "duration": { "property": "data" },
176
+ "frequency": { "property": "data" },
177
+ "severity": { "property": "data" },
178
+ "owner": { "property": "object" },
179
+ "maintainer": { "property": "object" },
180
+ "author": { "property": "object" },
181
+ "reviewer": { "property": "object" },
182
+ "parent": { "property": "object" },
183
+ "dependency": { "property": "object" }
184
+ },
185
+ "verbs": {
186
+ "import": {},
187
+ "call": {},
188
+ "test": {},
189
+ "contain": {},
190
+ "extend": {},
191
+ "use": {},
192
+ "depend": { "prep": "on" },
193
+ "rely": { "prep": "on" },
194
+ "inherit": { "prep": "from" },
195
+ "belong": { "prep": "to" },
196
+ "point": { "prep": "to" },
197
+ "implement": {},
198
+ "override": {},
199
+ "export": {},
200
+ "define": {},
201
+ "declare": {},
202
+ "reference": {},
203
+ "invoke": {},
204
+ "wrap": {},
205
+ "mock": {},
206
+ "cover": {},
207
+ "document": {},
208
+ "describe": {},
209
+ "modify": {},
210
+ "touch": {},
211
+ "fix": {},
212
+ "break": {},
213
+ "introduce": {},
214
+ "deprecate": {},
215
+ "replace": {},
216
+ "own": {},
217
+ "maintain": {},
218
+ "review": {},
219
+ "merge": {},
220
+ "revert": {},
221
+ "deploy": {},
222
+ "run": {},
223
+ "execute": {},
224
+ "load": {},
225
+ "parse": {},
226
+ "emit": {},
227
+ "validate": {},
228
+ "log": {},
229
+ "throw": {},
230
+ "catch": {},
231
+ "create": {},
232
+ "delete": {},
233
+ "update": {},
234
+ "expose": {},
235
+ "consume": {},
236
+ "produce": {},
237
+ "generate": {},
238
+ "configure": {},
239
+ "install": {},
240
+ "publish": {},
241
+ "release": {},
242
+ "ship": {},
243
+ "watch": {},
244
+ "trigger": {},
245
+ "build": {},
246
+ "write": {},
247
+ "read": {},
248
+ "have": {}
249
+ },
250
+ "adjectives": {
251
+ "legacy": { "type": "subclass" },
252
+ "internal": { "type": "subclass" },
253
+ "external": { "type": "subclass" },
254
+ "public": { "type": "subclass" },
255
+ "private": { "type": "subclass" },
256
+ "abstract": { "type": "subclass" },
257
+ "static": { "type": "subclass" },
258
+ "async": { "type": "subclass" },
259
+ "experimental": { "type": "subclass" },
260
+ "stable": { "type": "subclass" },
261
+ "core": { "type": "subclass" },
262
+ "shared": { "type": "subclass" },
263
+ "global": { "type": "subclass" },
264
+ "local": { "type": "subclass" },
265
+ "generated": { "type": "subclass" },
266
+ "standalone": { "type": "subclass" },
267
+ "primary": { "type": "subclass" },
268
+ "secondary": { "type": "subclass" },
269
+ "deprecated": { "type": "data" },
270
+ "fast": { "type": "data" },
271
+ "slow": { "type": "data" },
272
+ "large": { "type": "data" },
273
+ "small": { "type": "data" },
274
+ "flaky": { "type": "data" },
275
+ "tested": { "type": "data" },
276
+ "documented": { "type": "data" },
277
+ "buggy": { "type": "data" },
278
+ "broken": { "type": "data" },
279
+ "green": { "type": "data" },
280
+ "deterministic": { "type": "data" },
281
+ "pure": { "type": "data" },
282
+ "empty": { "type": "data" },
283
+ "stale": { "type": "data" }
284
+ },
285
+ "properNames": [
286
+ "tmct",
287
+ "Node",
288
+ "npm",
289
+ "JavaScript",
290
+ "TypeScript",
291
+ "Python",
292
+ "Java",
293
+ "Git",
294
+ "GitHub",
295
+ "GitLab",
296
+ "ESLint",
297
+ "Linux",
298
+ "macOS",
299
+ "Windows",
300
+ "Polycode"
301
+ ]
302
+ }
@@ -1,45 +1,217 @@
1
- // grammar/lexicon.mjs — thin re-export shim over @polycode-projects/ace-owl
2
- // (PLAN_OSS_ACE_PARSER.md / PLAN_AGENTS.md §3's "ace-owl open-source
3
- // extraction"). The declared lexicon behind tmct's ACE-OWL sub-fragment
4
- // parser now lives in the extracted package (packages/ace-owl/src/
5
- // lexicon.mjs)this file exists ONLY to bind the package's neutral,
6
- // caller-supplied namespace to tmct's own "tmct:" CURIE prefix, so every
7
- // consumer in this repo (chat.mjs, extensions.mjs, the grammar/ontology
8
- // tests, …) gets BYTE-IDENTICAL behaviour to before the extraction, with a
9
- // single shared implementation instead of two copies drifting apart.
1
+ // grammar/lexicon.mjs — the declared lexicon behind tmct's ACE-OWL
2
+ // sub-fragment parser (ROADMAP Phase 2, item 2; see ace.mjs). The lexicon is
3
+ // LOAD-BEARING: the grammar is only deterministic because every noun, verb
4
+ // (with any preposition), adjective (with its declared type) and proper name
5
+ // is DECLARED the parser never guesses a word's category. Undeclared words
6
+ // route a sentence out of the grammar (a miss is a feature, not a bug — see
7
+ // ace.mjs).
10
8
  //
11
- // Everything else (morphology, classify(), the committed starter vocabulary)
12
- // is the package's see packages/ace-owl/README.md for the full API and
13
- // packages/ace-owl/src/lexicon-core.json for the vocabulary itself (also the
14
- // canonical copy now; this repo no longer carries its own lexicon-core.json).
15
- import * as aceOwl from "@polycode-projects/ace-owl";
16
-
17
- export const TMCT_NS = "tmct:";
18
-
19
- export const {
20
- DETERMINERS, QUANTIFIERS, numberOf, thirdPerson,
21
- lookupNoun, lookupVerb, lookupAdjective, lookupProperName,
22
- } = aceOwl;
23
-
24
- /** loadLexicon(extra) bound to tmct's own "tmct:" namespace, so the
25
- * no-extra call stays the same cached, byte-identical lexicon it always
26
- * was (see ace-owl's loadLexicon(extra, ns) ns defaults to the package's
27
- * own neutral "ex:" when not passed, which tmct never wants). */
28
- export function loadLexicon(extra) {
29
- return aceOwl.loadLexicon(extra, TMCT_NS);
30
- }
31
-
32
- /** predicateOf(verbEntry) — bound to tmct's own "tmct:" namespace, same
33
- * reasoning as loadLexicon above. */
34
- export function predicateOf(verbEntry) {
35
- return aceOwl.predicateOf(verbEntry, TMCT_NS);
36
- }
37
-
38
- /** classify(word, lexicon?) re-exported with `lexicon` re-defaulted to
39
- * THIS module's own loadLexicon() (the "tmct:"-namespaced core), not the
40
- * package's neutral default; every no-lexicon call site (chat.mjs,
41
- * grammar/ace.mjs's own resolveNP, the grammar tests) must keep seeing
42
- * "tmct:"-prefixed verb predicates in the classification it returns. */
9
+ // Data lives in lexicon-core.json (plain, diffable), tmct's starter
10
+ // software-domain vocabulary. Extend it via loadLexicon(extra) with the same
11
+ // JSON shape (extensions.mjs's mergedLexiconExtra); extra entries win on
12
+ // conflict.
13
+ //
14
+ // Namespace: every lexicon carries a `.ns` field (the CURIE prefix ace.mjs
15
+ // stamps onto every term it mints) — always "tmct:" here (DEFAULT_NS).
16
+ // ace.mjs and predicateOf() read it off the lexicon rather than hardcoding
17
+ // the prefix inline, purely so a caller can supply its own already-namespaced
18
+ // lexicon; tmct itself only ever runs the one namespace.
19
+ //
20
+ // Morphology is deliberately tiny and deterministic (no NLP dependency): a
21
+ // suffix-fold for plurals/3rd-person-singular ("repositories"→repository,
22
+ // "relies"→rely, "classes"→class, "uses"→use) plus an optional declared
23
+ // irregular `plural` ("indices"). Anything the fold can't reach is simply not
24
+ // in the lexiconhonest, not clever.
25
+
26
+ import { readFileSync } from "node:fs";
27
+ import { fileURLToPath } from "node:url";
28
+ import { dirname, join } from "node:path";
29
+
30
+ const CORE_FILE = join(dirname(fileURLToPath(import.meta.url)), "lexicon-core.json");
31
+
32
+ /** The CURIE namespace every tmct lexicon mints terms under. */
33
+ export const DEFAULT_NS = "tmct:";
34
+
35
+ /** Determiner tokens the grammar consumes (pattern table's every/a/no…). */
36
+ export const DETERMINERS = Object.freeze({
37
+ every: "universal",
38
+ a: "indefinite",
39
+ an: "indefinite",
40
+ the: "definite",
41
+ no: "negative",
42
+ });
43
+
44
+ /** The cardinality quantifier phrases (pattern 5) → the OWL term they select. */
45
+ export const QUANTIFIERS = Object.freeze({
46
+ "at least": "owl:minCardinality",
47
+ "at most": "owl:maxCardinality",
48
+ exactly: "owl:cardinality",
49
+ });
50
+
51
+ const NUMBER_WORDS = Object.freeze({
52
+ one: 1, two: 2, three: 3, four: 4, five: 5,
53
+ six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
54
+ });
55
+
56
+ /** Parse a cardinality count token: a digit run or a small number word. */
57
+ export function numberOf(word) {
58
+ const w = String(word ?? "").trim().toLowerCase();
59
+ if (/^\d+$/.test(w)) return Number(w);
60
+ return NUMBER_WORDS[w] ?? null;
61
+ }
62
+
63
+ /** 3rd-person-singular surface form of a verb lemma ("import"→imports,
64
+ * "rely"→relies, "catch"→catches, "have"→has) — the predicate spelling. */
65
+ export function thirdPerson(base) {
66
+ const b = String(base);
67
+ if (b === "have") return "has";
68
+ if (/[^aeiou]y$/.test(b)) return `${b.slice(0, -1)}ies`;
69
+ if (/(s|x|z|ch|sh)$/.test(b)) return `${b}es`;
70
+ return `${b}s`;
71
+ }
72
+
73
+ /** The URI-style predicate a verb entry emits: a declared override
74
+ * (verbEntry.predicate, namespace-independent), or `${ns}<3sg lemma>` with
75
+ * any preposition camel-appended ("depend on" → `${ns}dependsOn`). `ns`
76
+ * defaults to DEFAULT_NS for a caller that doesn't thread one through. */
77
+ export function predicateOf(verbEntry, ns = DEFAULT_NS) {
78
+ if (verbEntry.predicate) return verbEntry.predicate;
79
+ const prep = verbEntry.prep ? verbEntry.prep[0].toUpperCase() + verbEntry.prep.slice(1) : "";
80
+ return `${ns}${thirdPerson(verbEntry.lemma)}${prep}`;
81
+ }
82
+
83
+ /** Deterministic singular/base-form candidates for a surface word, most
84
+ * specific first: as-is, -ies→y, -(s|x|z|ch|sh)es→stem, -s→stem. The FIRST
85
+ * candidate found in the relevant map wins ("classes"→class before "classe";
86
+ * "uses"→"us" misses, "use" hits). */
87
+ function foldCandidates(word) {
88
+ const w = String(word);
89
+ const out = [w];
90
+ if (w.length > 4 && /[a-z]ies$/.test(w)) out.push(`${w.slice(0, -3)}y`);
91
+ if (/(ses|xes|zes|ches|shes)$/.test(w)) out.push(w.slice(0, -2));
92
+ if (/[a-z]s$/.test(w) && !/ss$/.test(w)) out.push(w.slice(0, -1));
93
+ if (w === "has") out.push("have");
94
+ return out;
95
+ }
96
+
97
+ const NOUN_PROPERTY_TYPES = new Set(["data", "object"]);
98
+ const ADJECTIVE_TYPES = new Set(["subclass", "data"]);
99
+
100
+ /** Merge one raw lexicon block ({nouns, verbs, adjectives, properNames}) into
101
+ * the lookup maps, validating the declared typings (bad declarations throw —
102
+ * a lexicon that lies would make the grammar guess). */
103
+ function ingest(lex, raw = {}) {
104
+ for (const [lemma, e] of Object.entries(raw.nouns || {})) {
105
+ const entry = { lemma, ...(e || {}) };
106
+ if (entry.property && !NOUN_PROPERTY_TYPES.has(entry.property)) {
107
+ throw new Error(`lexicon noun "${lemma}": property must be "data" or "object", got ${JSON.stringify(entry.property)}`);
108
+ }
109
+ lex.nouns.set(lemma, entry);
110
+ if (entry.plural) lex.nounPlurals.set(entry.plural, lemma);
111
+ }
112
+ for (const [lemma, e] of Object.entries(raw.verbs || {})) {
113
+ lex.verbs.set(lemma, { lemma, ...(e || {}) });
114
+ }
115
+ for (const [lemma, e] of Object.entries(raw.adjectives || {})) {
116
+ const entry = { lemma, ...(e || {}) };
117
+ if (!ADJECTIVE_TYPES.has(entry.type)) {
118
+ throw new Error(`lexicon adjective "${lemma}": type must be "subclass" or "data", got ${JSON.stringify(entry.type)}`);
119
+ }
120
+ lex.adjectives.set(lemma, entry);
121
+ }
122
+ for (const name of raw.properNames || []) {
123
+ lex.properNames.set(String(name).toLowerCase(), String(name));
124
+ }
125
+ }
126
+
127
+ // Cache keyed by namespace — a no-extra load is immutable at runtime and
128
+ // cached per-ns, so two consumers requesting different namespaces (or the
129
+ // same one repeatedly) each get a stable, shared lexicon object.
130
+ const coreCacheByNs = new Map();
131
+
132
+ /** Load the lexicon: the committed core vocabulary, optionally merged with a
133
+ * caller-supplied `extra` block of the same JSON shape (extra entries win).
134
+ * `ns` (default DEFAULT_NS) is stamped onto the returned lexicon as `.ns` —
135
+ * the CURIE prefix ace.mjs mints new terms under. The no-extra result is
136
+ * cached per-ns (the JSON is committed, immutable at runtime). */
137
+ export function loadLexicon(extra, ns = DEFAULT_NS) {
138
+ if (!extra && coreCacheByNs.has(ns)) return coreCacheByNs.get(ns);
139
+ const raw = JSON.parse(readFileSync(CORE_FILE, "utf8"));
140
+ const lex = {
141
+ nouns: new Map(),
142
+ nounPlurals: new Map(),
143
+ verbs: new Map(),
144
+ adjectives: new Map(),
145
+ properNames: new Map(), // lowercased → canonical spelling
146
+ ns,
147
+ };
148
+ ingest(lex, raw);
149
+ if (extra) {
150
+ ingest(lex, extra);
151
+ return lex;
152
+ }
153
+ coreCacheByNs.set(ns, lex);
154
+ return lex;
155
+ }
156
+
157
+ /** Noun lookup with plural folding; returns the entry ({lemma, property?}) or null. */
158
+ export function lookupNoun(lexicon, word) {
159
+ const w = String(word ?? "").toLowerCase();
160
+ const irregular = lexicon.nounPlurals.get(w);
161
+ if (irregular) return lexicon.nouns.get(irregular) ?? null;
162
+ for (const cand of foldCandidates(w)) {
163
+ const hit = lexicon.nouns.get(cand);
164
+ if (hit) return hit;
165
+ }
166
+ return null;
167
+ }
168
+
169
+ /** Verb lookup with 3sg folding; returns the entry ({lemma, prep?, predicate?}) or null. */
170
+ export function lookupVerb(lexicon, word) {
171
+ const w = String(word ?? "").toLowerCase();
172
+ for (const cand of foldCandidates(w)) {
173
+ const hit = lexicon.verbs.get(cand);
174
+ if (hit) return hit;
175
+ }
176
+ return null;
177
+ }
178
+
179
+ /** Adjective lookup (exact lemma); returns {lemma, type, property?, value?} or null. */
180
+ export function lookupAdjective(lexicon, word) {
181
+ return lexicon.adjectives.get(String(word ?? "").toLowerCase()) ?? null;
182
+ }
183
+
184
+ /** Proper-name lookup, case-insensitive; returns the CANONICAL spelling or null. */
185
+ export function lookupProperName(lexicon, word) {
186
+ return lexicon.properNames.get(String(word ?? "").toLowerCase()) ?? null;
187
+ }
188
+
189
+ /** Classify one word (or a two-word quantifier phrase) against the lexicon.
190
+ * Returns {pos, type?, …} or null for an undeclared word. Priority when a
191
+ * word is declared in several categories (e.g. "test" noun+verb): closed-class
192
+ * tokens, then properName > noun > verb > adjective — the grammar itself
193
+ * disambiguates by position, this is the standalone answer. */
43
194
  export function classify(word, lexicon = loadLexicon()) {
44
- return aceOwl.classify(word, lexicon);
195
+ const w = String(word ?? "").trim();
196
+ if (!w) return null;
197
+ const lower = w.toLowerCase();
198
+ if (DETERMINERS[lower]) return { pos: "determiner", type: DETERMINERS[lower] };
199
+ if (QUANTIFIERS[lower]) return { pos: "quantifier", type: QUANTIFIERS[lower] };
200
+ const n = numberOf(lower);
201
+ if (n != null) return { pos: "number", type: "cardinal", value: n };
202
+ const proper = lookupProperName(lexicon, w);
203
+ if (proper) return { pos: "properName", type: "individual", canonical: proper };
204
+ const noun = lookupNoun(lexicon, lower);
205
+ if (noun) {
206
+ return noun.property
207
+ ? { pos: "noun", type: `${noun.property}-property`, lemma: noun.lemma, property: noun.property }
208
+ : { pos: "noun", type: "class", lemma: noun.lemma };
209
+ }
210
+ const verb = lookupVerb(lexicon, lower);
211
+ if (verb) {
212
+ return { pos: "verb", type: "objectProperty", lemma: verb.lemma, predicate: predicateOf(verb, lexicon.ns), ...(verb.prep ? { prep: verb.prep } : {}) };
213
+ }
214
+ const adj = lookupAdjective(lexicon, lower);
215
+ if (adj) return { pos: "adjective", type: adj.type, lemma: adj.lemma };
216
+ return null;
45
217
  }