@polycode-projects/the-mechanical-code-talker 2.2.0 → 2.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/bin/tmct.mjs +4 -5
- package/corpus/wordnet/generate.mjs +6 -7
- package/package.json +30 -2
- package/src/adapters/corpus/conceptnet.mjs +1 -1
- package/src/adapters/graph-build.mjs +3 -3
- package/src/adapters/memory/blocks.mjs +2 -2
- package/src/adapters/memory/core.mjs +5 -5
- package/src/adapters/providers/bootstrap.mjs +1 -1
- package/src/adapters/providers/fixture.mjs +1 -1
- package/src/adapters/wink-model.mjs +1 -1
- package/src/adapters/wordnet-source.mjs +70 -0
- package/src/domain/answer-variants.json +1 -1
- package/src/domain/ask-vocab.mjs +2 -2
- package/src/domain/ask.mjs +4 -4
- package/src/domain/codegraph.mjs +3 -3
- package/src/domain/corpus-matrix.mjs +87 -0
- package/src/domain/grammar/ace.mjs +11 -11
- package/src/domain/grammar/lexicon.mjs +3 -3
- package/src/domain/inflect.mjs +67 -0
- package/src/domain/interpret/fuzzy.mjs +1 -1
- package/src/domain/interpret/merge.mjs +1 -1
- package/src/domain/interpret/normalize.mjs +1 -1
- package/src/domain/licences.mjs +68 -0
- package/src/domain/memory/capability.mjs +1 -1
- package/src/domain/memory/trust.mjs +2 -2
- package/src/domain/persona/codegen.mjs +123 -0
- package/src/domain/persona/examples.mjs +26 -0
- package/src/domain/persona/tiers.mjs +270 -0
- package/src/domain/publish-gate.mjs +41 -0
- package/src/domain/router/call-validator.mjs +1 -1
- package/src/domain/router/drive.mjs +3 -4
- package/src/domain/router/registry.mjs +12 -13
- package/src/domain/router/resolver.mjs +18 -5
- package/src/domain/router/results.mjs +3 -3
- package/src/domain/router/taught.mjs +4 -3
- package/src/domain/schemaorg/turtle.mjs +25 -0
- package/src/domain/semcor/parse.mjs +87 -0
- package/src/domain/syllogise.mjs +6 -6
- package/src/domain/version-stamp.mjs +36 -0
- package/src/domain/wordnet/yaml.mjs +133 -0
- package/src/services/chat-session.mjs +2 -2
- package/src/services/chat.mjs +2 -2
- package/src/services/cli-args.mjs +4 -4
- package/src/services/finish.mjs +1 -1
- package/src/services/ledger-viz.mjs +2 -3
- package/src/services/sessions.mjs +4 -4
- package/src/services/viz-theme.mjs +3 -4
- package/src/surfaces/web/memory-ask-browser.bundle.js +1 -18
- package/src/domain/router/guardrail.mjs +0 -116
|
@@ -80,7 +80,7 @@ const REAL_WORD_COLLISIONS = new Set(collisionData.words);
|
|
|
80
80
|
* what it says, and if we don't record that relation the honest answer is a
|
|
81
81
|
* miss. Only the words the tier could actually reach are tabled; anything else
|
|
82
82
|
* never gets this far. */
|
|
83
|
-
|
|
83
|
+
const isRealEnglishWord = (w) => REAL_WORD_COLLISIONS.has(w);
|
|
84
84
|
|
|
85
85
|
/** A query word may be canonicalized only if it is plain alphabetic, not a
|
|
86
86
|
* stopword, and not already vocabulary. Dotted/digit terms (file names, shas)
|
|
@@ -123,7 +123,7 @@ export function mergeStrategyResults(results) {
|
|
|
123
123
|
* for alternateLines. Template only, reads straight off the parsed fields (the
|
|
124
124
|
* same discipline as ask.mjs's describeParse; callers with richer noun tables
|
|
125
125
|
* may pass their own describe). */
|
|
126
|
-
|
|
126
|
+
function describeAlternate(p) {
|
|
127
127
|
if (!p) return "something else";
|
|
128
128
|
if (p.ambiguousParse) return "one of several readings";
|
|
129
129
|
const obj = p.object ?? p.subject ?? "?";
|
|
@@ -404,7 +404,7 @@ export function applyNegationFrames(text) {
|
|
|
404
404
|
// Phrasing frames: route natural phrasings of a members-of-class or
|
|
405
405
|
// where-defined question onto the canonical shape the grammar answers.
|
|
406
406
|
// First match wins; run after applyNegationFrames.
|
|
407
|
-
|
|
407
|
+
const PHRASING_FRAMES = Object.freeze([
|
|
408
408
|
// MEMBERS-of-class → "what does X contain".
|
|
409
409
|
{ re: /^what\s+(?:functions?|methods?|members?|attributes?|fields?|properties)\s+(?:are|is)\s+(?:in|inside|within)\s+(?:the\s+)?(.+?)\??$/i, to: (m) => `what does ${m[1]} contain` },
|
|
410
410
|
{ re: /^what\s+(?:functions?|methods?|members?|attributes?|fields?|properties)\s+(?:does|do)\s+(.+?)\s+have\??$/i, to: (m) => `what does ${m[1]} contain` },
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// licences.mjs — the allowlist, the SPDX expression rule, and the two pure
|
|
2
|
+
// reads the licence check needs. Pure: objects and strings in, verdicts out, so
|
|
3
|
+
// the rule that gates CI can be tested without an installed node_modules tree.
|
|
4
|
+
//
|
|
5
|
+
// The check itself (scripts/check-licences.mjs) shells out to `npm ls` and
|
|
6
|
+
// reads each package's package.json off disk. That is the disk half. This is
|
|
7
|
+
// the deciding half, and the deciding half is where the edge cases are.
|
|
8
|
+
|
|
9
|
+
export const ALLOWED = new Set([
|
|
10
|
+
"MIT",
|
|
11
|
+
"ISC",
|
|
12
|
+
"BSD-2-Clause",
|
|
13
|
+
"BSD-3-Clause",
|
|
14
|
+
"Apache-2.0",
|
|
15
|
+
"MPL-2.0",
|
|
16
|
+
"0BSD",
|
|
17
|
+
"CC0-1.0",
|
|
18
|
+
"Unlicense",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** The licence `pkg` declares, across the three shapes npm has used: the
|
|
22
|
+
* current string, the legacy `{ type }` object, and the legacy `licenses[]`
|
|
23
|
+
* array (read as a choice, so it joins with OR). */
|
|
24
|
+
export function licenseFromPackageJson(pkg) {
|
|
25
|
+
if (typeof pkg.license === "string") return pkg.license;
|
|
26
|
+
if (pkg.license && typeof pkg.license.type === "string") return pkg.license.type;
|
|
27
|
+
if (Array.isArray(pkg.licenses)) return pkg.licenses.map((l) => l.type).join(" OR ");
|
|
28
|
+
return "(none declared)";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True iff `license` is inside the allowlist.
|
|
32
|
+
*
|
|
33
|
+
* A flat OR expression passes when any part is allowlisted, because OR is a
|
|
34
|
+
* choice and we can take the allowlisted one. A flat AND expression needs
|
|
35
|
+
* every part. Everything else — nesting, a WITH exception, an OR and an AND in
|
|
36
|
+
* the same expression, even a redundantly parenthesised single licence like
|
|
37
|
+
* "(MIT)" — returns false and gets reviewed by hand. Those are rare enough
|
|
38
|
+
* that a parser would be more code than the reviews it saves, and false is the
|
|
39
|
+
* safe direction: it stops CI and asks a human, rather than waving through an
|
|
40
|
+
* expression it only half understood. */
|
|
41
|
+
export function isAllowed(license) {
|
|
42
|
+
if (ALLOWED.has(license)) return true;
|
|
43
|
+
const inner = license.replace(/^\(/, "").replace(/\)$/, "");
|
|
44
|
+
if (/[()]|\bWITH\b/.test(inner)) return false;
|
|
45
|
+
if (inner.includes(" OR ") && !inner.includes(" AND ")) {
|
|
46
|
+
return inner.split(" OR ").some((part) => ALLOWED.has(part.trim()));
|
|
47
|
+
}
|
|
48
|
+
if (inner.includes(" AND ") && !inner.includes(" OR ")) {
|
|
49
|
+
return inner.split(" AND ").every((part) => ALLOWED.has(part.trim()));
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Every installed package in an `npm ls --json` dependency tree, deduped by
|
|
55
|
+
* name@version and sorted by name. A node with no `path` is a peer or optional
|
|
56
|
+
* dependency that was never installed, so it cannot ship and is skipped. */
|
|
57
|
+
export function installedPackages(deps) {
|
|
58
|
+
const seen = new Map();
|
|
59
|
+
(function walk(level) {
|
|
60
|
+
for (const [name, node] of Object.entries(level ?? {})) {
|
|
61
|
+
if (node && node.path && node.version) {
|
|
62
|
+
seen.set(`${name}@${node.version}`, { name, version: node.version, path: node.path });
|
|
63
|
+
}
|
|
64
|
+
if (node) walk(node.dependencies);
|
|
65
|
+
}
|
|
66
|
+
})(deps);
|
|
67
|
+
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
68
|
+
}
|
|
@@ -55,7 +55,7 @@ export const NEG_CAPABLE_OF_PREDICATE = negatedPredicate(CAPABLE_OF_PREDICATE);
|
|
|
55
55
|
* order it lists them in. One constant each, in one place, so the verbosity of
|
|
56
56
|
* every case-4 answer is tuned by editing two lines. */
|
|
57
57
|
export const CAPABILITY_REPORT_CAP = 6;
|
|
58
|
-
|
|
58
|
+
const byTrustThenName = (a, b) => (b.trust || 0) - (a.trust || 0) || String(a.subject).localeCompare(String(b.subject));
|
|
59
59
|
|
|
60
60
|
const asSet = (v) => (v instanceof Set ? v : new Set(Array.isArray(v) ? v : [v]));
|
|
61
61
|
|
|
@@ -69,7 +69,7 @@ export const SOURCE_PRIOR = Object.freeze({
|
|
|
69
69
|
entailed: 0.3,
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
73
73
|
export const RECENCY_FLOOR = 0.9; // recency multiplier stays within [0.9, 1.0]
|
|
74
74
|
|
|
75
75
|
// Actor-level (session-scoped) trust — a bounded nudge on a Source's type
|
|
@@ -156,7 +156,7 @@ export function computeTrust(fact, sourcesById = {}, opts = {}) {
|
|
|
156
156
|
|
|
157
157
|
// Laplace/"add-k" pseudo-count: without it a single data point would saturate
|
|
158
158
|
// mgx:sourceReliability to the bare max/min immediately.
|
|
159
|
-
|
|
159
|
+
const RELIABILITY_CONFIDENCE_PSEUDOCOUNT = 19;
|
|
160
160
|
|
|
161
161
|
/** Pure actor-level reliability from a session's asserted-vs-contradicted
|
|
162
162
|
* track record (findContradictions, core.mjs), confidence-scaled by sample
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// codegen.mjs — renders a reviewed persona-tier worksheet as JS source, ready
|
|
2
|
+
// to splice into corpus/tier2/generate.mjs's CORPUSES object, and as the
|
|
3
|
+
// lexicon-core.json noun entries those facts need.
|
|
4
|
+
//
|
|
5
|
+
// Pure: worksheet in, source text out, no imports.
|
|
6
|
+
|
|
7
|
+
export const CLUMP_ORDER = [
|
|
8
|
+
"human-core", "human-places", "human-objects", "human-nature",
|
|
9
|
+
"human-time-events", "human-body-food", "human-mind",
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export const CLUMP_LABEL = {
|
|
13
|
+
"human-core": "people, family, common roles",
|
|
14
|
+
"human-places": "places",
|
|
15
|
+
"human-objects": "objects, clothing, tools",
|
|
16
|
+
"human-nature": "animals, plants, substances",
|
|
17
|
+
"human-time-events": "time and events",
|
|
18
|
+
"human-body-food": "body and food",
|
|
19
|
+
"human-mind": "communication, cognition, feeling",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// The irregular plurals among the words these batches introduce (Latin/Greek-
|
|
23
|
+
// derived cognition/body/nature vocabulary especially). A word absent from this
|
|
24
|
+
// map gets `{}` and relies on the regular -s/-es/-ies suffix fold at LOOKUP
|
|
25
|
+
// time (src/domain/grammar/lexicon.mjs's foldCandidates), same as the vast
|
|
26
|
+
// majority of Small tier's own additions. So this map is not a pluralizer: it
|
|
27
|
+
// is the list of exceptions that folding cannot recover, and declaring a
|
|
28
|
+
// regular plural here would be redundant.
|
|
29
|
+
//
|
|
30
|
+
// This is deliberately NOT src/domain/inflect.mjs's pluralOf, and the two must
|
|
31
|
+
// not be merged. They answer opposite questions. pluralOf GENERATES candidate
|
|
32
|
+
// surface forms for the real-word collision table, where over-generating is the
|
|
33
|
+
// cheap mistake and its own header commits to regular rules only — it wants
|
|
34
|
+
// "foots", because a form it fails to generate is a real word the repair tier
|
|
35
|
+
// may rewrite into a different question. This map DECLARES the one correct
|
|
36
|
+
// plural for a lexicon entry, where "foots" would simply be a lie the grammar
|
|
37
|
+
// then trusts. Teaching pluralOf about "feet" would cost the collision table
|
|
38
|
+
// "foots"; deriving this map from pluralOf would put "foots" in the lexicon.
|
|
39
|
+
export const IRREGULAR_PLURALS = {
|
|
40
|
+
foot: "feet", tooth: "teeth", goose: "geese", ox: "oxen", die: "dice",
|
|
41
|
+
louse: "lice", mouse: "mice", crisis: "crises", analysis: "analyses",
|
|
42
|
+
hypothesis: "hypotheses", thesis: "theses", axis: "axes", basis: "bases",
|
|
43
|
+
oasis: "oases", criterion: "criteria", phenomenon: "phenomena",
|
|
44
|
+
alumnus: "alumni", cactus: "cacti", focus: "foci", radius: "radii",
|
|
45
|
+
fungus: "fungi", nucleus: "nuclei", stimulus: "stimuli", larva: "larvae",
|
|
46
|
+
alga: "algae", vertebra: "vertebrae", antenna: "antennae",
|
|
47
|
+
formula: "formulae", datum: "data", medium: "media", index: "indices",
|
|
48
|
+
matrix: "matrices", appendix: "appendices", curriculum: "curricula",
|
|
49
|
+
memorandum: "memoranda", millennium: "millennia", bacterium: "bacteria",
|
|
50
|
+
stratum: "strata", genus: "genera", species: "species", series: "series",
|
|
51
|
+
sheep: "sheep", deer: "deer", moose: "moose", salmon: "salmon",
|
|
52
|
+
trout: "trout", swine: "swine", offspring: "offspring", spacecraft: "spacecraft",
|
|
53
|
+
aircraft: "aircraft", scissors: "scissors", knife: "knives", wife: "wives",
|
|
54
|
+
life: "lives", leaf: "leaves", loaf: "loaves", thief: "thieves",
|
|
55
|
+
shelf: "shelves", elf: "elves", calf: "calves", half: "halves",
|
|
56
|
+
wolf: "wolves", self: "selves", woman: "women", man: "men",
|
|
57
|
+
child: "children", person: "people", tempo: "tempi",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** One noun's lexicon-core.json entry: an explicit plural only where folding
|
|
61
|
+
* could not recover it. */
|
|
62
|
+
export function lexiconNounEntry(word) {
|
|
63
|
+
const plural = IRREGULAR_PLURALS[word];
|
|
64
|
+
return plural ? { plural } : {};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** One clump's facts as indented JS array-literal source lines. */
|
|
68
|
+
export function factsBlock(facts) {
|
|
69
|
+
return facts.map(([s, r, o]) => ` ["${s}", "${r}", "${o}"],`).join("\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A noun list wrapped at ~8 words/line, matching Small tier's own
|
|
73
|
+
* lexicon-list style. */
|
|
74
|
+
export function nounsListBlock(words) {
|
|
75
|
+
const lines = [];
|
|
76
|
+
for (let i = 0; i < words.length; i += 8) {
|
|
77
|
+
lines.push(` ${words.slice(i, i + 8).map((w) => `"${w}"`).join(", ")},`);
|
|
78
|
+
}
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One CORPUSES entry as JS source, for `id` ("human-medium"/"human-large")
|
|
83
|
+
* from `byClump`, the reviewed worksheet's per-clump {facts, newNouns}. */
|
|
84
|
+
export function buildCorpusEntry(id, tierLabel, byClump) {
|
|
85
|
+
const nounsSrc = CLUMP_ORDER.map((c) => ` // ${c} (+${byClump[c].newNouns.length} words: ${CLUMP_LABEL[c]})\n${nounsListBlock(byClump[c].newNouns)}`).join("\n");
|
|
86
|
+
const factsSrc = CLUMP_ORDER.map((c) => ` // ---- ${c} (+${byClump[c].facts.length} facts, ${tierLabel}) ----\n${factsBlock(byClump[c].facts)}`).join("\n\n");
|
|
87
|
+
|
|
88
|
+
return `
|
|
89
|
+
// ${tierLabel} tier — INCREMENTAL facts beyond ${id === "human-medium" ? "Small (corpus/tier2/human.jsonl)" : "Medium (corpus/tier2/human-medium.jsonl)"} only
|
|
90
|
+
// (PLAN_SEED.md §3's tier-selection design: Small/Medium/Large are SIZES of
|
|
91
|
+
// one bundle, not separate corpus ids — this file holds only what ${tierLabel}
|
|
92
|
+
// ADDS beyond the previous tier). Built by scripts/build-persona-tiers.mjs
|
|
93
|
+
// from the same two locally-cloned WordNet source files as Small
|
|
94
|
+
// (~/projects/globalwordnet/english-wordnet/src/yaml/), automatically
|
|
95
|
+
// curated: candidate words ranked by WordNet sense-count (a commonness
|
|
96
|
+
// proxy, PLAN_SEED.md §12), restricted to each word's own TOP senses (not
|
|
97
|
+
// some rare/slang meaning that happens to live in this domain), obscure/
|
|
98
|
+
// archaic/offensive/pharmaceutical content excluded via a definition-text
|
|
99
|
+
// blocklist plus an explicit word denylist, reviewed by hand before being
|
|
100
|
+
// spliced in here (scripts/apply-persona-tiers.mjs). ${id === "human-large" ? "Large's own facts walk real multi-hop hypernym chains (up to 4 hops, PLAN_SEED.md §3's own \"surgeon ⊑ doctor ⊑ … ⊑ person\" example) wherever WordNet's real structure supports it, not a flat one-hop-per-word cap." : "Medium stays flat, one hop per word, same style as Small."}
|
|
101
|
+
"${id}": {
|
|
102
|
+
kind: "domain",
|
|
103
|
+
description: "The ${tierLabel} tier of the default human-world persona (PLAN_SEED.md): incremental facts beyond ${id === "human-medium" ? "Small" : "Medium"} only — activated alongside \\"human\\" via --persona-size ${id === "human-medium" ? "medium" : "large"}, never active by default.",
|
|
104
|
+
lexicon: {
|
|
105
|
+
nouns: [
|
|
106
|
+
${nounsSrc}
|
|
107
|
+
],
|
|
108
|
+
},
|
|
109
|
+
facts: [
|
|
110
|
+
${factsSrc}
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The CORPUSES source with `entries` spliced in before its closing brace.
|
|
117
|
+
* Throws when the anchor is absent rather than writing a mangled file. */
|
|
118
|
+
export function spliceCorpusEntries(generateSrc, entries) {
|
|
119
|
+
const anchor = "\n};\n\nconst conceptUri = ";
|
|
120
|
+
const idx = generateSrc.indexOf(anchor);
|
|
121
|
+
if (idx === -1) throw new Error("apply-persona-tiers: could not find CORPUSES closing anchor in generate.mjs");
|
|
122
|
+
return generateSrc.slice(0, idx) + entries.join("") + generateSrc.slice(idx + 1);
|
|
123
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// examples.mjs — the two rules that decide whether a WordNet `example:` field
|
|
2
|
+
// can serve as a persona example sentence, and what its text actually is.
|
|
3
|
+
//
|
|
4
|
+
// Pure: values in, values out, no imports.
|
|
5
|
+
|
|
6
|
+
/** A handful of WordNet examples are cross-reference stubs ("see table 1"),
|
|
7
|
+
* not real sentences. Filtering them keeps a re-run reproducing the committed
|
|
8
|
+
* corpus/tier2/human-examples.jsonl exactly — this was the one candidate
|
|
9
|
+
* dropped by hand when that file was first curated. */
|
|
10
|
+
export const isRealSentence = (s) => !/^see\s+\w+\s*\d*\.?$/i.test(String(s).trim());
|
|
11
|
+
|
|
12
|
+
/** A WordNet `example:` is usually a plain string, but a few are an ATTRIBUTED
|
|
13
|
+
* LITERARY QUOTE — a `{source, text}` mapping, e.g. "ecstasy"'s example is
|
|
14
|
+
* `{source: "Charles Dickens", text: "listening to sweet music…"}`. That shape
|
|
15
|
+
* is real and was found live while extending coverage past Small tier's own
|
|
16
|
+
* 665-word list; none of Small's words happened to hit it, so it went uncaught
|
|
17
|
+
* until Medium/Large's much wider coverage.
|
|
18
|
+
*
|
|
19
|
+
* Returns the plain sentence text, or null for any other shape. The literary
|
|
20
|
+
* source is real but this corpus wants a plain example sentence, not a
|
|
21
|
+
* citation index. */
|
|
22
|
+
export function normalizeExample(example) {
|
|
23
|
+
if (typeof example === "string") return example;
|
|
24
|
+
if (example && typeof example === "object" && typeof example.text === "string") return example.text;
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// tiers.mjs — the curation rules that build the Medium/Large persona tiers out
|
|
2
|
+
// of real WordNet structure. No invented facts: every hop and every meronym is
|
|
3
|
+
// a pointer WordNet already declares.
|
|
4
|
+
//
|
|
5
|
+
// Pure throughout — these read in-memory maps a caller loaded from disk, so
|
|
6
|
+
// they are testable with no WordNet clone present. The loading lives in
|
|
7
|
+
// src/adapters/wordnet-source.mjs, the fact targets and the run itself in
|
|
8
|
+
// scripts/build-persona-tiers.mjs.
|
|
9
|
+
|
|
10
|
+
// human-base's own category roots, plus every hypernym TARGET term Small's
|
|
11
|
+
// curation already established as a "root" word (generate.mjs's own comment:
|
|
12
|
+
// "category-root nouns used as a hypernym TARGET") — a real hypernym chain
|
|
13
|
+
// walk stops here rather than continuing on to WordNet's ultra-abstract
|
|
14
|
+
// "entity"/"abstraction"/"physical_entity" tops, which would add depth
|
|
15
|
+
// without adding anything a plain-English question would ever ask about.
|
|
16
|
+
export const STOP_SET = new Set([
|
|
17
|
+
"person", "place", "object", "event", "time", "quantity", "organization", "group",
|
|
18
|
+
"animal", "plant", "furniture", "vehicle", "insect", "emotion", "metal", "liquid",
|
|
19
|
+
"weather", "planet", "jewelry", "cutlery", "government", "material", "artifact",
|
|
20
|
+
"location", "structure", "food", "drink", "clothing", "body", "language", "mind",
|
|
21
|
+
"family", "meal", "season", "number", "entity", "abstraction", "physical_entity",
|
|
22
|
+
"attribute", "state", "act", "communication", "cognition", "measure", "unit",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export const BLOCKLIST_RE = /\b(archaic|obsolete|offensive|derogatory|informal|slang|dialect|euphemism|hypothetical|imaginary|mythical|mythology|extraterrestrial|fictional|taxonomic genus|genus of|family [A-Z]|nonstandard|vulgar|disparaging|obscene|coarse|genital|ethnic slur|ethnic epithet|excrement|contemptuous|insulting|trade name|street name|controlled substance|illegal|sexual assault|monoclonal antibody|chemical compound|chemical formula|proprietary name)\b/i;
|
|
26
|
+
|
|
27
|
+
// A short, explicit denylist for specific words WordNet's own definitions
|
|
28
|
+
// don't reliably self-tag (the blocklist regex above misses some — e.g. the
|
|
29
|
+
// "female genitals" sense of a common word is tagged only "obscene terms
|
|
30
|
+
// for…", but the word itself has an unrelated clean sense too, so it isn't
|
|
31
|
+
// caught by filtering on OTHER senses' definitions). Checked directly
|
|
32
|
+
// against candidate headwords, not definitions.
|
|
33
|
+
export const WORD_DENYLIST = new Set([
|
|
34
|
+
"cunt", "pussy", "dick", "cock", "prick", "twat", "boob", "tit", "tits",
|
|
35
|
+
"fuck", "shit", "piss", "bitch", "whore", "slut", "fag", "faggot", "nigger",
|
|
36
|
+
"nigga", "spic", "chink", "kike", "wetback", "retard", "cripple",
|
|
37
|
+
"asshole", "poop", "rape", "bastard",
|
|
38
|
+
// deictic/function words that happen to carry a marginal WordNet noun
|
|
39
|
+
// sense ("here" = "this place") — technically real, pragmatically not
|
|
40
|
+
// something a plain-English question would ever ask "what is X" about.
|
|
41
|
+
"here", "there", "somewhere", "elsewhere", "nowhere", "anywhere", "everywhere",
|
|
42
|
+
// Real, live test-fixture collisions (test/fixtures/entities.fixture.json's
|
|
43
|
+
// code-graph class/individual names double as ordinary WordNet-common
|
|
44
|
+
// words) — found by actually running the test suite against the first
|
|
45
|
+
// draft of this batch, not guessed in advance. "base"/"button" are
|
|
46
|
+
// exactly the kind of everyday-but-also-a-common-class-name word that
|
|
47
|
+
// will keep recurring as the persona vocabulary grows; excluded rather
|
|
48
|
+
// than editing the shared fixture (many other tests depend on its exact
|
|
49
|
+
// shape). "john" is also excluded on its own merits — WordNet's sense
|
|
50
|
+
// for it (a prostitute's customer) is exactly the "obscure/informal
|
|
51
|
+
// long-tail" this batch's curation is meant to skip, its own definition
|
|
52
|
+
// just doesn't happen to carry one of the blocklist's tag words.
|
|
53
|
+
"base", "button", "register", "john", "store",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const WORD_RE = /^[a-z]+$/;
|
|
57
|
+
|
|
58
|
+
const humanize = (term) => String(term).replace(/_/g, " ");
|
|
59
|
+
|
|
60
|
+
/** Definition text of a synset (first line only — enough for the blocklist). */
|
|
61
|
+
export function defOf(synset) {
|
|
62
|
+
return Array.isArray(synset?.definition) ? synset.definition[0] : synset?.definition || "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every word the lexicon already declares, across ALL THREE parts of speech,
|
|
66
|
+
* plus the previous tier's own nouns.
|
|
67
|
+
*
|
|
68
|
+
* Adjectives and verbs count, not just nouns: a word already declared as an
|
|
69
|
+
* adjective (e.g. "male") must never ALSO become a noun. That was a real bug,
|
|
70
|
+
* caught only by running the suite — the first pass added "male" as a noun
|
|
71
|
+
* since WordNet legitimately has that sense too, which made ACE reclassify
|
|
72
|
+
* "ahab is male" as class-membership (rdfs:subClassOf) instead of the intended
|
|
73
|
+
* property fact (mgx:hasProperty), silently breaking every filter-rule test
|
|
74
|
+
* built on "who is male". Nouns/verbs/adjectives are independent lookup maps
|
|
75
|
+
* and a word CAN legitimately sit in two ("cook", "love" already do, noun +
|
|
76
|
+
* verb), but a NEW second classification for an EXISTING word is never
|
|
77
|
+
* introduced — only the word's original part of speech is authoritative. */
|
|
78
|
+
export function declaredWords(lex, previousTierNouns = []) {
|
|
79
|
+
return new Set([
|
|
80
|
+
...Object.keys(lex.nouns).map((w) => w.toLowerCase()),
|
|
81
|
+
...Object.keys(lex.verbs).map((w) => w.toLowerCase()),
|
|
82
|
+
...Object.keys(lex.adjectives).map((w) => w.toLowerCase()),
|
|
83
|
+
...[...previousTierNouns].map((w) => w.toLowerCase()),
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Walk UP a synset's hypernym chain from `synsetId`, resolving each
|
|
88
|
+
* ancestor's member[0] term, to check membership of a "building-like" root
|
|
89
|
+
* set (human-places' artifact-subtree filter) — up to 8 hops, memoized. */
|
|
90
|
+
export function makeAncestorRootCheck(synsetMap, rootWords) {
|
|
91
|
+
const memo = new Map();
|
|
92
|
+
function isUnderRoot(id, depth = 0) {
|
|
93
|
+
if (depth > 8 || !id) return false;
|
|
94
|
+
if (memo.has(id)) return memo.get(id);
|
|
95
|
+
const s = synsetMap.get(id);
|
|
96
|
+
if (!s) { memo.set(id, false); return false; }
|
|
97
|
+
const members = (s.members || []).map((m) => m.toLowerCase());
|
|
98
|
+
if (members.some((m) => rootWords.has(m))) { memo.set(id, true); return true; }
|
|
99
|
+
const hyperId = Array.isArray(s.hypernym) ? s.hypernym[0] : null;
|
|
100
|
+
const result = hyperId ? isUnderRoot(hyperId, depth + 1) : false;
|
|
101
|
+
memo.set(id, result);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
return isUnderRoot;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// A candidate is only accepted for a clump if the synset we found it in is
|
|
108
|
+
// among the word's own TOP senses overall (its sense-rank in the entries
|
|
109
|
+
// reverse index, 0-based) — otherwise a common, highly polysemous word
|
|
110
|
+
// (e.g. "run", "light", "draw", "back") gets swept in via some rare/slang
|
|
111
|
+
// sense that just happens to live in this domain ("light" = a friend,
|
|
112
|
+
// "draw" = an entertainer), which is a genuinely obscure long-tail sense —
|
|
113
|
+
// just obscure at the SENSE level rather than the word level. Top-3 senses
|
|
114
|
+
// (rank <= 2) gives real latitude (a word's domain-relevant meaning is very
|
|
115
|
+
// often sense 2 or 3, not always sense 1) while still excluding deep-tail
|
|
116
|
+
// marginal senses.
|
|
117
|
+
export const MAX_SENSE_RANK = 2;
|
|
118
|
+
|
|
119
|
+
export function senseRank(word, synsetId, entriesIdx) {
|
|
120
|
+
const nounSenses = entriesIdx.get(word)?.senses?.n;
|
|
121
|
+
if (!nounSenses) return -1;
|
|
122
|
+
return nounSenses.findIndex((s) => s.synset === synsetId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Candidate headwords from a set of synsets: up to 2 qualifying members per
|
|
126
|
+
* synset (real WordNet synonyms, not invented) — word regex, length bound,
|
|
127
|
+
* not blocklisted, not already used, and the synset must be among the
|
|
128
|
+
* word's own top senses (see senseRank above). */
|
|
129
|
+
export function collectCandidates(synsetEntries, usedWords, entriesIdx) {
|
|
130
|
+
const candidates = new Map(); // word -> first-seen synsetId (existence only)
|
|
131
|
+
for (const [id, synset] of synsetEntries) {
|
|
132
|
+
if (BLOCKLIST_RE.test(defOf(synset))) continue;
|
|
133
|
+
const members = synset.members || [];
|
|
134
|
+
let taken = 0;
|
|
135
|
+
for (const m of members) {
|
|
136
|
+
if (taken >= 2) break;
|
|
137
|
+
const w = String(m).toLowerCase();
|
|
138
|
+
if (!WORD_RE.test(w) || w.length < 2 || w.length > 16 || WORD_DENYLIST.has(w)) continue;
|
|
139
|
+
if (usedWords.has(w) || candidates.has(w)) continue;
|
|
140
|
+
const rank = senseRank(w, id, entriesIdx);
|
|
141
|
+
if (rank < 0 || rank > MAX_SENSE_RANK) continue;
|
|
142
|
+
candidates.set(w, id);
|
|
143
|
+
taken += 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return candidates;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Resolve a word to the SPECIFIC synset it was discovered under in the
|
|
150
|
+
* clump's own source file(s) — deliberately NOT the entries index's
|
|
151
|
+
* sense-1 (a word's globally-most-frequent sense across ALL of WordNet is
|
|
152
|
+
* routinely a completely different domain than the clump it was found in —
|
|
153
|
+
* e.g. "run" turning up as a noun.group.yaml member resolves, via a global
|
|
154
|
+
* sense-1 lookup, to a baseball score, not anything group-related). The
|
|
155
|
+
* entries index is used ONLY for the sense-count ranking heuristic
|
|
156
|
+
* (rankCandidates), never for resolution. */
|
|
157
|
+
export function resolveSynset(word, candidateSynsetId, synsetMap) {
|
|
158
|
+
const synset = synsetMap.get(candidateSynsetId);
|
|
159
|
+
if (!synset) return null;
|
|
160
|
+
return { synsetId: candidateSynsetId, synset };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Chemical/pharmaceutical trade names (e.g. "methylenedioxymethamphetamine",
|
|
164
|
+
// "infliximab") are almost always a single very long unbroken word with no
|
|
165
|
+
// spaces — real everyday concepts, even multi-word ones ("medium of
|
|
166
|
+
// exchange"), never have an individual token this long. A cheap, effective
|
|
167
|
+
// shape filter: reject any candidate/hypernym/meronym TERM with a token over
|
|
168
|
+
// 15 characters, independent of the definition-text blocklist (which these
|
|
169
|
+
// technical entries routinely don't trip, since their definitions are
|
|
170
|
+
// clinically neutral — "a monoclonal antibody used to treat…" carries none
|
|
171
|
+
// of the archaic/slang/offensive keywords above).
|
|
172
|
+
export const looksLikeCommonTerm = (term) => String(term).split(" ").every((tok) => tok.length <= 15);
|
|
173
|
+
|
|
174
|
+
/** One real hypernym hop: [subjectTerm, "/r/IsA", hypernymTerm], plus the
|
|
175
|
+
* next synset to continue from (or null at a stop/dead end/blocklisted
|
|
176
|
+
* ancestor — a chain never walks INTO an obscure/archaic/mythical/technical
|
|
177
|
+
* concept, even if the word that started the chain was clean). */
|
|
178
|
+
export function nextHop(term, synsetId, synsetMap) {
|
|
179
|
+
const s = synsetMap.get(synsetId);
|
|
180
|
+
const hyperId = Array.isArray(s?.hypernym) ? s.hypernym[0] : null;
|
|
181
|
+
if (!hyperId) return null;
|
|
182
|
+
const hyper = synsetMap.get(hyperId);
|
|
183
|
+
if (BLOCKLIST_RE.test(defOf(hyper))) return null;
|
|
184
|
+
const hyperTerm = Array.isArray(hyper?.members) ? humanize(hyper.members[0]).toLowerCase() : null;
|
|
185
|
+
if (!hyperTerm || hyperTerm === term || !looksLikeCommonTerm(hyperTerm)) return null;
|
|
186
|
+
return { fact: [term, "/r/IsA", hyperTerm], nextSynsetId: hyperId, nextTerm: hyperTerm };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A real meronym-derived secondary fact for `synset`, preferring
|
|
190
|
+
* mero_part > mero_member > mero_substance (word HasA part / HasA member /
|
|
191
|
+
* MadeOf substance) — real WordNet pointers, never invented. */
|
|
192
|
+
export function meronymFact(word, synset, synsetMap) {
|
|
193
|
+
const pick = (key, rel) => {
|
|
194
|
+
const ids = synset[key];
|
|
195
|
+
if (!Array.isArray(ids) || !ids.length) return null;
|
|
196
|
+
const target = synsetMap.get(ids[0]);
|
|
197
|
+
if (BLOCKLIST_RE.test(defOf(target))) return null;
|
|
198
|
+
const term = Array.isArray(target?.members) ? humanize(target.members[0]).toLowerCase() : null;
|
|
199
|
+
if (!term || term === word || !looksLikeCommonTerm(term)) return null;
|
|
200
|
+
return [word, rel, term];
|
|
201
|
+
};
|
|
202
|
+
return pick("mero_part", "/r/HasA") || pick("mero_member", "/r/HasA") || pick("mero_substance", "/r/MadeOf");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Sense-count score, tie-broken by shorter word then alphabetically —
|
|
206
|
+
// deterministic across re-runs (same inputs -> same output, no Math.random).
|
|
207
|
+
export function rankCandidates(words, entriesIdx) {
|
|
208
|
+
return [...words].sort((a, b) => {
|
|
209
|
+
const sa = entriesIdx.get(a)?.total || 0;
|
|
210
|
+
const sb = entriesIdx.get(b)?.total || 0;
|
|
211
|
+
if (sb !== sa) return sb - sa;
|
|
212
|
+
if (a.length !== b.length) return a.length - b.length;
|
|
213
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Build one tier's incremental facts + new-noun list for one clump.
|
|
218
|
+
* `candidatesMap` is word -> the SPECIFIC synset id it was discovered under
|
|
219
|
+
* (from collectCandidates) — the actual resolution source (see
|
|
220
|
+
* resolveSynset's doc comment); `entriesIdx` is used only for ranking. */
|
|
221
|
+
export function buildClump(clumpId, candidatesMap, entriesIdx, synsetMap, target, usedWords, seenTriples, opts) {
|
|
222
|
+
const { maxHops } = opts;
|
|
223
|
+
const ranked = rankCandidates(candidatesMap.keys(), entriesIdx);
|
|
224
|
+
const facts = [];
|
|
225
|
+
const newNouns = [];
|
|
226
|
+
for (const word of ranked) {
|
|
227
|
+
if (facts.length >= target) break;
|
|
228
|
+
if (usedWords.has(word)) continue;
|
|
229
|
+
const resolved = resolveSynset(word, candidatesMap.get(word), synsetMap);
|
|
230
|
+
if (!resolved) continue;
|
|
231
|
+
const wordFacts = [];
|
|
232
|
+
let curTerm = word;
|
|
233
|
+
let curSynsetId = resolved.synsetId;
|
|
234
|
+
for (let hop = 0; hop < maxHops; hop += 1) {
|
|
235
|
+
const h = nextHop(curTerm, curSynsetId, synsetMap);
|
|
236
|
+
if (!h) break;
|
|
237
|
+
const key = `${h.fact[0]}|${h.fact[1]}|${h.fact[2]}`;
|
|
238
|
+
if (!seenTriples.has(key)) { wordFacts.push(h.fact); seenTriples.add(key); }
|
|
239
|
+
if (STOP_SET.has(h.nextTerm)) break;
|
|
240
|
+
curTerm = h.nextTerm;
|
|
241
|
+
curSynsetId = h.nextSynsetId;
|
|
242
|
+
}
|
|
243
|
+
const mero = meronymFact(word, resolved.synset, synsetMap);
|
|
244
|
+
if (mero) {
|
|
245
|
+
const key = `${mero[0]}|${mero[1]}|${mero[2]}`;
|
|
246
|
+
if (!seenTriples.has(key)) { wordFacts.push(mero); seenTriples.add(key); }
|
|
247
|
+
}
|
|
248
|
+
if (!wordFacts.length) continue; // every candidate hop/mero fact was already present elsewhere — skip
|
|
249
|
+
facts.push(...wordFacts);
|
|
250
|
+
newNouns.push(word);
|
|
251
|
+
usedWords.add(word);
|
|
252
|
+
}
|
|
253
|
+
return { facts, newNouns, clumpId, requested: target, got: facts.length };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Final safety net: a denylisted word (see WORD_DENYLIST) can still reach a
|
|
257
|
+
* fact as a HYPERNYM/MERONYM TARGET (nextHop/meronymFact only check the
|
|
258
|
+
* definition-text blocklist + the shape filter, not the explicit word list —
|
|
259
|
+
* that list is deliberately checked here, once, against every final fact's
|
|
260
|
+
* subject AND object, rather than duplicated at every resolution call site).
|
|
261
|
+
* Drops the fact outright and prunes any newNoun left with no remaining
|
|
262
|
+
* supporting fact (mirrors generate.mjs's own verifyLexiconAlignment
|
|
263
|
+
* "orphaned metadata" check). */
|
|
264
|
+
export function stripDenylisted(result) {
|
|
265
|
+
const hasDenied = (term) => term.split(" ").some((tok) => WORD_DENYLIST.has(tok));
|
|
266
|
+
const facts = result.facts.filter((f) => !hasDenied(f[0]) && !hasDenied(f[2]));
|
|
267
|
+
const survivingTerms = new Set(facts.flatMap((f) => [f[0], f[2]]));
|
|
268
|
+
const newNouns = result.newNouns.filter((w) => survivingTerms.has(w));
|
|
269
|
+
return { ...result, facts, newNouns, got: facts.length };
|
|
270
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// publish-gate.mjs — should this commit publish to npm? Pure: two version
|
|
2
|
+
// strings in, a decision and a reason out, no imports, so CI can ask without
|
|
3
|
+
// npm ci.
|
|
4
|
+
//
|
|
5
|
+
// CI asked this with `[ "$PUBLISHED" = "$LOCAL" ]`, which only answers "same".
|
|
6
|
+
// A local version BELOW what npm already has — a revert, a bad merge, a branch
|
|
7
|
+
// landing behind — reads as "different" and goes to `npm publish`, where the
|
|
8
|
+
// registry rejects it and the job fails on a confusing error instead of a clear
|
|
9
|
+
// skip. Equality cannot tell "ahead" from "behind"; comparing can.
|
|
10
|
+
|
|
11
|
+
const CORE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
|
|
12
|
+
|
|
13
|
+
/** [major, minor, patch] for a semver string, or null if it isn't one. */
|
|
14
|
+
function core(version) {
|
|
15
|
+
const found = CORE.exec(String(version ?? "").trim());
|
|
16
|
+
return found ? [Number(found[1]), Number(found[2]), Number(found[3])] : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** -1 | 0 | 1 comparing the release cores of `a` and `b`. Pre-release and build
|
|
20
|
+
* metadata are ignored: this gate decides whether a release moved, and 2.2.0
|
|
21
|
+
* and 2.2.0-rc.1 are the same release for that purpose. */
|
|
22
|
+
export function compareVersions(a, b) {
|
|
23
|
+
const [x, y] = [core(a), core(b)];
|
|
24
|
+
if (!x || !y) throw new Error(`not a comparable version: "${!x ? a : b}"`);
|
|
25
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Should `local` publish, given the registry currently serves `published`?
|
|
30
|
+
* `published` is "none" when the package has never been published.
|
|
31
|
+
* Returns { publish, reason } — the reason is what CI prints either way. */
|
|
32
|
+
export function shouldPublish(local, published) {
|
|
33
|
+
if (!core(local)) throw new Error(`not a publishable version: "${local}"`);
|
|
34
|
+
if (published === "none" || published == null || published === "") {
|
|
35
|
+
return { publish: true, reason: `publishing ${local} (nothing published yet)` };
|
|
36
|
+
}
|
|
37
|
+
const order = compareVersions(local, published);
|
|
38
|
+
if (order === 0) return { publish: false, reason: `npm already has ${local} — no version bump in this push, skipping publish` };
|
|
39
|
+
if (order < 0) return { publish: false, reason: `local ${local} is BEHIND npm's ${published} — refusing to publish; the registry would reject it` };
|
|
40
|
+
return { publish: true, reason: `publishing ${local} (npm currently has ${published})` };
|
|
41
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/domain/router/call-validator.mjs — pure registry validators shared by the
|
|
2
|
-
// product router (resolver /
|
|
2
|
+
// product router (resolver / goal-reasoner) + the bench grader
|
|
3
3
|
// (agentbench/grade.mjs re-exports these). Depends ONLY on registry.mjs — no
|
|
4
4
|
// bench code — so the product←bench dependency stays inverted: the bench
|
|
5
5
|
// imports the product, never the other way round. No I/O, no Date.now, no LLM.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/domain/router/drive.mjs — the product-facing drive of the capability router: the
|
|
2
2
|
// piece that turns a real English request into a real, executed answer over a
|
|
3
|
-
// real repo graph. registry/resolver/planner/
|
|
4
|
-
//
|
|
3
|
+
// real repo graph. registry/resolver/planner/goal-reasoner/call-validator
|
|
4
|
+
// are all pure, deterministic decision machinery — this module
|
|
5
5
|
// is the thin, stateful shell around them that a CLI or chat surface calls:
|
|
6
6
|
// build a { dispatch, resolve, graph } context against the repo's actual code
|
|
7
7
|
// graph, then run a request through resolver -> planner -> goal-reasoner.
|
|
@@ -162,8 +162,7 @@ const WORLD_GOAL_RE = new RegExp(
|
|
|
162
162
|
* ground the move sequence by pure simulation over the taught rules
|
|
163
163
|
* (compileDomain + stateFromFacts + compileGoal + findActionPath — all
|
|
164
164
|
* read-only). Returned calls are NEVER dispatched: taught records carry
|
|
165
|
-
* readOnly:false
|
|
166
|
-
* "next" executes move 1. Returns a loopResult, or null when the request is
|
|
165
|
+
* readOnly:false, so the plan is simulated and chat's "next" executes move 1. Returns a loopResult, or null when the request is
|
|
167
166
|
* not a world-goal shape (the caller falls through to the goal-reasoner). */
|
|
168
167
|
export async function runTaughtPlan(request, tools, ctx) {
|
|
169
168
|
const m = WORLD_GOAL_RE.exec(String(request || "").trim());
|