@polycode-projects/the-mechanical-code-talker 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -28
- package/ROADMAP.md +3 -2
- package/bin/tmct.mjs +17 -90
- package/corpus/LICENSES.json +19 -4
- package/corpus/README.md +48 -0
- package/corpus/generated/README.md +24 -9
- package/corpus/generated/ace-surface-variants.jsonl +4 -1
- package/corpus/generated/manifest.json +4 -4
- package/corpus/prose/manifest.json +512 -0
- package/corpus/prose/sqlite/LICENSE-NOTICE +53 -0
- package/corpus/prose/sqlite/arch.txt +213 -0
- package/corpus/prose/sqlite/atomiccommit.txt +1117 -0
- package/corpus/prose/sqlite/faq.txt +473 -0
- package/corpus/prose/sqlite/fileformat.txt +1589 -0
- package/corpus/prose/sqlite/lang_createtable.txt +1339 -0
- package/corpus/prose/sqlite/lang_insert.txt +580 -0
- package/corpus/prose/sqlite/lang_select.txt +3293 -0
- package/corpus/prose/sqlite/optoverview.txt +908 -0
- package/corpus/prose/sqlite/queryplanner.txt +447 -0
- package/corpus/prose/sqlite/transactional.txt +41 -0
- package/corpus/prose/sqlite/wal.txt +567 -0
- package/corpus/prose/sqlite/whentouse.txt +300 -0
- package/corpus/prose/wikipedia/Apple.txt +4 -0
- package/corpus/prose/wikipedia/Attempto_Controlled_English.txt +169 -0
- package/corpus/prose/wikipedia/Automated_planning_and_scheduling.txt +67 -0
- package/corpus/prose/wikipedia/Bee.txt +7 -0
- package/corpus/prose/wikipedia/Bird.txt +8 -0
- package/corpus/prose/wikipedia/Bone.txt +4 -0
- package/corpus/prose/wikipedia/Book.txt +7 -0
- package/corpus/prose/wikipedia/Bread.txt +6 -0
- package/corpus/prose/wikipedia/Butterfly.txt +6 -0
- package/corpus/prose/wikipedia/Car.txt +1 -0
- package/corpus/prose/wikipedia/Cat.txt +1 -0
- package/corpus/prose/wikipedia/Child.txt +3 -0
- package/corpus/prose/wikipedia/City.txt +2 -0
- package/corpus/prose/wikipedia/Clock.txt +2 -0
- package/corpus/prose/wikipedia/Cooking.txt +1 -0
- package/corpus/prose/wikipedia/Description_logic.txt +660 -0
- package/corpus/prose/wikipedia/Doctor.txt +6 -0
- package/corpus/prose/wikipedia/Dog.txt +4 -0
- package/corpus/prose/wikipedia/Eagle.txt +4 -0
- package/corpus/prose/wikipedia/Emotion.txt +9 -0
- package/corpus/prose/wikipedia/Eye.txt +5 -0
- package/corpus/prose/wikipedia/Family.txt +3 -0
- package/corpus/prose/wikipedia/Farm.txt +4 -0
- package/corpus/prose/wikipedia/Fear.txt +4 -0
- package/corpus/prose/wikipedia/First-order_logic.txt +1518 -0
- package/corpus/prose/wikipedia/Fish.txt +10 -0
- package/corpus/prose/wikipedia/Flower.txt +3 -0
- package/corpus/prose/wikipedia/Food.txt +10 -0
- package/corpus/prose/wikipedia/Grass.txt +9 -0
- package/corpus/prose/wikipedia/Hand.txt +2 -0
- package/corpus/prose/wikipedia/Happiness.txt +3 -0
- package/corpus/prose/wikipedia/Heart.txt +4 -0
- package/corpus/prose/wikipedia/Horse.txt +4 -0
- package/corpus/prose/wikipedia/House.txt +6 -0
- package/corpus/prose/wikipedia/Human.txt +4 -0
- package/corpus/prose/wikipedia/Insect.txt +6 -0
- package/corpus/prose/wikipedia/Interactive_fiction.txt +112 -0
- package/corpus/prose/wikipedia/Knowledge.txt +5 -0
- package/corpus/prose/wikipedia/Knowledge_representation_and_reasoning.txt +87 -0
- package/corpus/prose/wikipedia/LICENSE-NOTICE +94 -0
- package/corpus/prose/wikipedia/Language.txt +10 -0
- package/corpus/prose/wikipedia/Learning.txt +4 -0
- package/corpus/prose/wikipedia/Mammal.txt +3 -0
- package/corpus/prose/wikipedia/Memory.txt +5 -0
- package/corpus/prose/wikipedia/Milk.txt +1 -0
- package/corpus/prose/wikipedia/Mountain.txt +1 -0
- package/corpus/prose/wikipedia/Natural_language_processing.txt +211 -0
- package/corpus/prose/wikipedia/Ostrich.txt +2 -0
- package/corpus/prose/wikipedia/Owl.txt +2 -0
- package/corpus/prose/wikipedia/Penguin.txt +2 -0
- package/corpus/prose/wikipedia/Plant.txt +5 -0
- package/corpus/prose/wikipedia/Rain.txt +1 -0
- package/corpus/prose/wikipedia/Resource_Description_Framework.txt +184 -0
- package/corpus/prose/wikipedia/River.txt +1 -0
- package/corpus/prose/wikipedia/School.txt +8 -0
- package/corpus/prose/wikipedia/Sea.txt +1 -0
- package/corpus/prose/wikipedia/Semantic_Web.txt +114 -0
- package/corpus/prose/wikipedia/Semantic_reasoner.txt +29 -0
- package/corpus/prose/wikipedia/Snow.txt +5 -0
- package/corpus/prose/wikipedia/Sun.txt +5 -0
- package/corpus/prose/wikipedia/Teacher.txt +4 -0
- package/corpus/prose/wikipedia/Team.txt +3 -0
- package/corpus/prose/wikipedia/Text-based_game.txt +17 -0
- package/corpus/prose/wikipedia/Tool.txt +4 -0
- package/corpus/prose/wikipedia/Tree.txt +7 -0
- package/corpus/prose/wikipedia/Weather.txt +4 -0
- package/corpus/prose/wikipedia/Web_Ontology_Language.txt +133 -0
- package/corpus/prose/wikipedia/Wind.txt +8 -0
- package/corpus/prose/wikipedia/Writing.txt +5 -0
- package/corpus/seon/README.md +1 -0
- package/corpus/tier2/generate.mjs +18 -18
- package/corpus/tier2/manifest.json +3 -3
- package/data/games/hanoi-3.txt +8 -2
- package/package.json +24 -5
- package/src/adapters/corpus-lanes.mjs +13 -0
- package/src/adapters/graph-build.mjs +5 -7
- package/src/adapters/import-closure.mjs +28 -0
- package/src/adapters/memory/blocks.mjs +5 -4
- package/src/adapters/memory/core.mjs +78 -5
- package/src/adapters/memory/shacl.mjs +12 -0
- package/src/adapters/providers/graph-service.mjs +12 -5
- package/src/adapters/tracked-files.mjs +17 -0
- package/src/domain/ask-vocab.mjs +2 -0
- package/src/domain/ask.mjs +225 -13
- package/src/domain/cli-verbs.mjs +201 -0
- package/src/domain/codegraph.mjs +142 -56
- package/src/domain/completions/graph-adapter.mjs +1 -1
- package/src/domain/completions/group.mjs +3 -17
- package/src/domain/completions/infer.mjs +4 -13
- package/src/domain/completions/rank.mjs +6 -19
- package/src/domain/grammar/lexicon-core.json +1 -1
- package/src/domain/hash.mjs +36 -13
- package/src/domain/interpret/fuzzy.mjs +7 -2
- package/src/domain/interpret/normalize.mjs +9 -0
- package/src/domain/interpret/strategies/keywords.mjs +19 -9
- package/src/domain/memory/capability.mjs +22 -3
- package/src/domain/memory/touched-facts.mjs +17 -0
- package/src/domain/module-paths.mjs +9 -0
- package/src/domain/persona/tiers.mjs +1 -1
- package/src/domain/planning.mjs +37 -0
- package/src/domain/prose.mjs +10 -2
- package/src/domain/relative-specifiers.mjs +12 -0
- package/src/domain/router/registry.mjs +3 -2
- package/src/domain/router/results.mjs +5 -18
- package/src/domain/seeded-random.mjs +33 -0
- package/src/domain/syllogise.mjs +10 -7
- package/src/domain/text-stats.mjs +31 -0
- package/src/services/chat.mjs +720 -182
- package/src/services/extract-facts.mjs +155 -0
- package/src/services/import-file.mjs +2 -2
- package/src/services/ledger-viz.mjs +6 -1
- package/src/services/sentences.mjs +26 -0
- package/src/surfaces/web/memory-ask-browser.bundle.js +11390 -360
- package/src/tools/graph-load.mjs +7 -1
- package/src/tools/readme-docs.mjs +113 -0
- package/src/tools/schema-docs.mjs +2 -2
- package/corpus/namenet/generate.mjs +0 -309
- package/corpus/wordnet/generate.mjs +0 -332
- package/src/adapters/prose-tokens.mjs +0 -98
- package/src/adapters/wordnet-source.mjs +0 -70
- package/src/domain/corpus-matrix.mjs +0 -87
- package/src/domain/inflect.mjs +0 -67
- package/src/domain/licences.mjs +0 -68
- package/src/domain/markdown-links.mjs +0 -55
- package/src/domain/persona/codegen.mjs +0 -123
- package/src/domain/publish-gate.mjs +0 -41
- package/src/domain/schemaorg/turtle.mjs +0 -25
- package/src/domain/semcor/parse.mjs +0 -87
- package/src/domain/version-stamp.mjs +0 -36
- package/src/domain/wordnet/yaml.mjs +0 -133
|
@@ -1,332 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// corpus/wordnet/generate.mjs — converts a LOCAL Open English WordNet (OEWN)
|
|
3
|
-
// checkout into ConceptNet-shape fact rows. Curated + committed, tier-2-shaped
|
|
4
|
-
// — NOT "tier-3" (corpus/README.md's tiering policy already uses that name
|
|
5
|
-
// for something else, runtime-learned facts that are never committed); this
|
|
6
|
-
// bundle is just too large to hand-author like the other tier-2 corpuses.
|
|
7
|
-
// NOT part of the product path — a maintainer tool, run by hand, offline, $0;
|
|
8
|
-
// its OUTPUT (corpus/wordnet/wordnet-{xl,full}.jsonl + manifest.json) is what
|
|
9
|
-
// gets committed, never the source YAML itself.
|
|
10
|
-
//
|
|
11
|
-
// node corpus/wordnet/generate.mjs [yamlDir]
|
|
12
|
-
// TMCT_WORDNET_YAML_DIR=/path/to/yaml node corpus/wordnet/generate.mjs
|
|
13
|
-
//
|
|
14
|
-
// Input: `~/projects/globalwordnet/english-wordnet/src/yaml/` by default (a
|
|
15
|
-
// LOCAL clone, never vendored/committed — CC-BY-4.0, see LICENSE-NOTICE in
|
|
16
|
-
// this directory) — Princeton WordNet's 107,526 synsets across
|
|
17
|
-
// `noun.*.yaml`/`verb.*.yaml`/`adj.*.yaml`/`adv.*.yaml` (45 of the checkout's
|
|
18
|
-
// 73 yaml files; the other 28 are `entries-<letter>.yaml` word-form indexes
|
|
19
|
-
// this converter doesn't need — synset records alone carry every relation and
|
|
20
|
-
// member list this converter reads).
|
|
21
|
-
//
|
|
22
|
-
// Two passes, same discipline as corpus/conceptnet/fetch-slice.mjs and
|
|
23
|
-
// corpus/tier2/generate.mjs (deterministic, sorted, one JSON object per
|
|
24
|
-
// line, `{start, rel, end, weight, surfaceText}` — the exact tier-1 slice
|
|
25
|
-
// shape):
|
|
26
|
-
// 1. load every synset (across ALL 45 files) into one global
|
|
27
|
-
// synsetId -> record map — synset ids ("00034778-n") are globally
|
|
28
|
-
// unique, POS suffix included, so a single flat Map is correct.
|
|
29
|
-
// 2. walk every synset's structural relations + its own `members` list,
|
|
30
|
-
// emitting one row per edge (see RELATION_MAP / synonymPairs below).
|
|
31
|
-
//
|
|
32
|
-
// The hand-rolled `parseYaml` this file reuses (imported, not duplicated) is
|
|
33
|
-
// src/domain/wordnet/yaml.mjs's tiny YAML-subset reader — already proven
|
|
34
|
-
// against this exact OEWN dump shape by the persona-tier tooling. Reusing it
|
|
35
|
-
// (rather than adding a general YAML dependency, or re-deriving a second
|
|
36
|
-
// hand-rolled parser) keeps this converter self-consistent with the rest of
|
|
37
|
-
// the tooling that already reads this same source.
|
|
38
|
-
//
|
|
39
|
-
// Licence: Open English WordNet content is CC-BY-4.0 (Princeton WordNet +
|
|
40
|
-
// Open English Wordnet team) — see LICENSE-NOTICE in this directory. The
|
|
41
|
-
// code in this file is tmct code under the repository's MPL-2.0; only the
|
|
42
|
-
// generated data (corpus/wordnet/*.jsonl) carries CC-BY-4.0.
|
|
43
|
-
|
|
44
|
-
import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
|
|
45
|
-
import { homedir } from "node:os";
|
|
46
|
-
import { createHash } from "node:crypto";
|
|
47
|
-
import { fileURLToPath } from "node:url";
|
|
48
|
-
import { dirname, join } from "node:path";
|
|
49
|
-
import { parseYaml } from "../../src/domain/wordnet/yaml.mjs";
|
|
50
|
-
|
|
51
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
52
|
-
export const WORDNET_OUT_DIR = HERE;
|
|
53
|
-
|
|
54
|
-
export const DEFAULT_YAML_DIR = join(homedir(), "projects", "globalwordnet", "english-wordnet", "src", "yaml");
|
|
55
|
-
|
|
56
|
-
/** Resolve the input yaml directory: CLI positional arg > env var > default.
|
|
57
|
-
* Exposed as a pure function (argv/env injectable) so it's unit-testable
|
|
58
|
-
* without touching real process.argv/env. */
|
|
59
|
-
export function resolveYamlDir(argv = process.argv.slice(2), env = process.env) {
|
|
60
|
-
return argv[0] || env.TMCT_WORDNET_YAML_DIR || DEFAULT_YAML_DIR;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// ---- relation mapping table (pure, unit-tested) ----------------------------
|
|
64
|
-
// WordNet relation key -> { rel: ConceptNet relation URI, flip }. `flip`
|
|
65
|
-
// governs which side of the WordNet edge becomes ConceptNet's `start` vs
|
|
66
|
-
// `end` — WordNet's meronymy relations point WHOLE -> part (the synset that
|
|
67
|
-
// OWNS a `mero_part`/`mero_member` list is the whole; its listed target is
|
|
68
|
-
// the part/member — confirmed via scripts/wordnet.py's own
|
|
69
|
-
// inverse_synset_rels table: mero_part's inverse is holo_part, so "A mero_part
|
|
70
|
-
// B" reads "B is a part-meronym of A", i.e. A HAS PART B), which is the
|
|
71
|
-
// OPPOSITE of ConceptNet's own /r/PartOf convention (start=part, end=whole —
|
|
72
|
-
// confirmed against a real corpus/conceptnet/slice.jsonl row: "action PartOf
|
|
73
|
-
// keyboard" — action, the PART, is start). mero_part/mero_member both need
|
|
74
|
-
// `flip: true` for exactly this reason. mero_substance needs NO flip:
|
|
75
|
-
// ConceptNet's /r/MadeOf is start=whole, end=substance (confirmed against
|
|
76
|
-
// slice.jsonl's "computer MadeOf hardware" — computer, the WHOLE, is start),
|
|
77
|
-
// which already matches WordNet's own A(whole) -> mero_substance -> B
|
|
78
|
-
// (substance) direction untouched. hypernym/causes/attribute/similar/also
|
|
79
|
-
// are all natural A->B mappings with no direction conflict (the specific
|
|
80
|
-
// synset/adjective source relation IS the ConceptNet start side already).
|
|
81
|
-
export const RELATION_MAP = Object.freeze({
|
|
82
|
-
hypernym: { rel: "/r/IsA", flip: false },
|
|
83
|
-
mero_part: { rel: "/r/PartOf", flip: true },
|
|
84
|
-
mero_member: { rel: "/r/PartOf", flip: true },
|
|
85
|
-
mero_substance: { rel: "/r/MadeOf", flip: false },
|
|
86
|
-
causes: { rel: "/r/Causes", flip: false },
|
|
87
|
-
attribute: { rel: "/r/HasProperty", flip: false },
|
|
88
|
-
similar: { rel: "/r/SimilarTo", flip: false },
|
|
89
|
-
also: { rel: "/r/RelatedTo", flip: false },
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
// Deliberately excluded — no mapped ConceptNet relation, and task scope is to
|
|
93
|
-
// use ONLY the relations Phase 1 already added to conceptnet-map.toml, never
|
|
94
|
-
// invent a new map row: `entails` (verb implication — no ConceptNet analog)
|
|
95
|
-
// and `exemplifies` (instance-of-category — closest is /r/IsA, but that would
|
|
96
|
-
// blur "kind of" and "example of", a real semantic difference ConceptNet
|
|
97
|
-
// itself keeps separate via /r/InstanceOf, which conceptnet-map.toml doesn't
|
|
98
|
-
// carry). `definition`/`example` are free prose, never structured facts.
|
|
99
|
-
export const SKIPPED_RELATIONS = Object.freeze(["entails", "exemplifies"]);
|
|
100
|
-
|
|
101
|
-
// ---- term encoding (pure, unit-tested) -------------------------------------
|
|
102
|
-
// Exact inverse of src/adapters/corpus/conceptnet.mjs's termText() decode
|
|
103
|
-
// (`/^\/c\/en\/([^/]+)/` then `.replace(/_/g, " ")`): lowercase, spaces ->
|
|
104
|
-
// underscores, wrapped as `/c/en/<term>`. Nothing else is touched — any other
|
|
105
|
-
// punctuation (apostrophes, hyphens) round-trips through termText() unchanged
|
|
106
|
-
// because termText only ever substitutes underscores back to spaces.
|
|
107
|
-
export function encodeTerm(raw) {
|
|
108
|
-
const t = String(raw ?? "").trim().toLowerCase().replace(/\s+/g, "_");
|
|
109
|
-
return t ? `/c/en/${t}` : null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** `/c/en/ice_cream` -> "ice cream" — the human-readable form used in
|
|
113
|
-
* surfaceText, mirroring corpus/tier2/generate.mjs's own `humanize`. */
|
|
114
|
-
export const humanize = (term) => String(term ?? "").replace(/_/g, " ");
|
|
115
|
-
|
|
116
|
-
// ---- synonym chaining (pure, unit-tested) ----------------------------------
|
|
117
|
-
// A synset's `members` list are synonyms of each other. Chained N-1 (member
|
|
118
|
-
// [0] paired with each of member[1..N-1]) rather than the full N*(N-1)/2
|
|
119
|
-
// cross product — members average 1.72/synset (185,149 member-slots across
|
|
120
|
-
// 107,526 synsets), and the chain already connects every member into one
|
|
121
|
-
// component (a synonym-chases-synonym graph read), so the cross product would
|
|
122
|
-
// roughly double the fact count for no new information the chain doesn't
|
|
123
|
-
// already encode transitively.
|
|
124
|
-
export function synonymPairs(members) {
|
|
125
|
-
if (!Array.isArray(members) || members.length < 2) return [];
|
|
126
|
-
const [first, ...rest] = members;
|
|
127
|
-
return rest.map((m) => [first, m]);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ---- pass 1: load every synset across every yaml file ----------------------
|
|
131
|
-
const SYNSET_FILE_RE = /^(noun|verb|adj|adv)\..+\.yaml$/;
|
|
132
|
-
|
|
133
|
-
export async function loadAllSynsets(yamlDir) {
|
|
134
|
-
const all = await readdir(yamlDir);
|
|
135
|
-
const files = all.filter((f) => SYNSET_FILE_RE.test(f)).sort();
|
|
136
|
-
if (!files.length) {
|
|
137
|
-
throw new Error(`${yamlDir}: no noun./verb./adj./adv. yaml files found — wrong path?`);
|
|
138
|
-
}
|
|
139
|
-
const bySynset = new Map();
|
|
140
|
-
for (const f of files) {
|
|
141
|
-
const text = await readFile(join(yamlDir, f), "utf8");
|
|
142
|
-
const parsed = parseYaml(text);
|
|
143
|
-
for (const [id, rec] of Object.entries(parsed)) bySynset.set(id, rec);
|
|
144
|
-
}
|
|
145
|
-
return { bySynset, files };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const repTerm = (synset) => {
|
|
149
|
-
const m = Array.isArray(synset?.members) ? synset.members : [];
|
|
150
|
-
return m.length ? m[0] : null;
|
|
151
|
-
};
|
|
152
|
-
|
|
153
|
-
// ---- pass 2: synset map -> deduped, sorted ConceptNet-shape rows -----------
|
|
154
|
-
|
|
155
|
-
function makeRowBuilder() {
|
|
156
|
-
const rows = new Map(); // dedupe key -> row
|
|
157
|
-
const add = (rawSubject, rel, rawObject) => {
|
|
158
|
-
const start = encodeTerm(rawSubject);
|
|
159
|
-
const end = encodeTerm(rawObject);
|
|
160
|
-
if (!start || !end || start === end) return; // self-loop / empty term — noise, not a fact
|
|
161
|
-
const key = `${rel} ${start} ${end}`;
|
|
162
|
-
if (rows.has(key)) return;
|
|
163
|
-
rows.set(key, {
|
|
164
|
-
start,
|
|
165
|
-
rel,
|
|
166
|
-
end,
|
|
167
|
-
weight: 1,
|
|
168
|
-
surfaceText: `[[${humanize(rawSubject)}]] ${rel.replace("/r/", "")} [[${humanize(rawObject)}]]`,
|
|
169
|
-
});
|
|
170
|
-
};
|
|
171
|
-
return { rows, add };
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const sortRows = (rows) => rows.slice().sort((a, b) => (
|
|
175
|
-
a.rel !== b.rel ? (a.rel < b.rel ? -1 : 1)
|
|
176
|
-
: a.start !== b.start ? (a.start < b.start ? -1 : 1)
|
|
177
|
-
: a.end < b.end ? -1 : a.end > b.end ? 1 : 0
|
|
178
|
-
));
|
|
179
|
-
|
|
180
|
-
/** Every structural + synonym-chain fact, deterministically sorted — the
|
|
181
|
-
* wordnet-full.jsonl content. */
|
|
182
|
-
export function buildFullFacts(bySynset) {
|
|
183
|
-
const { rows, add } = makeRowBuilder();
|
|
184
|
-
for (const synset of bySynset.values()) {
|
|
185
|
-
const A = repTerm(synset);
|
|
186
|
-
if (!A) continue;
|
|
187
|
-
for (const [wnRel, { rel, flip }] of Object.entries(RELATION_MAP)) {
|
|
188
|
-
const targets = Array.isArray(synset[wnRel]) ? synset[wnRel] : [];
|
|
189
|
-
for (const targetId of targets) {
|
|
190
|
-
const B = repTerm(bySynset.get(targetId));
|
|
191
|
-
if (!B) continue;
|
|
192
|
-
if (flip) add(B, rel, A); else add(A, rel, B);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
for (const [m0, mi] of synonymPairs(synset.members)) add(m0, "/r/Synonym", mi);
|
|
196
|
-
}
|
|
197
|
-
return sortRows([...rows.values()]);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// ---- XL slice: hypernym backbone + synonym chains for the most commonly-
|
|
201
|
-
// referenced synsets, budget-bounded ----------------------------------------
|
|
202
|
-
|
|
203
|
-
/** How many times each synset appears as a hypernym TARGET — a synset's
|
|
204
|
-
* "commonly referenced" proxy: a category many other synsets specialize
|
|
205
|
-
* (e.g. "person", "act", "object") is referenced far more often than a
|
|
206
|
-
* narrow leaf synset. */
|
|
207
|
-
export function hypernymRefCounts(bySynset) {
|
|
208
|
-
const refCount = new Map();
|
|
209
|
-
for (const synset of bySynset.values()) {
|
|
210
|
-
const targets = Array.isArray(synset.hypernym) ? synset.hypernym : [];
|
|
211
|
-
for (const t of targets) refCount.set(t, (refCount.get(t) || 0) + 1);
|
|
212
|
-
}
|
|
213
|
-
return refCount;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/** Build the bounded XL slice: the `budget` fact rows are split between the
|
|
217
|
-
* hypernym backbone and synonym chains in the SAME proportion the full
|
|
218
|
-
* corpus's real hypernym-edge-count : synonym-chain-fact-count ratio has
|
|
219
|
-
* (measured from `full`, not a hand-picked constant) — the backbone gets the
|
|
220
|
-
* hypernym edges whose TARGET is most commonly referenced first; the
|
|
221
|
-
* synonym budget goes to the full chain of the most commonly-referenced
|
|
222
|
-
* synsets (same ranking), walked in ranked order until the budget is spent. */
|
|
223
|
-
export function buildXlFacts(bySynset, full, budget = 24000) {
|
|
224
|
-
const refCount = hypernymRefCounts(bySynset);
|
|
225
|
-
|
|
226
|
-
const hypernymTotal = full.filter((r) => r.rel === "/r/IsA").length;
|
|
227
|
-
const synonymTotal = full.filter((r) => r.rel === "/r/Synonym").length;
|
|
228
|
-
const denom = hypernymTotal + synonymTotal || 1;
|
|
229
|
-
const hypernymBudget = Math.round(budget * (hypernymTotal / denom));
|
|
230
|
-
const synonymBudget = budget - hypernymBudget;
|
|
231
|
-
|
|
232
|
-
// Backbone: every hypernym EDGE (not yet a fact row), ranked by how
|
|
233
|
-
// commonly-referenced its TARGET synset is, ties broken deterministically.
|
|
234
|
-
const edges = [];
|
|
235
|
-
for (const [id, synset] of bySynset) {
|
|
236
|
-
const targets = Array.isArray(synset.hypernym) ? synset.hypernym : [];
|
|
237
|
-
for (const t of targets) edges.push({ source: id, target: t });
|
|
238
|
-
}
|
|
239
|
-
edges.sort((a, b) => {
|
|
240
|
-
const byRef = (refCount.get(b.target) || 0) - (refCount.get(a.target) || 0);
|
|
241
|
-
if (byRef) return byRef;
|
|
242
|
-
if (a.target !== b.target) return a.target < b.target ? -1 : 1;
|
|
243
|
-
return a.source < b.source ? -1 : 1;
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
const { rows, add } = makeRowBuilder();
|
|
247
|
-
for (const { source, target } of edges) {
|
|
248
|
-
if (rows.size >= hypernymBudget) break;
|
|
249
|
-
const A = repTerm(bySynset.get(source));
|
|
250
|
-
const B = repTerm(bySynset.get(target));
|
|
251
|
-
if (!A || !B) continue;
|
|
252
|
-
add(A, "/r/IsA", B);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Synonym chains: rank EVERY synset by the same "commonly referenced"
|
|
256
|
-
// proxy (0 for a synset that never appears as a hypernym target), then walk
|
|
257
|
-
// down taking each synset's FULL N-1 chain until the synonym budget is met
|
|
258
|
-
// (a synset's chain is never split — "~budget", not an exact cap).
|
|
259
|
-
const rankedSynsets = [...bySynset.entries()].sort((a, b) => {
|
|
260
|
-
const byRef = (refCount.get(b[0]) || 0) - (refCount.get(a[0]) || 0);
|
|
261
|
-
if (byRef) return byRef;
|
|
262
|
-
return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
|
|
263
|
-
});
|
|
264
|
-
let synonymCount = 0;
|
|
265
|
-
for (const [, synset] of rankedSynsets) {
|
|
266
|
-
if (synonymCount >= synonymBudget) break;
|
|
267
|
-
const pairs = synonymPairs(synset.members);
|
|
268
|
-
if (!pairs.length) continue;
|
|
269
|
-
for (const [m0, mi] of pairs) add(m0, "/r/Synonym", mi);
|
|
270
|
-
synonymCount += pairs.length;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
return sortRows([...rows.values()]);
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// ---- output ------------------------------------------------------------
|
|
277
|
-
|
|
278
|
-
const toJsonl = (rows) => rows.map((r) => JSON.stringify(r)).join("\n") + "\n";
|
|
279
|
-
const sha256 = (text) => createHash("sha256").update(text).digest("hex");
|
|
280
|
-
|
|
281
|
-
async function main() {
|
|
282
|
-
const yamlDir = resolveYamlDir();
|
|
283
|
-
process.stderr.write(`corpus/wordnet/generate.mjs: reading ${yamlDir}\n`);
|
|
284
|
-
const { bySynset, files } = await loadAllSynsets(yamlDir);
|
|
285
|
-
process.stderr.write(` loaded ${bySynset.size} synsets across ${files.length} files\n`);
|
|
286
|
-
|
|
287
|
-
const full = buildFullFacts(bySynset);
|
|
288
|
-
const xl = buildXlFacts(bySynset, full);
|
|
289
|
-
process.stderr.write(` wordnet-full: ${full.length} facts\n`);
|
|
290
|
-
process.stderr.write(` wordnet-xl: ${xl.length} facts\n`);
|
|
291
|
-
|
|
292
|
-
await mkdir(WORDNET_OUT_DIR, { recursive: true });
|
|
293
|
-
const fullText = toJsonl(full);
|
|
294
|
-
const xlText = toJsonl(xl);
|
|
295
|
-
await writeFile(join(WORDNET_OUT_DIR, "wordnet-full.jsonl"), fullText);
|
|
296
|
-
await writeFile(join(WORDNET_OUT_DIR, "wordnet-xl.jsonl"), xlText);
|
|
297
|
-
|
|
298
|
-
const manifest = {
|
|
299
|
-
version: 1,
|
|
300
|
-
generated: "by corpus/wordnet/generate.mjs",
|
|
301
|
-
corpuses: [
|
|
302
|
-
{
|
|
303
|
-
id: "wordnet-xl",
|
|
304
|
-
kind: "language",
|
|
305
|
-
description: "A bounded ~24,000-fact slice of Princeton WordNet / Open English WordNet, prioritizing the hypernym (IsA) backbone and synonym chains for the most commonly-referenced synsets. Mechanically derived from Open English WordNet (CC-BY-4.0).",
|
|
306
|
-
source: { kind: "curated", tool: "corpus/wordnet/generate.mjs" },
|
|
307
|
-
file: "wordnet-xl.jsonl",
|
|
308
|
-
facts: xl.length,
|
|
309
|
-
bytes: Buffer.byteLength(xlText),
|
|
310
|
-
sha256: sha256(xlText),
|
|
311
|
-
license: "CC-BY-4.0",
|
|
312
|
-
},
|
|
313
|
-
{
|
|
314
|
-
id: "wordnet-full",
|
|
315
|
-
kind: "language",
|
|
316
|
-
description: "The complete ConceptNet-shape conversion of Princeton WordNet / Open English WordNet's structural relations (hypernym, meronymy, causes, attribute, similar, also) plus member-synonym chains. Derived from Open English WordNet (CC-BY-4.0).",
|
|
317
|
-
source: { kind: "curated", tool: "corpus/wordnet/generate.mjs" },
|
|
318
|
-
file: "wordnet-full.jsonl",
|
|
319
|
-
facts: full.length,
|
|
320
|
-
bytes: Buffer.byteLength(fullText),
|
|
321
|
-
sha256: sha256(fullText),
|
|
322
|
-
license: "CC-BY-4.0",
|
|
323
|
-
},
|
|
324
|
-
],
|
|
325
|
-
};
|
|
326
|
-
const manifestText = JSON.stringify(manifest, null, 2) + "\n";
|
|
327
|
-
await writeFile(join(WORDNET_OUT_DIR, "manifest.json"), manifestText);
|
|
328
|
-
process.stderr.write(`wrote corpus/wordnet/manifest.json (${manifest.corpuses.length} corpuses)\n`);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
const isMain = process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href;
|
|
332
|
-
if (isMain) await main();
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
// prose-tokens.mjs — the adapters layer's own tokenizer, shared by every
|
|
2
|
-
// adapter that writes prose tokens (the memory store, graph-build). Adapters
|
|
3
|
-
// may not import the domain layer, while prose.mjs (the graph/ask side's
|
|
4
|
-
// canonical tokenizer) is domain and may not import adapters — so this layer
|
|
5
|
-
// carries its own copy of the primitives it stores tokens with. The two copies
|
|
6
|
-
// must stay byte-identical: the parity suite in
|
|
7
|
-
// test/adapters/prose-tokens.test.mjs pins every function here to its
|
|
8
|
-
// prose.mjs twin, so a change to either side fails loudly until both move
|
|
9
|
-
// together.
|
|
10
|
-
|
|
11
|
-
const STOPWORDS = new Set(
|
|
12
|
-
("a an and or but the of to in on at for with from by as is are was were be been being " +
|
|
13
|
-
"it its this that these those i you he she they we me my your our do does did not no " +
|
|
14
|
-
"yes if then else than so such can will would should could may might about into over " +
|
|
15
|
-
"under out up down off again more most some any all what which who whom whose when " +
|
|
16
|
-
"where why how").split(/\s+/),
|
|
17
|
-
);
|
|
18
|
-
|
|
19
|
-
const MAX_TOKEN_LEN = 40; // drops hash-like/garbage tokens
|
|
20
|
-
const MAX_TOKENS_PER_DOC = 120; // bounds cost on a pathologically long docstring/name
|
|
21
|
-
|
|
22
|
-
/** Split an identifier or a path-like name into lowercase word tokens.
|
|
23
|
-
* Handles camelCase, PascalCase, snake_case, kebab-case, dotted names, path
|
|
24
|
-
* separators, and acronym runs ("HTTPSConnection" -> https/connection,
|
|
25
|
-
* "parseXML" -> parse/xml). Filters single-character tokens (loop-variable noise). */
|
|
26
|
-
export function splitIdentifierWords(raw) {
|
|
27
|
-
if (!raw) return [];
|
|
28
|
-
let s = String(raw).replace(/\.[A-Za-z0-9]+$/, ""); // strip a trailing file extension only
|
|
29
|
-
s = s
|
|
30
|
-
.replace(/[/\\]/g, " ") // path separators
|
|
31
|
-
.replace(/([a-z0-9])([A-Z])/g, "$1 $2") // camelCase / word|Digit boundary
|
|
32
|
-
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") // acronym run -> TitleCase (HTTPSConnection)
|
|
33
|
-
.replace(/([A-Za-z])([0-9])/g, "$1 $2")
|
|
34
|
-
.replace(/([0-9])([A-Za-z])/g, "$1 $2")
|
|
35
|
-
.replace(/[_\-.]+/g, " ");
|
|
36
|
-
return s.split(/\s+/).map((w) => w.toLowerCase()).filter((w) => w.length > 1 && w.length <= MAX_TOKEN_LEN);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Tokenize free prose (a docstring/doc-comment) — lowercase words, punctuation stripped,
|
|
40
|
-
* common stopwords and single-/over-length tokens dropped, capped at MAX_TOKENS_PER_DOC. */
|
|
41
|
-
export function tokenizeProse(text) {
|
|
42
|
-
if (!text) return [];
|
|
43
|
-
const out = [];
|
|
44
|
-
const seen = new Set();
|
|
45
|
-
for (const raw of String(text).toLowerCase().split(/[^a-z0-9]+/)) {
|
|
46
|
-
if (raw.length < 2 || raw.length > MAX_TOKEN_LEN || STOPWORDS.has(raw)) continue;
|
|
47
|
-
if (seen.has(raw)) continue;
|
|
48
|
-
seen.add(raw);
|
|
49
|
-
out.push(raw);
|
|
50
|
-
if (out.length >= MAX_TOKENS_PER_DOC) break;
|
|
51
|
-
}
|
|
52
|
-
return out;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** The combined, deduped, sorted token set for one individual: its (decomposed) name
|
|
56
|
-
* plus any captured doc text. Returns [] if there's nothing to index (never null). */
|
|
57
|
-
export function proseTokensFor({ name, doc } = {}) {
|
|
58
|
-
const set = new Set([...splitIdentifierWords(name), ...tokenizeProse(doc)]);
|
|
59
|
-
return [...set].sort();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Attach a `prose_tokens` attribute to every individual, from its (decomposed)
|
|
63
|
-
* name and captured doc text — except Commit, whose `label` is a truncated
|
|
64
|
-
* SHA, not a decomposable identifier: it tokenizes `message` instead. Mutates
|
|
65
|
-
* and returns the same array; `enabled=false` is a no-op. */
|
|
66
|
-
export function attachProseTokens(individuals, { enabled = true } = {}) {
|
|
67
|
-
if (!enabled) return individuals;
|
|
68
|
-
for (const ind of individuals) {
|
|
69
|
-
const attrs = ind.attributes || [];
|
|
70
|
-
const isCommit = ind.class === "Commit";
|
|
71
|
-
const name = isCommit ? null : ind.label;
|
|
72
|
-
const doc = isCommit
|
|
73
|
-
? attrs.find((a) => a.key === "message")?.value
|
|
74
|
-
: attrs.find((a) => a.key === "doc")?.value;
|
|
75
|
-
const tokens = proseTokensFor({ name, doc });
|
|
76
|
-
if (tokens.length) {
|
|
77
|
-
ind.attributes = [...(ind.attributes || []), { prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }];
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
return individuals;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Build the inverted index (word -> sorted, deduped [individual ids]) from individuals
|
|
84
|
-
* that already carry a `prose_tokens` attribute. Plain object, JSON-serializable —
|
|
85
|
-
* this is what lands as the payload's `proseIndex`. */
|
|
86
|
-
export function buildProseIndex(individuals) {
|
|
87
|
-
const index = Object.create(null);
|
|
88
|
-
for (const ind of individuals) {
|
|
89
|
-
const tokAttr = (ind.attributes || []).find((a) => a.key === "prose_tokens");
|
|
90
|
-
if (!tokAttr?.value) continue;
|
|
91
|
-
for (const word of tokAttr.value.split(" ")) {
|
|
92
|
-
if (!index[word]) index[word] = [];
|
|
93
|
-
index[word].push(ind.id);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
for (const word of Object.keys(index)) index[word].sort();
|
|
97
|
-
return index;
|
|
98
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
// wordnet-source.mjs — reads a LOCAL Open English WordNet clone off disk and
|
|
2
|
-
// indexes it. The clone is never vendored, never committed, never part of the
|
|
3
|
-
// npm package: point TMCT_WORDNET_SRC at it, or keep it at the default path.
|
|
4
|
-
//
|
|
5
|
-
// This is the disk half of the WordNet reader. The parsing half is pure and
|
|
6
|
-
// lives in src/domain/wordnet/yaml.mjs, so it is testable with no clone
|
|
7
|
-
// present; everything here needs the real files.
|
|
8
|
-
|
|
9
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
10
|
-
import { existsSync } from "node:fs";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { parseYaml } from "../domain/wordnet/yaml.mjs";
|
|
14
|
-
|
|
15
|
-
export const WORDNET_SRC = process.env.TMCT_WORDNET_SRC || join(homedir(), "projects", "globalwordnet", "english-wordnet");
|
|
16
|
-
export const WORDNET_YAML_DIR = join(WORDNET_SRC, "src", "yaml");
|
|
17
|
-
|
|
18
|
-
/** True iff a WordNet clone is readable at `yamlDir`. Callers use this to fail
|
|
19
|
-
* with a one-line message rather than a stack trace: these are maintainer
|
|
20
|
-
* tools, never a build dependency. */
|
|
21
|
-
export function hasWordnetSource(yamlDir = WORDNET_YAML_DIR) {
|
|
22
|
-
return existsSync(yamlDir);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** Load one or more noun.<x>/verb.<x>.yaml files into a flat synset-id -> record map. */
|
|
26
|
-
export async function loadSynsets(files, yamlDir = WORDNET_YAML_DIR) {
|
|
27
|
-
const map = new Map();
|
|
28
|
-
for (const f of files) {
|
|
29
|
-
const path = join(yamlDir, f);
|
|
30
|
-
if (!existsSync(path)) continue;
|
|
31
|
-
const parsed = parseYaml(await readFile(path, "utf8"));
|
|
32
|
-
for (const [id, rec] of Object.entries(parsed)) map.set(id, rec);
|
|
33
|
-
}
|
|
34
|
-
return map;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Load the entries-<letter>.yaml files that could contain any of `words`
|
|
38
|
-
* (only the letters actually needed — 28 files, ~1MB-3MB each, no reason to
|
|
39
|
-
* load all 28 when a clump only needs a handful of letters). Returns
|
|
40
|
-
* word -> { n: [{id, synset}], v: [...], a: [...] }. */
|
|
41
|
-
export async function loadEntriesFor(words, yamlDir = WORDNET_YAML_DIR) {
|
|
42
|
-
const letters = new Set();
|
|
43
|
-
for (const w of words) {
|
|
44
|
-
const c = w[0].toLowerCase();
|
|
45
|
-
letters.add(/[a-z]/.test(c) ? c : "0");
|
|
46
|
-
}
|
|
47
|
-
const index = new Map();
|
|
48
|
-
for (const letter of letters) {
|
|
49
|
-
const path = join(yamlDir, `entries-${letter}.yaml`);
|
|
50
|
-
if (!existsSync(path)) continue;
|
|
51
|
-
const parsed = parseYaml(await readFile(path, "utf8"));
|
|
52
|
-
for (const [word, byPos] of Object.entries(parsed)) {
|
|
53
|
-
if (!words.has(word)) continue;
|
|
54
|
-
const senses = {};
|
|
55
|
-
for (const [pos, rec] of Object.entries(byPos || {})) {
|
|
56
|
-
if (pos === "form") continue;
|
|
57
|
-
const list = Array.isArray(rec?.sense) ? rec.sense : [];
|
|
58
|
-
senses[pos] = list.map((s) => ({ id: s.id, synset: s.synset })).filter((s) => s.synset);
|
|
59
|
-
}
|
|
60
|
-
index.set(word, senses);
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return index;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Every noun.*.yaml synset in the clone. */
|
|
67
|
-
export async function loadAllNounSynsets(yamlDir = WORDNET_YAML_DIR) {
|
|
68
|
-
const files = (await readdir(yamlDir)).filter((f) => f.startsWith("noun."));
|
|
69
|
-
return loadSynsets(files, yamlDir);
|
|
70
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
// corpus-matrix.mjs — the fold and the two gap heuristics behind the
|
|
2
|
-
// capability-by-lane coverage matrix, plus the table renderer. Pure: rows in,
|
|
3
|
-
// counts and text out, so the heuristics can be tested against a handful of
|
|
4
|
-
// made-up rows instead of whatever test/corpus happens to hold today.
|
|
5
|
-
//
|
|
6
|
-
// scripts/corpus-matrix.mjs keeps the readdir, the readFile and the printing.
|
|
7
|
-
|
|
8
|
-
/** A row's capability group: the first two dot-segments of its key, so
|
|
9
|
-
* "ask.alias.two-hop" and "ask.alias.miss" are one capability. */
|
|
10
|
-
export const groupOfKey = (key) => key.split(".").slice(0, 2).join(".");
|
|
11
|
-
|
|
12
|
-
/** The key a row is counted under. A row with no key is still a row, and
|
|
13
|
-
* hiding it would understate the lane. */
|
|
14
|
-
export const keyOfRow = (row) => String(row.key ?? "(no key)");
|
|
15
|
-
|
|
16
|
-
/** A key segment naming a miss, a guard or a negation — the row that pins what
|
|
17
|
-
* a capability DECLINES to do, as opposed to its happy path. */
|
|
18
|
-
const NEGATIVE_RE = /(honest-miss|miss|guard|negation|negative|never|decline|refus|unsolvable|unknown|hedge|no-antecedent|untouched|empty)/;
|
|
19
|
-
|
|
20
|
-
export const isNegativeKey = (key) => NEGATIVE_RE.test(key);
|
|
21
|
-
|
|
22
|
-
/** Fold `{ lane, row }` pairs into the two indexes every view needs: the count
|
|
23
|
-
* per group per lane, and the full keys each group was built from. */
|
|
24
|
-
export function tallyRows(entries) {
|
|
25
|
-
const counts = new Map(); // group -> Map<lane, rowCount>
|
|
26
|
-
const fullKeys = new Map(); // group -> Set<full key>
|
|
27
|
-
for (const { lane, row } of entries) {
|
|
28
|
-
const key = keyOfRow(row);
|
|
29
|
-
const group = groupOfKey(key);
|
|
30
|
-
if (!counts.has(group)) counts.set(group, new Map());
|
|
31
|
-
const perLane = counts.get(group);
|
|
32
|
-
perLane.set(lane, (perLane.get(lane) ?? 0) + 1);
|
|
33
|
-
if (!fullKeys.has(group)) fullKeys.set(group, new Set());
|
|
34
|
-
fullKeys.get(group).add(key);
|
|
35
|
-
}
|
|
36
|
-
return { counts, fullKeys };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** The groups the gap heuristics judge. bench.* rows assert a rig runs rather
|
|
40
|
-
* than pinning a capability, so "no negative row" says nothing there. */
|
|
41
|
-
export const behaviourGroups = ({ counts }) =>
|
|
42
|
-
[...counts.keys()].filter((g) => !g.startsWith("bench.")).sort();
|
|
43
|
-
|
|
44
|
-
const rowTotal = (counts, group) => [...counts.get(group).values()].reduce((a, b) => a + b, 0);
|
|
45
|
-
|
|
46
|
-
/** Groups a single row pins end to end. A review candidate, not a hole. */
|
|
47
|
-
export function thinGroups(tally) {
|
|
48
|
-
return behaviourGroups(tally).filter((g) => rowTotal(tally.counts, g) === 1);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Groups whose keys never name a miss, guard or negation — a happy path is
|
|
52
|
-
* pinned and the decline is not. A review candidate, not a hole. */
|
|
53
|
-
export function groupsWithNoNegativeRow(tally) {
|
|
54
|
-
return behaviourGroups(tally).filter((g) => ![...tally.fullKeys.get(g)].some(isNegativeKey));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** The lanes a group has rows in, in the order given. */
|
|
58
|
-
export const lanesOfGroup = ({ counts }, group) => [...counts.get(group).keys()];
|
|
59
|
-
|
|
60
|
-
/** One row per group, one column per lane, an empty cell where a lane has no
|
|
61
|
-
* row for that group. The header row comes first. */
|
|
62
|
-
export function matrixRows({ counts }, lanes) {
|
|
63
|
-
const groups = [...counts.keys()].sort();
|
|
64
|
-
return [
|
|
65
|
-
["key", ...lanes],
|
|
66
|
-
...groups.map((group) => [
|
|
67
|
-
group,
|
|
68
|
-
...lanes.map((lane) => {
|
|
69
|
-
const n = counts.get(group).get(lane);
|
|
70
|
-
return n ? String(n) : "";
|
|
71
|
-
}),
|
|
72
|
-
]),
|
|
73
|
-
];
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** `rows` (header first) as fixed-width text, with a rule under the header.
|
|
77
|
-
* Each column is as wide as its widest cell; trailing padding is trimmed. */
|
|
78
|
-
export function renderTable(rows) {
|
|
79
|
-
const [header, ...body] = rows;
|
|
80
|
-
const widths = header.map((h, col) => Math.max(h.length, ...body.map((r) => r[col].length)));
|
|
81
|
-
const renderLine = (cells) => cells.map((c, col) => c.padEnd(widths[col])).join(" ").trimEnd();
|
|
82
|
-
return [
|
|
83
|
-
renderLine(header),
|
|
84
|
-
renderLine(widths.map((w) => "-".repeat(w))),
|
|
85
|
-
...body.map(renderLine),
|
|
86
|
-
].join("\n");
|
|
87
|
-
}
|
package/src/domain/inflect.mjs
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// inflect.mjs — the regular English -s/-ed/-ing rules, applied to a lemma.
|
|
2
|
-
//
|
|
3
|
-
// WordNet carries lemmas only ("rest" is present, "rests" is absent), and it is
|
|
4
|
-
// the inflected forms that collide with the fuzzy repair tier's targets —
|
|
5
|
-
// "rests" is one edit from "tests". So the real-word collision table expands
|
|
6
|
-
// every lemma through these rules before it looks for collisions.
|
|
7
|
-
//
|
|
8
|
-
// These are the REGULAR rules and nothing else. No irregular table, no stress
|
|
9
|
-
// model: pastOf("run") is "runned" and pastOf("make") is "maked". That is the
|
|
10
|
-
// intended shape. The table's job is to name words the repair tier must not
|
|
11
|
-
// rewrite, and inflectionsOf is generous on purpose (see below) — an extra form
|
|
12
|
-
// costs one repair we decline to make, and the sentence misses honestly, while
|
|
13
|
-
// a missing form costs a real word rewritten into a different question,
|
|
14
|
-
// answered with confidence. The first is the cheaper mistake.
|
|
15
|
-
|
|
16
|
-
import { STOPWORDS } from "./interpret/normalize.mjs";
|
|
17
|
-
import {
|
|
18
|
-
FUZZY_TARGET_WORDS, FUZZY_REPAIR_MIN_LENGTH, fuzzyMatchInSet, fuzzyBound,
|
|
19
|
-
} from "./interpret/fuzzy.mjs";
|
|
20
|
-
|
|
21
|
-
const VOWELS = new Set(["a", "e", "i", "o", "u"]);
|
|
22
|
-
const isVowel = (c) => VOWELS.has(c);
|
|
23
|
-
|
|
24
|
-
/** A single final consonant after a single vowel doubles before -ed/-ing
|
|
25
|
-
* ("run" -> "running"). w, x and y never double. Stress is not modelled, so a
|
|
26
|
-
* second syllable doubles too ("visit" -> "visitting"). */
|
|
27
|
-
export function doublesFinalConsonant(w) {
|
|
28
|
-
const [c3, c2, c1] = [w.at(-3), w.at(-2), w.at(-1)];
|
|
29
|
-
if (!c3 || isVowel(c1) || "wxy".includes(c1)) return false;
|
|
30
|
-
return isVowel(c2) && !isVowel(c3);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function pluralOf(w) {
|
|
34
|
-
if (/(?:s|x|z|ch|sh)$/.test(w)) return `${w}es`;
|
|
35
|
-
if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}ies`;
|
|
36
|
-
return `${w}s`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function pastOf(w) {
|
|
40
|
-
if (w.endsWith("e")) return `${w}d`;
|
|
41
|
-
if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}ied`;
|
|
42
|
-
if (doublesFinalConsonant(w)) return `${w}${w.at(-1)}ed`;
|
|
43
|
-
return `${w}ed`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function gerundOf(w) {
|
|
47
|
-
if (w.endsWith("ie")) return `${w.slice(0, -2)}ying`;
|
|
48
|
-
if (w.endsWith("e") && !/(?:ee|oe|ye)$/.test(w)) return `${w.slice(0, -1)}ing`;
|
|
49
|
-
if (doublesFinalConsonant(w)) return `${w}${w.at(-1)}ing`;
|
|
50
|
-
return `${w}ing`;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Every surface form of `w` the collision table counts as real English. */
|
|
54
|
-
export const inflectionsOf = (w) => [w, pluralOf(w), pastOf(w), gerundOf(w)];
|
|
55
|
-
|
|
56
|
-
/** The words in `realWords` that the repair tier would rewrite onto one of its
|
|
57
|
-
* targets: long enough to reach the tier, not a stopword, not a target itself,
|
|
58
|
-
* and within the fuzzy bound of some target. Sorted, so the table it feeds is
|
|
59
|
-
* reproducible. */
|
|
60
|
-
export function collisionsFrom(realWords) {
|
|
61
|
-
return [...realWords]
|
|
62
|
-
.filter((w) => w.length >= FUZZY_REPAIR_MIN_LENGTH)
|
|
63
|
-
.filter((w) => !STOPWORDS.has(w))
|
|
64
|
-
.filter((w) => !FUZZY_TARGET_WORDS.includes(w))
|
|
65
|
-
.filter((w) => fuzzyMatchInSet(w, FUZZY_TARGET_WORDS, fuzzyBound(w)) !== null)
|
|
66
|
-
.sort();
|
|
67
|
-
}
|