@polycode-projects/the-mechanical-code-talker 0.2.0 → 0.3.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/ROADMAP.md +5 -2
- package/bin/tmct.mjs +253 -12
- package/corpus/README.md +52 -0
- package/corpus/conceptnet/LICENSE-NOTICE +37 -0
- package/corpus/conceptnet/README.md +103 -0
- package/corpus/conceptnet/fetch-slice.mjs +136 -0
- package/corpus/conceptnet/filter-dump.mjs +89 -0
- package/corpus/conceptnet/slice.jsonl +14258 -0
- package/data/phrasebook/software-phrases.txt +231 -0
- package/data/templates/responses.jsonl +55 -0
- package/package.json +12 -3
- package/src/ask-nlp.mjs +14 -0
- package/src/ask-vocab.mjs +13 -1
- package/src/ask.mjs +92 -493
- package/src/chat.mjs +147 -45
- package/src/corpus/conceptnet-map.toml +251 -0
- package/src/corpus/conceptnet.mjs +155 -0
- package/src/corpus/templates.mjs +104 -0
- package/src/grammar/ace.mjs +341 -0
- package/src/grammar/assert.mjs +40 -0
- package/src/grammar/lexicon-core.json +287 -0
- package/src/grammar/lexicon.mjs +202 -0
- package/src/index.mjs +21 -5
- package/src/interpret/fuzzy.mjs +89 -0
- package/src/interpret/merge.mjs +148 -0
- package/src/interpret/normalize.mjs +117 -0
- package/src/interpret/pipeline.mjs +112 -0
- package/src/interpret/strategies/grammar.mjs +137 -0
- package/src/interpret/strategies/keywords.mjs +185 -0
- package/src/interpret/strategies/noise-strip.mjs +114 -0
- package/src/memory/blocks.mjs +201 -0
- package/src/memory/core.mjs +292 -0
- package/src/memory/fold.mjs +105 -0
- package/src/sessions.mjs +125 -3
- package/src/source.mjs +44 -5
- package/src/tui/app.mjs +173 -0
- package/bin/cli.mjs +0 -226
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// corpus/conceptnet.mjs — the ConceptNet slice loader + memory seeder
|
|
2
|
+
// (ROADMAP Phase 2, "ConceptNet corpus slice").
|
|
3
|
+
//
|
|
4
|
+
// loadSlice(path?) stream corpus/conceptnet/slice.jsonl → assertions
|
|
5
|
+
// loadMap(path?) src/corpus/conceptnet-map.toml → Map(rel → row)
|
|
6
|
+
// toFacts(assertions,map) assertions → appendFact-shaped triples
|
|
7
|
+
// seedMemory(dir, opts) write them into <dir>/.tmct/memory via appendFact
|
|
8
|
+
//
|
|
9
|
+
// The slice is committed data (one JSON object per line: {start, rel, end,
|
|
10
|
+
// surfaceText?, weight}; en→en only; CC-BY-SA 4.0 for ConceptNet-derived rows
|
|
11
|
+
// — see corpus/conceptnet/LICENSE-NOTICE). The mapping table decides which
|
|
12
|
+
// relations become memory facts and under which predicate URI; rows marked
|
|
13
|
+
// ace = "none" are deliberate non-emissions. A slice relation MISSING from
|
|
14
|
+
// the table is a drift error — loud, never guessed around.
|
|
15
|
+
//
|
|
16
|
+
// Seeding goes through src/memory/core.mjs appendFact() ONLY (memory is
|
|
17
|
+
// import-only here): fact ids are content-hashed from (s,p,o), so re-seeding
|
|
18
|
+
// is idempotent by construction. seedMemory additionally pre-loads the store
|
|
19
|
+
// once and skips triples already present, so a re-seed is read-mostly instead
|
|
20
|
+
// of N rewrites.
|
|
21
|
+
|
|
22
|
+
import { createReadStream } from "node:fs";
|
|
23
|
+
import { readFile } from "node:fs/promises";
|
|
24
|
+
import { createInterface } from "node:readline";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { dirname, join } from "node:path";
|
|
27
|
+
import { parse as parseToml } from "smol-toml";
|
|
28
|
+
import { appendFact, loadMemory, normFactTerm } from "../memory/core.mjs";
|
|
29
|
+
|
|
30
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
31
|
+
export const SLICE_FILE = join(PKG_ROOT, "corpus", "conceptnet", "slice.jsonl");
|
|
32
|
+
export const MAP_FILE = join(PKG_ROOT, "src", "corpus", "conceptnet-map.toml");
|
|
33
|
+
|
|
34
|
+
const ACE_PATTERNS = new Set(["subClassOf", "type", "ObjectProperty", "someValuesFrom", "disjointWith", "property", "none"]);
|
|
35
|
+
|
|
36
|
+
/** Load the slice JSONL as a stream (never the whole file as one string) and
|
|
37
|
+
* return the parsed assertions. Every line must carry start/rel/end; bad
|
|
38
|
+
* lines fail loudly with file:line. */
|
|
39
|
+
export async function loadSlice(path = SLICE_FILE) {
|
|
40
|
+
const rl = createInterface({ input: createReadStream(path, "utf8"), crlfDelay: Infinity });
|
|
41
|
+
const assertions = [];
|
|
42
|
+
let n = 0;
|
|
43
|
+
for await (const raw of rl) {
|
|
44
|
+
n += 1;
|
|
45
|
+
const line = raw.trim();
|
|
46
|
+
if (!line) continue;
|
|
47
|
+
let row;
|
|
48
|
+
try {
|
|
49
|
+
row = JSON.parse(line);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
throw new Error(`${path}:${n}: not valid JSON: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
for (const field of ["start", "rel", "end"]) {
|
|
54
|
+
if (typeof row[field] !== "string" || !row[field]) {
|
|
55
|
+
throw new Error(`${path}:${n}: assertion missing "${field}"`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
assertions.push(row);
|
|
59
|
+
}
|
|
60
|
+
return assertions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Load the relation → ACE-OWL mapping table. Returns Map(rel → row); every
|
|
64
|
+
* row must have a known `ace` pattern, and a mapped (non-"none") row must
|
|
65
|
+
* name the predicate URI it emits. */
|
|
66
|
+
export async function loadMap(path = MAP_FILE) {
|
|
67
|
+
const table = parseToml(await readFile(path, "utf8"));
|
|
68
|
+
const rows = table.relation || [];
|
|
69
|
+
const map = new Map();
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
if (!row.rel) throw new Error(`${path}: a [[relation]] row is missing "rel"`);
|
|
72
|
+
if (map.has(row.rel)) throw new Error(`${path}: duplicate mapping for ${row.rel}`);
|
|
73
|
+
if (!ACE_PATTERNS.has(row.ace)) {
|
|
74
|
+
throw new Error(`${path}: ${row.rel} has unknown ace pattern ${JSON.stringify(row.ace)}`);
|
|
75
|
+
}
|
|
76
|
+
if (row.ace !== "none" && !row.predicate) {
|
|
77
|
+
throw new Error(`${path}: ${row.rel} maps to ${row.ace} but names no predicate URI`);
|
|
78
|
+
}
|
|
79
|
+
map.set(row.rel, row);
|
|
80
|
+
}
|
|
81
|
+
return map;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** /c/en/source_code → "source code" — the human term text a memory fact stores. */
|
|
85
|
+
export const termText = (uri) => {
|
|
86
|
+
const m = /^\/c\/en\/([^/]+)/.exec(String(uri || ""));
|
|
87
|
+
return m ? m[1].replace(/_/g, " ") : null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** Map slice assertions → appendFact-shaped triples:
|
|
91
|
+
* { subject, predicate, object, provenance }
|
|
92
|
+
* (provenance is a STRING — exactly what src/memory/core.mjs appendFact
|
|
93
|
+
* takes; it names the corpus and the originating ConceptNet relation).
|
|
94
|
+
* Rows whose relation maps ace="none" are skipped — deliberate non-emission.
|
|
95
|
+
* A relation with NO row in the map throws: that is table drift, not data. */
|
|
96
|
+
export function toFacts(assertions, map) {
|
|
97
|
+
const facts = [];
|
|
98
|
+
for (const a of assertions) {
|
|
99
|
+
const row = map.get(a.rel);
|
|
100
|
+
if (!row) {
|
|
101
|
+
throw new Error(`slice/map drift: relation ${a.rel} has no row in conceptnet-map.toml`);
|
|
102
|
+
}
|
|
103
|
+
if (row.ace === "none") continue;
|
|
104
|
+
const subject = termText(a.start);
|
|
105
|
+
const object = termText(a.end);
|
|
106
|
+
if (!subject || !object) continue; // non-en endpoint slipped in — filtered, not fatal
|
|
107
|
+
facts.push({
|
|
108
|
+
subject,
|
|
109
|
+
predicate: row.predicate,
|
|
110
|
+
object,
|
|
111
|
+
provenance: `corpus:conceptnet ${a.rel}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return facts;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Seed a repo's memory graph (<dir>/.tmct/memory/graph.json) from the
|
|
118
|
+
* committed slice. Options: limit (cap the facts written — handy for tests
|
|
119
|
+
* and fast bootstraps), slicePath/mapPath overrides.
|
|
120
|
+
*
|
|
121
|
+
* Idempotent twice over: appendFact's content-hashed ids make a blind
|
|
122
|
+
* re-append an upsert, and we pre-read the store once to skip triples that
|
|
123
|
+
* are already there (so re-seeding costs one read, not N rewrites).
|
|
124
|
+
* Returns { appended, skipped, total }. */
|
|
125
|
+
export async function seedMemory(dir, { limit, slicePath = SLICE_FILE, mapPath = MAP_FILE } = {}) {
|
|
126
|
+
const [assertions, map] = await Promise.all([loadSlice(slicePath), loadMap(mapPath)]);
|
|
127
|
+
let facts = toFacts(assertions, map);
|
|
128
|
+
if (limit !== undefined) facts = facts.slice(0, limit);
|
|
129
|
+
|
|
130
|
+
// One read up front: what does the store already reify? Keys are built with
|
|
131
|
+
// memory's own normFactTerm so they match the normalized read-back exactly
|
|
132
|
+
// (appendFact converges /c/en/foo_bar, tmct:Foo and "Foo bar" to one term).
|
|
133
|
+
const factKey = (s, p, o) => `${normFactTerm(s)} ${p} ${normFactTerm(o)}`;
|
|
134
|
+
const existing = new Set();
|
|
135
|
+
const memory = await loadMemory(dir);
|
|
136
|
+
for (const ind of memory.individuals || []) {
|
|
137
|
+
if (ind?.class !== "Fact") continue;
|
|
138
|
+
const get = (key) => (ind.attributes || []).find((x) => x.key === key)?.value;
|
|
139
|
+
existing.add(factKey(get("subject"), get("predicate"), get("object")));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let appended = 0;
|
|
143
|
+
let skipped = 0;
|
|
144
|
+
for (const fact of facts) {
|
|
145
|
+
const key = factKey(fact.subject, fact.predicate, fact.object);
|
|
146
|
+
if (existing.has(key)) {
|
|
147
|
+
skipped += 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
await appendFact(dir, fact);
|
|
151
|
+
existing.add(key);
|
|
152
|
+
appended += 1;
|
|
153
|
+
}
|
|
154
|
+
return { appended, skipped, total: facts.length };
|
|
155
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// corpus/templates.mjs — the response-template library + SE phrase book loaders
|
|
2
|
+
// (ROADMAP Phase 2, items 4+7). Plain diffable data in, strict renderers out:
|
|
3
|
+
//
|
|
4
|
+
// data/templates/responses.jsonl {id, class, template, register} rows
|
|
5
|
+
// data/phrasebook/software-phrases.txt one phrase pattern per line
|
|
6
|
+
// (`#` comments, `~` synonym families)
|
|
7
|
+
//
|
|
8
|
+
// loadTemplates() validates the whole file (parse, required fields, unique
|
|
9
|
+
// ids) and caches; render(id, slots) is then synchronous and STRICT — an
|
|
10
|
+
// unknown id or a missing slot throws, it never emits a half-filled sentence.
|
|
11
|
+
// The response surface (Phase 1 pipeline) fills templates from grounded data
|
|
12
|
+
// only, so a thrown slot is a programming error, not a user-facing miss.
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
|
|
18
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
19
|
+
export const TEMPLATES_FILE = join(PKG_ROOT, "data", "templates", "responses.jsonl");
|
|
20
|
+
export const PHRASEBOOK_FILE = join(PKG_ROOT, "data", "phrasebook", "software-phrases.txt");
|
|
21
|
+
|
|
22
|
+
const REGISTERS = new Set(["terse", "friendly"]);
|
|
23
|
+
const SLOT_RE = /\{([A-Za-z][A-Za-z0-9]*)\}/g;
|
|
24
|
+
|
|
25
|
+
/** The slot names a template string requires, in first-appearance order. */
|
|
26
|
+
export function slotsOf(template) {
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const m of String(template).matchAll(SLOT_RE)) {
|
|
29
|
+
if (!out.includes(m[1])) out.push(m[1]);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let cache = null; // Map<id, row> from the last loadTemplates() — render()'s source
|
|
35
|
+
|
|
36
|
+
/** Load + validate the response templates. Every line must parse as JSON with
|
|
37
|
+
* a unique `id`, a `class`, a known `register`, and a non-empty `template`
|
|
38
|
+
* (bad data fails loudly at load, never at render time). Returns Map<id,row>
|
|
39
|
+
* (each row gains `slots`, its required slot names) and primes render(). */
|
|
40
|
+
export async function loadTemplates(path = TEMPLATES_FILE) {
|
|
41
|
+
const text = await readFile(path, "utf8");
|
|
42
|
+
const byId = new Map();
|
|
43
|
+
const lines = text.split("\n");
|
|
44
|
+
for (let n = 0; n < lines.length; n += 1) {
|
|
45
|
+
const line = lines[n].trim();
|
|
46
|
+
if (!line) continue;
|
|
47
|
+
let row;
|
|
48
|
+
try {
|
|
49
|
+
row = JSON.parse(line);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
throw new Error(`${path}:${n + 1}: not valid JSON: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
for (const field of ["id", "class", "template", "register"]) {
|
|
54
|
+
if (typeof row[field] !== "string" || !row[field]) {
|
|
55
|
+
throw new Error(`${path}:${n + 1}: missing/empty "${field}"`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!REGISTERS.has(row.register)) {
|
|
59
|
+
throw new Error(`${path}:${n + 1}: register must be terse|friendly, got "${row.register}"`);
|
|
60
|
+
}
|
|
61
|
+
if (byId.has(row.id)) throw new Error(`${path}:${n + 1}: duplicate template id "${row.id}"`);
|
|
62
|
+
byId.set(row.id, { ...row, slots: slotsOf(row.template) });
|
|
63
|
+
}
|
|
64
|
+
cache = byId;
|
|
65
|
+
return byId;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Fill template `id` with `slots` — strict: unknown id throws; ANY missing
|
|
69
|
+
* slot throws (named), so a response is complete or not emitted at all.
|
|
70
|
+
* Extra slots are ignored. Uses the map from loadTemplates() (pass
|
|
71
|
+
* `templates` explicitly to bypass the module cache, e.g. in tests). */
|
|
72
|
+
export function render(id, slots = {}, templates = cache) {
|
|
73
|
+
if (!templates) throw new Error("render() before loadTemplates() — load the template library first");
|
|
74
|
+
const row = templates.get(id);
|
|
75
|
+
if (!row) throw new Error(`unknown template id "${id}"`);
|
|
76
|
+
const missing = row.slots.filter((s) => slots[s] === undefined || slots[s] === null);
|
|
77
|
+
if (missing.length) {
|
|
78
|
+
throw new Error(`template "${id}" missing slot${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`);
|
|
79
|
+
}
|
|
80
|
+
return row.template.replace(SLOT_RE, (_, name) => String(slots[name]));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Load + parse the SE phrase book. Returns:
|
|
84
|
+
* patterns [{pattern, slots}] one per phrase line ("what calls {x}")
|
|
85
|
+
* synonyms [[word, …], …] one per `~` family line (≥2 entries each)
|
|
86
|
+
* `#`-prefixed lines and blank lines are skipped. */
|
|
87
|
+
export async function loadPhrasebook(path = PHRASEBOOK_FILE) {
|
|
88
|
+
const text = await readFile(path, "utf8");
|
|
89
|
+
const patterns = [];
|
|
90
|
+
const synonyms = [];
|
|
91
|
+
const lines = text.split("\n");
|
|
92
|
+
for (let n = 0; n < lines.length; n += 1) {
|
|
93
|
+
const line = lines[n].trim();
|
|
94
|
+
if (!line || line.startsWith("#")) continue;
|
|
95
|
+
if (line.startsWith("~")) {
|
|
96
|
+
const family = line.slice(1).split(",").map((w) => w.trim().toLowerCase()).filter(Boolean);
|
|
97
|
+
if (family.length < 2) throw new Error(`${path}:${n + 1}: a synonym family needs at least 2 entries`);
|
|
98
|
+
synonyms.push(family);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
patterns.push({ pattern: line, slots: slotsOf(line) });
|
|
102
|
+
}
|
|
103
|
+
return { patterns, synonyms };
|
|
104
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
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.
|
|
9
|
+
//
|
|
10
|
+
// parseAce(sentence, lexicon) → { pattern, triples, residue } | null
|
|
11
|
+
// pattern one of: subClassOf | typeAssertion | relation | someValuesFrom |
|
|
12
|
+
// cardinality | disjointWith | possessive | adjective
|
|
13
|
+
// triples [{ subject, predicate, object, kind, n? }] — OWL-labelled string
|
|
14
|
+
// triples shaped for src/memory/core.mjs's appendFact (which
|
|
15
|
+
// normalizes subject/object via normFactTerm: "tmct:module" is
|
|
16
|
+
// stored as "module"; the predicate keeps its vocabulary casing).
|
|
17
|
+
// residue [] on a clean parse; the unknown tokens when the sentence FITS a
|
|
18
|
+
// pattern structurally but uses undeclared words (triples is then
|
|
19
|
+
// empty — feeds the pipeline's "if you mean X…" surround).
|
|
20
|
+
// null the sentence does not fit the fragment at all.
|
|
21
|
+
//
|
|
22
|
+
// Term style: classes/individuals are `tmct:<lexeme>` CURIEs (lexicon lemma
|
|
23
|
+
// for nouns, canonical spelling for proper names, the literal token for
|
|
24
|
+
// code-shaped references like chat.mjs); predicates are the OWL/RDF(S)
|
|
25
|
+
// vocabulary terms or the lexicon verb's tmct:<3sg> predicate. Restriction
|
|
26
|
+
// and intersection class expressions get READABLE deterministic node names
|
|
27
|
+
// (tmct:some-imports-test, tmct:module-that-imports-test) instead of blank
|
|
28
|
+
// nodes, so the same sentence always re-emits the same triples and appendFact
|
|
29
|
+
// stays idempotent. An intersection is flattened to repeated
|
|
30
|
+
// owl:intersectionOf triples (one per member) — the flat-JSON stand-in for an
|
|
31
|
+
// RDF list, documented in ontology/tmct-core.ttl.
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
loadLexicon, lookupNoun, lookupVerb, lookupAdjective, lookupProperName,
|
|
35
|
+
predicateOf, numberOf, classify,
|
|
36
|
+
} from "./lexicon.mjs";
|
|
37
|
+
|
|
38
|
+
const DET = new Set(["a", "an", "the"]);
|
|
39
|
+
// A token SHAPED like a code reference (a path, file, symbol or CURIE) is an
|
|
40
|
+
// individual by form — a deterministic tokenizer rule, not a guess: declared
|
|
41
|
+
// proper names cover words; this covers chat.mjs, src/ask.mjs, Foo#bar.
|
|
42
|
+
const CODE_REF = /[./\\#:@]/;
|
|
43
|
+
|
|
44
|
+
/** Whitespace tokenizer: curly quotes normalized, commas/semicolons dropped,
|
|
45
|
+
* ONE trailing punctuation run stripped (so "chat.mjs." keeps its dots). */
|
|
46
|
+
export function tokenize(sentence) {
|
|
47
|
+
return String(sentence ?? "")
|
|
48
|
+
.replace(/[‘’]/g, "'")
|
|
49
|
+
.replace(/[,;]/g, " ")
|
|
50
|
+
.replace(/[?!.]+\s*$/, "")
|
|
51
|
+
.trim()
|
|
52
|
+
.split(/\s+/)
|
|
53
|
+
.filter(Boolean);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const local = (term) => String(term).replace(/^tmct:/, "");
|
|
57
|
+
|
|
58
|
+
const stripDet = (tokens) =>
|
|
59
|
+
tokens.length > 1 && DET.has(tokens[0].toLowerCase()) ? tokens.slice(1) : tokens;
|
|
60
|
+
|
|
61
|
+
/** Resolve a 1–2 word noun phrase: PROPERNAME | code-ref | NOUN | ADJ NOUN.
|
|
62
|
+
* Returns { term, individual, extras, unknown } — `term` null on a miss with
|
|
63
|
+
* the undeclared tokens in `unknown` (empty `unknown` = structurally
|
|
64
|
+
* unparseable phrase → the caller returns a hard null). `extras` carries the
|
|
65
|
+
* pattern-8 adjective triples (subclass axioms / hasValue restriction). */
|
|
66
|
+
function resolveNP(lexicon, tokensIn) {
|
|
67
|
+
const tokens = stripDet(tokensIn);
|
|
68
|
+
if (tokens.length === 1) {
|
|
69
|
+
const t = tokens[0];
|
|
70
|
+
const proper = lookupProperName(lexicon, t);
|
|
71
|
+
if (proper) return { term: `tmct:${proper}`, individual: true, extras: [], unknown: [] };
|
|
72
|
+
if (CODE_REF.test(t)) return { term: `tmct:${t}`, individual: true, extras: [], unknown: [] };
|
|
73
|
+
const noun = lookupNoun(lexicon, t);
|
|
74
|
+
if (noun) return { term: `tmct:${noun.lemma}`, individual: false, noun, extras: [], unknown: [] };
|
|
75
|
+
return { term: null, individual: false, extras: [], unknown: [t] };
|
|
76
|
+
}
|
|
77
|
+
if (tokens.length === 2) {
|
|
78
|
+
const adj = lookupAdjective(lexicon, tokens[0]);
|
|
79
|
+
const noun = lookupNoun(lexicon, tokens[1]);
|
|
80
|
+
if (adj && noun) {
|
|
81
|
+
const term = `tmct:${adj.lemma}-${noun.lemma}`;
|
|
82
|
+
const extras = [
|
|
83
|
+
{ subject: term, predicate: "rdfs:subClassOf", object: `tmct:${noun.lemma}`, kind: "rdfs:subClassOf" },
|
|
84
|
+
];
|
|
85
|
+
if (adj.type === "subclass") {
|
|
86
|
+
// the adjective itself denotes a class: legacy-module ⊑ module, ⊑ legacy
|
|
87
|
+
extras.push({ subject: term, predicate: "rdfs:subClassOf", object: `tmct:${adj.lemma}`, kind: "rdfs:subClassOf" });
|
|
88
|
+
} else {
|
|
89
|
+
// data adjective: subclass-with-restriction on the boolean-ish property
|
|
90
|
+
const r = `tmct:has-${adj.lemma}`;
|
|
91
|
+
extras.push(
|
|
92
|
+
{ subject: r, predicate: "rdf:type", object: "owl:Restriction", kind: "owl:hasValue" },
|
|
93
|
+
{ subject: r, predicate: "owl:onProperty", object: adj.property || `tmct:${adj.lemma}`, kind: "owl:hasValue" },
|
|
94
|
+
{ subject: r, predicate: "owl:hasValue", object: adj.value ?? "true", kind: "owl:hasValue" },
|
|
95
|
+
{ subject: term, predicate: "rdfs:subClassOf", object: r, kind: "owl:hasValue" },
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return { term, individual: false, noun, extras, unknown: [] };
|
|
99
|
+
}
|
|
100
|
+
// only genuinely undeclared words are residue — a declared word in the
|
|
101
|
+
// wrong slot ("GitLab pipeline") is a structural miss, not an unknown
|
|
102
|
+
const unknown = tokens.filter((t) => !classify(t, lexicon));
|
|
103
|
+
return { term: null, individual: false, extras: [], unknown };
|
|
104
|
+
}
|
|
105
|
+
// 0 or 3+ tokens: not a fragment NP. Name the undeclared words if any.
|
|
106
|
+
return { term: null, individual: false, extras: [], unknown: tokens.filter((t) => !classify(t, lexicon)) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The shared miss result: a structural fit with undeclared words returns the
|
|
110
|
+
* pattern + residue (triples empty); a fit with only declared-but-unusable
|
|
111
|
+
* phrasing returns null — the honest fall-through either way. */
|
|
112
|
+
function missOrNull(pattern, nps, extraUnknown = []) {
|
|
113
|
+
const residue = [...extraUnknown, ...nps.flatMap((np) => np.unknown)];
|
|
114
|
+
return residue.length ? { pattern, triples: [], residue } : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const hit = (pattern, nps, triples, more = {}) => ({
|
|
118
|
+
pattern,
|
|
119
|
+
triples: [...nps.flatMap((np) => np.extras), ...triples],
|
|
120
|
+
residue: [],
|
|
121
|
+
...more,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/** Pattern 3 — "N1 VERB N2" / "PROPERNAME VERBs PROPERNAME" → object-property
|
|
125
|
+
* assertion. Also the no-declared-verb 3-token shape: both ends resolvable →
|
|
126
|
+
* residue names the middle token (the future "if you mean X…" hook). */
|
|
127
|
+
function parseRelation(lexicon, toks, lower) {
|
|
128
|
+
for (let i = 1; i < toks.length - 1; i += 1) {
|
|
129
|
+
const verb = lookupVerb(lexicon, lower[i]);
|
|
130
|
+
if (!verb) continue;
|
|
131
|
+
let objStart = i + 1;
|
|
132
|
+
if (verb.prep) {
|
|
133
|
+
if (lower[objStart] !== verb.prep) continue;
|
|
134
|
+
objStart += 1;
|
|
135
|
+
if (objStart >= toks.length) continue;
|
|
136
|
+
}
|
|
137
|
+
const np1 = resolveNP(lexicon, toks.slice(0, i));
|
|
138
|
+
const np2 = resolveNP(lexicon, toks.slice(objStart));
|
|
139
|
+
if (np1.term == null || np2.term == null) return missOrNull("relation", [np1, np2]);
|
|
140
|
+
return hit("relation", [np1, np2], [
|
|
141
|
+
{ subject: np1.term, predicate: predicateOf(verb), object: np2.term, kind: "owl:ObjectProperty" },
|
|
142
|
+
]);
|
|
143
|
+
}
|
|
144
|
+
const content = toks.filter((t) => !DET.has(t.toLowerCase()));
|
|
145
|
+
if (content.length === 3 && !classify(content[1], lexicon)) {
|
|
146
|
+
const np1 = resolveNP(lexicon, [content[0]]);
|
|
147
|
+
const np2 = resolveNP(lexicon, [content[2]]);
|
|
148
|
+
if (np1.term != null && np2.term != null) return { pattern: "relation", triples: [], residue: [content[1]] };
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Pattern 8 (copula arm) — "X is ADJ": data adjective → datatype-property
|
|
154
|
+
* assertion; subclass adjective → rdf:type (individual) / rdfs:subClassOf. */
|
|
155
|
+
function adjectiveCopula(pattern, np1, adj) {
|
|
156
|
+
if (np1.term == null) return missOrNull(pattern, [np1]);
|
|
157
|
+
if (adj.type === "data") {
|
|
158
|
+
return hit(pattern, [np1], [
|
|
159
|
+
{ subject: np1.term, predicate: adj.property || `tmct:${adj.lemma}`, object: adj.value ?? "true", kind: "owl:DatatypeProperty" },
|
|
160
|
+
]);
|
|
161
|
+
}
|
|
162
|
+
const predicate = np1.individual ? "rdf:type" : "rdfs:subClassOf";
|
|
163
|
+
return hit(pattern, [np1], [
|
|
164
|
+
{ subject: np1.term, predicate, object: `tmct:${adj.lemma}`, kind: predicate },
|
|
165
|
+
]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Pattern 4 — "every N1 that VERBs a N2 is a N3" → someValuesFrom restriction:
|
|
169
|
+
* (N1 ⊓ ∃VERB.N2) ⊑ N3, flattened onto readable deterministic node names. */
|
|
170
|
+
function parseRestriction(lexicon, toks, lower, thatIdx) {
|
|
171
|
+
const isIdx = lower.indexOf("is", thatIdx + 2);
|
|
172
|
+
if (isIdx < 0 || thatIdx + 1 >= isIdx) return null;
|
|
173
|
+
const verb = lookupVerb(lexicon, lower[thatIdx + 1]);
|
|
174
|
+
const np1 = resolveNP(lexicon, toks.slice(1, thatIdx));
|
|
175
|
+
let objStart = thatIdx + 2;
|
|
176
|
+
if (verb?.prep) {
|
|
177
|
+
if (lower[objStart] !== verb.prep) return null;
|
|
178
|
+
objStart += 1;
|
|
179
|
+
}
|
|
180
|
+
const np2 = resolveNP(lexicon, toks.slice(objStart, isIdx));
|
|
181
|
+
const np3 = resolveNP(lexicon, toks.slice(isIdx + 1));
|
|
182
|
+
if (!verb) return missOrNull("someValuesFrom", [np1, np2, np3], [toks[thatIdx + 1]]);
|
|
183
|
+
if (np1.term == null || np2.term == null || np3.term == null) {
|
|
184
|
+
return missOrNull("someValuesFrom", [np1, np2, np3]);
|
|
185
|
+
}
|
|
186
|
+
if (np1.individual || np2.individual || np3.individual) return null; // class-level pattern only
|
|
187
|
+
const pred = predicateOf(verb);
|
|
188
|
+
const k = "owl:someValuesFrom";
|
|
189
|
+
const r = `tmct:some-${local(pred)}-${local(np2.term)}`;
|
|
190
|
+
const inter = `tmct:${local(np1.term)}-that-${local(pred)}-${local(np2.term)}`;
|
|
191
|
+
return hit("someValuesFrom", [np1, np2, np3], [
|
|
192
|
+
{ subject: r, predicate: "rdf:type", object: "owl:Restriction", kind: k },
|
|
193
|
+
{ subject: r, predicate: "owl:onProperty", object: pred, kind: k },
|
|
194
|
+
{ subject: r, predicate: "owl:someValuesFrom", object: np2.term, kind: k },
|
|
195
|
+
{ subject: inter, predicate: "owl:intersectionOf", object: np1.term, kind: k },
|
|
196
|
+
{ subject: inter, predicate: "owl:intersectionOf", object: r, kind: k },
|
|
197
|
+
{ subject: inter, predicate: "rdfs:subClassOf", object: np3.term, kind: k },
|
|
198
|
+
]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Pattern 5 — "every N has at least|at most|exactly n N2" → cardinality
|
|
202
|
+
* restriction on tmct:has (owl:onClass records the counted class — the
|
|
203
|
+
* qualified-form question is noted in docs/references/schemas/owl2-vocabulary.md). */
|
|
204
|
+
function parseCardinality(lexicon, toks, lower, hasIdx) {
|
|
205
|
+
let kind = null;
|
|
206
|
+
let nIdx = -1;
|
|
207
|
+
if (lower[hasIdx + 1] === "at" && lower[hasIdx + 2] === "least") { kind = "owl:minCardinality"; nIdx = hasIdx + 3; }
|
|
208
|
+
else if (lower[hasIdx + 1] === "at" && lower[hasIdx + 2] === "most") { kind = "owl:maxCardinality"; nIdx = hasIdx + 3; }
|
|
209
|
+
else if (lower[hasIdx + 1] === "exactly") { kind = "owl:cardinality"; nIdx = hasIdx + 2; }
|
|
210
|
+
else return null;
|
|
211
|
+
const n = numberOf(lower[nIdx]);
|
|
212
|
+
if (n == null || nIdx + 1 >= toks.length) return null;
|
|
213
|
+
const np1 = resolveNP(lexicon, toks.slice(1, hasIdx));
|
|
214
|
+
const np2 = resolveNP(lexicon, toks.slice(nIdx + 1));
|
|
215
|
+
if (np1.term == null || np2.term == null) return missOrNull("cardinality", [np1, np2]);
|
|
216
|
+
if (np1.individual || np2.individual) return null;
|
|
217
|
+
const tag = { "owl:minCardinality": "min", "owl:maxCardinality": "max", "owl:cardinality": "exactly" }[kind];
|
|
218
|
+
const r = `tmct:${tag}-${n}-${local(np2.term)}`;
|
|
219
|
+
return hit("cardinality", [np1, np2], [
|
|
220
|
+
{ subject: r, predicate: "rdf:type", object: "owl:Restriction", kind },
|
|
221
|
+
{ subject: r, predicate: "owl:onProperty", object: "tmct:has", kind },
|
|
222
|
+
{ subject: r, predicate: kind, object: String(n), kind, n },
|
|
223
|
+
{ subject: r, predicate: "owl:onClass", object: np2.term, kind },
|
|
224
|
+
{ subject: np1.term, predicate: "rdfs:subClassOf", object: r, kind },
|
|
225
|
+
], { n });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Patterns 1, 4, 5 and 8's "every …" arm. */
|
|
229
|
+
function parseEvery(lexicon, toks, lower) {
|
|
230
|
+
const thatIdx = lower.indexOf("that");
|
|
231
|
+
if (thatIdx > 1) return parseRestriction(lexicon, toks, lower, thatIdx);
|
|
232
|
+
const hasIdx = lower.indexOf("has");
|
|
233
|
+
if (hasIdx > 1 && (lower[hasIdx + 1] === "at" || lower[hasIdx + 1] === "exactly")) {
|
|
234
|
+
return parseCardinality(lexicon, toks, lower, hasIdx);
|
|
235
|
+
}
|
|
236
|
+
const isIdx = lower.indexOf("is");
|
|
237
|
+
if (isIdx <= 1 || isIdx === toks.length - 1) return null;
|
|
238
|
+
const np1 = resolveNP(lexicon, toks.slice(1, isIdx));
|
|
239
|
+
const rest = toks.slice(isIdx + 1);
|
|
240
|
+
if (rest.length === 1) {
|
|
241
|
+
const adj = lookupAdjective(lexicon, rest[0]);
|
|
242
|
+
if (adj) return adjectiveCopula("adjective", np1, adj);
|
|
243
|
+
}
|
|
244
|
+
const np2 = resolveNP(lexicon, rest);
|
|
245
|
+
if (np1.term == null || np2.term == null) return missOrNull("subClassOf", [np1, np2]);
|
|
246
|
+
if (np1.individual || np2.individual) return null; // "every X is chat.mjs" — not the fragment
|
|
247
|
+
return hit("subClassOf", [np1, np2], [
|
|
248
|
+
{ subject: np1.term, predicate: "rdfs:subClassOf", object: np2.term, kind: "rdfs:subClassOf" },
|
|
249
|
+
]);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Pattern 6 — "no N1 is a N2" → owl:disjointWith. */
|
|
253
|
+
function parseDisjoint(lexicon, toks, lower) {
|
|
254
|
+
const isIdx = lower.indexOf("is");
|
|
255
|
+
if (isIdx <= 1 || isIdx === toks.length - 1) return null;
|
|
256
|
+
const np1 = resolveNP(lexicon, toks.slice(1, isIdx));
|
|
257
|
+
const np2 = resolveNP(lexicon, toks.slice(isIdx + 1));
|
|
258
|
+
if (np1.term == null || np2.term == null) return missOrNull("disjointWith", [np1, np2]);
|
|
259
|
+
if (np1.individual || np2.individual) return null;
|
|
260
|
+
return hit("disjointWith", [np1, np2], [
|
|
261
|
+
{ subject: np1.term, predicate: "owl:disjointWith", object: np2.term, kind: "owl:disjointWith" },
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Pattern 7 — "N1's N2 is VALUE" / "the N2 of N1 is VALUE": data or object
|
|
266
|
+
* property assertion per the possessive noun's DECLARED typing (undeclared
|
|
267
|
+
* typing defaults to data — a literal value is the honest floor). */
|
|
268
|
+
function buildPossessive(lexicon, ownerToks, headToks, valueToks) {
|
|
269
|
+
const owner = resolveNP(lexicon, ownerToks);
|
|
270
|
+
if (headToks.length !== 1) return null;
|
|
271
|
+
const head = lookupNoun(lexicon, headToks[0]);
|
|
272
|
+
if (!head) return missOrNull("possessive", [owner], [headToks[0]]);
|
|
273
|
+
if (owner.term == null) return missOrNull("possessive", [owner]);
|
|
274
|
+
if (!valueToks.length) return null;
|
|
275
|
+
const predicate = `tmct:${head.lemma}`;
|
|
276
|
+
if ((head.property || "data") === "object") {
|
|
277
|
+
const value = resolveNP(lexicon, valueToks);
|
|
278
|
+
if (value.term == null) return missOrNull("possessive", [owner, value]);
|
|
279
|
+
return hit("possessive", [owner, value], [
|
|
280
|
+
{ subject: owner.term, predicate, object: value.term, kind: "owl:ObjectProperty" },
|
|
281
|
+
]);
|
|
282
|
+
}
|
|
283
|
+
return hit("possessive", [owner], [
|
|
284
|
+
{ subject: owner.term, predicate, object: valueToks.join(" "), kind: "owl:DatatypeProperty" },
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function parsePossessive(lexicon, toks, lower) {
|
|
289
|
+
const ownerRaw = toks[0].replace(/'s$/i, "");
|
|
290
|
+
const isIdx = lower.indexOf("is");
|
|
291
|
+
if (isIdx < 2 || !ownerRaw) return null;
|
|
292
|
+
return buildPossessive(lexicon, [ownerRaw], toks.slice(1, isIdx), toks.slice(isIdx + 1));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function parseOfForm(lexicon, toks, lower) {
|
|
296
|
+
const ofIdx = lower.indexOf("of");
|
|
297
|
+
const isIdx = lower.indexOf("is", ofIdx + 1);
|
|
298
|
+
if (ofIdx < 2 || isIdx < ofIdx + 2) return null;
|
|
299
|
+
return buildPossessive(lexicon, toks.slice(ofIdx + 1, isIdx), toks.slice(1, ofIdx), toks.slice(isIdx + 1));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Patterns 2 (class assertion), 1's bare-copula variant, and 8's copula arm. */
|
|
303
|
+
function parseCopula(lexicon, toks, lower, isIdx) {
|
|
304
|
+
const np1 = resolveNP(lexicon, toks.slice(0, isIdx));
|
|
305
|
+
const rest = toks.slice(isIdx + 1);
|
|
306
|
+
if (!rest.length) return null;
|
|
307
|
+
if (rest.length === 1) {
|
|
308
|
+
const adj = lookupAdjective(lexicon, rest[0]);
|
|
309
|
+
if (adj) return adjectiveCopula("adjective", np1, adj);
|
|
310
|
+
}
|
|
311
|
+
const np2 = resolveNP(lexicon, rest);
|
|
312
|
+
if (np1.term == null || np2.term == null) {
|
|
313
|
+
return missOrNull(np1.individual ? "typeAssertion" : "subClassOf", [np1, np2]);
|
|
314
|
+
}
|
|
315
|
+
if (np2.individual) return null; // "chat.mjs is sessions.mjs" — identity is not in the fragment
|
|
316
|
+
if (np1.individual) {
|
|
317
|
+
return hit("typeAssertion", [np1, np2], [
|
|
318
|
+
{ subject: np1.term, predicate: "rdf:type", object: np2.term, kind: "rdf:type" },
|
|
319
|
+
]);
|
|
320
|
+
}
|
|
321
|
+
return hit("subClassOf", [np1, np2], [
|
|
322
|
+
{ subject: np1.term, predicate: "rdfs:subClassOf", object: np2.term, kind: "rdfs:subClassOf" },
|
|
323
|
+
]);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Parse one sentence against the 8-pattern ACE-OWL sub-fragment. See the file
|
|
327
|
+
* header for the result contract; `lexicon` defaults to the committed core. */
|
|
328
|
+
export function parseAce(sentence, lexicon = loadLexicon()) {
|
|
329
|
+
const toks = tokenize(sentence);
|
|
330
|
+
if (toks.length < 3) return null;
|
|
331
|
+
const lower = toks.map((t) => t.toLowerCase());
|
|
332
|
+
if (lower[0] === "every") return parseEvery(lexicon, toks, lower);
|
|
333
|
+
if (lower[0] === "no") return parseDisjoint(lexicon, toks, lower);
|
|
334
|
+
if (/'s$/.test(lower[0]) && lower[0].length > 2) return parsePossessive(lexicon, toks, lower);
|
|
335
|
+
if (lower[0] === "the" && lower.includes("of") && lower.includes("is")) {
|
|
336
|
+
return parseOfForm(lexicon, toks, lower);
|
|
337
|
+
}
|
|
338
|
+
const isIdx = lower.indexOf("is");
|
|
339
|
+
if (isIdx > 0) return parseCopula(lexicon, toks, lower, isIdx);
|
|
340
|
+
return parseRelation(lexicon, toks, lower);
|
|
341
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// grammar/assert.mjs — the grammar→memory bridge: parseAce a sentence and land
|
|
2
|
+
// every emitted triple in tmct's OWN memory graph via memory/core.mjs's
|
|
3
|
+
// appendFact (ROADMAP Phase 2 item 2 meeting Phase 1 item 9).
|
|
4
|
+
//
|
|
5
|
+
// appendFact normalizes each triple's subject/object through normFactTerm
|
|
6
|
+
// (tmct:Legacy-module → "legacy-module"; the predicate keeps its vocabulary
|
|
7
|
+
// casing) and content-addresses the fact id — so re-asserting the same
|
|
8
|
+
// sentence upserts, never duplicates, and different writers (chat, corpus)
|
|
9
|
+
// converge on the same stored term spelling. Provenance is a compact tag
|
|
10
|
+
// ("ace:chat:<sessionId>@<ts>"); core.mjs unions tags "|"-joined when several
|
|
11
|
+
// writers assert the same fact.
|
|
12
|
+
|
|
13
|
+
import { appendFact } from "../memory/core.mjs";
|
|
14
|
+
import { parseAce } from "./ace.mjs";
|
|
15
|
+
import { loadLexicon } from "./lexicon.mjs";
|
|
16
|
+
|
|
17
|
+
/** Render a provenance descriptor {source, sessionId?, ts?} as the stored tag.
|
|
18
|
+
* Deterministic, compact, greppable: ace:chat:0189…abcd@2026-07-04T10:00:00Z */
|
|
19
|
+
export function provenanceTag({ source = "chat", sessionId = "", ts = "" } = {}) {
|
|
20
|
+
return `ace:${source}${sessionId ? `:${sessionId}` : ""}${ts ? `@${ts}` : ""}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse `sentence` against the ACE-OWL sub-fragment and append every emitted
|
|
24
|
+
* triple to the memory graph under `dir`. Returns the parse result extended
|
|
25
|
+
* with `ids` (one fact id per triple, same order) and the provenance tag —
|
|
26
|
+
* or null (grammar miss, nothing written), or the residue parse (unknown
|
|
27
|
+
* words: triples empty, ids empty, nothing written). */
|
|
28
|
+
export async function assertSentence(dir, sentence, { lexicon, provenance } = {}) {
|
|
29
|
+
const parse = parseAce(sentence, lexicon ?? loadLexicon());
|
|
30
|
+
if (!parse) return null;
|
|
31
|
+
const tag = provenanceTag(provenance);
|
|
32
|
+
const ids = [];
|
|
33
|
+
for (const t of parse.triples) {
|
|
34
|
+
const { id } = await appendFact(dir, {
|
|
35
|
+
subject: t.subject, predicate: t.predicate, object: t.object, provenance: tag,
|
|
36
|
+
});
|
|
37
|
+
ids.push(id);
|
|
38
|
+
}
|
|
39
|
+
return { ...parse, ids, provenance: tag };
|
|
40
|
+
}
|