@polycode-projects/the-mechanical-code-talker 1.4.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/ROADMAP.md +98 -11
- package/corpus/README.md +23 -22
- package/corpus/seon/README.md +7 -6
- package/corpus/seon/concepts.jsonl +119 -0
- package/corpus/tier2/general.jsonl +49 -0
- package/corpus/tier2/generate.mjs +68 -0
- package/corpus/tier2/manifest.json +14 -0
- package/data/templates/constructions/agent-noun-relations.toml +98 -0
- package/data/templates/responses.jsonl +1 -0
- package/package.json +5 -1
- package/src/ask-vocab.mjs +39 -1
- package/src/ask.mjs +278 -32
- package/src/chat.mjs +681 -212
- package/src/completions/complete.mjs +138 -0
- package/src/completions/group.mjs +171 -0
- package/src/completions/infer.mjs +395 -0
- package/src/completions/prune.mjs +156 -0
- package/src/completions/rank.mjs +154 -0
- package/src/completions/search.mjs +85 -0
- package/src/corpus/conceptnet.mjs +36 -3
- package/src/corpus/unknown-ingest.mjs +209 -0
- package/src/extensions.mjs +14 -4
- package/src/finish.mjs +61 -18
- package/src/grammar/ace.mjs +24 -338
- package/src/grammar/lexicon.mjs +37 -194
- package/src/interpret/pipeline.mjs +23 -2
- package/src/interpret/strategies/constructions.mjs +207 -0
- package/src/interpret/strategies/grammar.mjs +24 -3
- package/src/interpret/strategies/keywords.mjs +34 -0
- package/src/memory/blocks.mjs +7 -2
- package/src/memory/core.mjs +283 -20
- package/src/memory/shacl.mjs +114 -0
- package/src/prose.mjs +5 -1
- package/src/syllogise.mjs +0 -0
- package/src/grammar/lexicon-core.json +0 -302
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// completions/infer.mjs — Stage 3 ("inference between groups") of PLAN_COMPLETIONS.md's
|
|
2
|
+
// six-stage mechanical-text-generation pipeline. §4's staging table: "cross-group inference
|
|
3
|
+
// wired to the existing entailment/rule-chase machinery, closed inference-relation
|
|
4
|
+
// vocabulary... Reuses syllogise.mjs/resolveRelationChase, no new engine... Every asserted
|
|
5
|
+
// inference cites a concrete licensing test, zero fabricated relationships on a hand-labeled
|
|
6
|
+
// set" — this file, plus test/completions-infer.test.mjs's hand-labeled fixture, is that exit
|
|
7
|
+
// criterion made concrete.
|
|
8
|
+
//
|
|
9
|
+
// "Apply tmct's existing entailment machinery... not just to graph facts, but to
|
|
10
|
+
// relationships BETWEEN retrieved text groups... a closed, small inference-relation
|
|
11
|
+
// vocabulary, deliberately mirroring marginalia's own TYPED_EDGES closed set... A relationship
|
|
12
|
+
// between two groups is only asserted when a concrete, named test licenses it... never
|
|
13
|
+
// inferred by prose similarity alone" (PLAN_COMPLETIONS.md §1.3). Four relations, each with
|
|
14
|
+
// its own mechanical, named licensing test — see the four test*() functions below, one per
|
|
15
|
+
// relation, each documented at its own definition:
|
|
16
|
+
//
|
|
17
|
+
// supports — resolveRelationChase (src/memory/core.mjs, PLAN_COMPLETIONS.md Stage 1's
|
|
18
|
+
// own prerequisite extraction) confirms a taught relation fact between two
|
|
19
|
+
// entities both groups' text share.
|
|
20
|
+
// contradicts — the two groups' text carry OPPOSITE negation polarity around the SAME
|
|
21
|
+
// shared graph-known entity + shared content token (token-level, closed
|
|
22
|
+
// negation-marker set — no graph fact required).
|
|
23
|
+
// elaborates — one group's graph-known entity set is a PROPER SUBSET of the other's (the
|
|
24
|
+
// wider group elaborates the narrower one).
|
|
25
|
+
// exemplifies — one group names a class-level term (something else is taught
|
|
26
|
+
// rdfs:subClassOf/rdf:type it — checkable via the SAME memory/core.mjs-loaded
|
|
27
|
+
// fact rows), and the other group names a taught INSTANCE of that class.
|
|
28
|
+
//
|
|
29
|
+
// "Entity" grounding: a group's raw content tokens (tokenizeBlock, the same tokenizer
|
|
30
|
+
// group.mjs/rank.mjs use, filtered the same isContentToken way those two files already
|
|
31
|
+
// establish) are narrowed to GRAPH-KNOWN terms only — tokens that normFactTerm-match some
|
|
32
|
+
// fact's subject or object in the loaded memory. This is the concrete grounding that keeps
|
|
33
|
+
// "group A and group B share an entity" a checkable graph fact, not a prose-similarity guess:
|
|
34
|
+
// two groups merely using the same English word never licenses anything on its own unless
|
|
35
|
+
// that word is itself a taught term.
|
|
36
|
+
//
|
|
37
|
+
// Determinism: no randomness anywhere. Groups are processed in a fixed (id-sorted) pairwise
|
|
38
|
+
// order; every internal token/entity set is turned into a sorted array before use; every
|
|
39
|
+
// per-relation test returns at most one hit per (group pair, relation), picked by that fixed
|
|
40
|
+
// order — never "first of an unordered Set/Map iteration". See
|
|
41
|
+
// test/completions-infer.test.mjs's own double-run diff test, the same discipline
|
|
42
|
+
// test/completions-stage0.test.mjs and test/completions-stage2.test.mjs already apply to
|
|
43
|
+
// search+group and to sentence ranking.
|
|
44
|
+
|
|
45
|
+
import { normFactTerm, readFactRows, resolveRelationChase } from "../memory/core.mjs";
|
|
46
|
+
import { tokenizeBlock } from "../memory/blocks.mjs";
|
|
47
|
+
import { splitSentences } from "./rank.mjs";
|
|
48
|
+
import { STOPWORDS } from "../prose.mjs";
|
|
49
|
+
|
|
50
|
+
// Same content-token filter group.mjs/rank.mjs each apply to their own adjacency/ranking (not
|
|
51
|
+
// exported from either, so replicated here rather than reached across files — see either
|
|
52
|
+
// file's own header for why raw tokenizeBlock output, which re-admits stopword-shaped filler,
|
|
53
|
+
// is unsuitable for unweighted set operations like the ones this file runs).
|
|
54
|
+
const isContentToken = (t) => /^[a-z0-9]+$/.test(t) && !STOPWORDS.has(t);
|
|
55
|
+
|
|
56
|
+
/** tokenizeBlock(text), narrowed to real content tokens — see isContentToken above. */
|
|
57
|
+
function contentTokens(text) {
|
|
58
|
+
return tokenizeBlock(text).filter(isContentToken);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// This file's own local copy of chat.mjs's private HAS_PROPERTY_PREDICATE constant (same
|
|
62
|
+
// literal string, "mgx:hasProperty") — not exported from memory/core.mjs or chat.mjs, so
|
|
63
|
+
// resolveRelationChase's own unit tests (test/memory-core.test.mjs) establish the precedent of
|
|
64
|
+
// a caller supplying its own minimal, self-contained copy in its `helpers` bag rather than
|
|
65
|
+
// reaching into chat.mjs (out of scope for this dispatch) for the shared constant.
|
|
66
|
+
const HAS_PROPERTY_PREDICATE = "mgx:hasProperty";
|
|
67
|
+
|
|
68
|
+
// The taught ISA-family predicates (chat.mjs's own MINT_ISA_PREDICATES/ISA_PREDICATES sets
|
|
69
|
+
// this same pair, elsewhere) — the exemplifies test's "checkable via the taught IsA/subClassOf
|
|
70
|
+
// graph" per PLAN_COMPLETIONS.md §1.3, read directly off readFactRows() rows rather than via
|
|
71
|
+
// syllogise.mjs's fuller OWL 2 RL machinery (out of scope for this dispatch; this file only
|
|
72
|
+
// needs the STORED isa edges, not their transitive closure).
|
|
73
|
+
const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
|
|
74
|
+
|
|
75
|
+
// The contradicts test's closed negation-marker vocabulary (PLAN_COMPLETIONS.md §1.3's own
|
|
76
|
+
// suggestion: "a simpler token-level polarity check... a small closed negation-word set").
|
|
77
|
+
// Deliberately checked against the RAW sentence text, not contentTokens()'s output — prose.mjs's
|
|
78
|
+
// STOPWORDS (which isContentToken filters through) already strips "not"/"no" as filler for
|
|
79
|
+
// clustering/ranking purposes, which would silently erase the exact signal this test needs.
|
|
80
|
+
const NEGATION_MARKERS = new Set([
|
|
81
|
+
"not", "no", "never", "cannot", "none", "nobody", "nothing", "neither", "nor", "without",
|
|
82
|
+
]);
|
|
83
|
+
const NEGATION_CONTRACTION_RE = /n't\b/i; // doesn't/isn't/don't/won't/can't/... one closed check
|
|
84
|
+
|
|
85
|
+
/** Does this sentence carry a negation marker from the closed set above? Token-level, on the
|
|
86
|
+
* raw (not stopword-filtered) sentence text. */
|
|
87
|
+
function sentenceIsNegated(sentence) {
|
|
88
|
+
const s = String(sentence || "").toLowerCase();
|
|
89
|
+
if (NEGATION_CONTRACTION_RE.test(s)) return true;
|
|
90
|
+
const words = s.split(/[^a-z0-9]+/).filter(Boolean);
|
|
91
|
+
return words.some((w) => NEGATION_MARKERS.has(w));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Union of contentTokens() over every member's text — a group's own content-token
|
|
95
|
+
* vocabulary, deduped. */
|
|
96
|
+
function groupContentTokenSet(group) {
|
|
97
|
+
const set = new Set();
|
|
98
|
+
for (const m of group?.members || []) {
|
|
99
|
+
for (const t of contentTokens(m?.text || "")) set.add(t);
|
|
100
|
+
}
|
|
101
|
+
return set;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Every sentence across a group's members, pre-split (rank.mjs's own splitSentences — reused
|
|
105
|
+
* verbatim, no re-implementation), each carrying its own content-token set and negation flag
|
|
106
|
+
* — the exact per-sentence facts the contradicts test needs. */
|
|
107
|
+
function sentencesOf(group) {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const m of group?.members || []) {
|
|
110
|
+
for (const sentence of splitSentences(m?.text || "")) {
|
|
111
|
+
out.push({ sentence, tokens: new Set(contentTokens(sentence)), negated: sentenceIsNegated(sentence) });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return out;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Every normFactTerm-normalized term that appears as SOME fact's subject or object in the
|
|
118
|
+
* loaded memory — the "graph-known term" universe entities are grounded against. */
|
|
119
|
+
function buildGraphTerms(rows) {
|
|
120
|
+
const set = new Set();
|
|
121
|
+
for (const r of rows) {
|
|
122
|
+
const s = normFactTerm(r.subject);
|
|
123
|
+
const o = normFactTerm(r.object);
|
|
124
|
+
if (s) set.add(s);
|
|
125
|
+
if (o) set.add(o);
|
|
126
|
+
}
|
|
127
|
+
return set;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A group's GRAPH-GROUNDED entities: its own content-token vocabulary, narrowed to tokens
|
|
131
|
+
* that are themselves graph-known terms (buildGraphTerms' universe) — sorted for determinism.
|
|
132
|
+
* This is the concrete grounding test/completions-infer.test.mjs's fixture exercises: two
|
|
133
|
+
* groups merely sharing an English word (e.g. both saying "abstraction") never counts unless
|
|
134
|
+
* that word is itself a taught fact term. */
|
|
135
|
+
function entitiesOf(group, graphTerms) {
|
|
136
|
+
return [...groupContentTokenSet(group)].filter((t) => graphTerms.has(t)).sort();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A minimal, self-contained relationFactsFor(name) — direct-predicate match only
|
|
140
|
+
* (`mgx:${name}`), no alias/subClassOf-over-relation-names chase. Mirrors
|
|
141
|
+
* test/memory-core.test.mjs's own testRelationFactsFor precedent exactly: resolveRelationChase
|
|
142
|
+
* never calls the alias substrate itself, that lives entirely inside whatever relationFactsFor
|
|
143
|
+
* the caller supplies, and a direct-only implementation is an honest, valid, simpler one — no
|
|
144
|
+
* alias-chase claim is made or needed for the supports test's own licensing standard. */
|
|
145
|
+
function makeRelationFactsFor(rows) {
|
|
146
|
+
return (name) => rows
|
|
147
|
+
.filter((f) => f.predicate === `mgx:${name}`)
|
|
148
|
+
.map((f) => ({ fact: f, aliasFacts: [] }));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The resolveRelationChase/resolveRelationChaseReverse `helpers` bag this file supplies —
|
|
152
|
+
* same shape test/memory-core.test.mjs's own direct unit tests use, built once per
|
|
153
|
+
* inferRelations() call over the loaded memory's fact rows. */
|
|
154
|
+
function makeHelpers(rows) {
|
|
155
|
+
return {
|
|
156
|
+
relationFactsFor: makeRelationFactsFor(rows),
|
|
157
|
+
renderFactLine: (f) => `${f.subject} ${f.predicate} ${f.object}`,
|
|
158
|
+
factPhrase: (f) => `${f.subject} ${f.predicate} ${f.object}`,
|
|
159
|
+
factTermVariants: (normFn, term) => new Set([normFn(term)]),
|
|
160
|
+
byTrust: (a, b) => (b.trust ?? 0) - (a.trust ?? 0),
|
|
161
|
+
rows,
|
|
162
|
+
HAS_PROPERTY_PREDICATE,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Every distinct relation NAME resolveRelationChase can plausibly be asked about: every
|
|
167
|
+
* `mgx:<name>` predicate actually present among the loaded facts, minus HAS_PROPERTY_PREDICATE
|
|
168
|
+
* (a property-literal marker, not a relation name) — a closed, corpus-derived candidate list,
|
|
169
|
+
* never an open-ended guess at what "a relation" might be named. Sorted for determinism. */
|
|
170
|
+
function relationNameCandidates(rows) {
|
|
171
|
+
const names = new Set();
|
|
172
|
+
for (const r of rows) {
|
|
173
|
+
if (r.predicate === HAS_PROPERTY_PREDICATE) continue;
|
|
174
|
+
if (!String(r.predicate || "").startsWith("mgx:")) continue;
|
|
175
|
+
names.add(r.predicate.slice("mgx:".length).toLowerCase());
|
|
176
|
+
}
|
|
177
|
+
return [...names].sort();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* SUPPORTS — group A and group B share at least two graph-grounded entities, AND a taught
|
|
182
|
+
* relation fact (resolveRelationChase, src/memory/core.mjs — this file's direct tie-in to
|
|
183
|
+
* PLAN_COMPLETIONS.md Stage 1's own prerequisite extraction) confirms a claim connecting two of
|
|
184
|
+
* those shared entities. Tries every (subject, object) ordered pair drawn from the shared
|
|
185
|
+
* entity set, against every candidate relation name, in fixed sorted order; the first hit
|
|
186
|
+
* (deterministic given fixed inputs) is the one asserted. Returns
|
|
187
|
+
* `{ licensingTest, evidence }` or null.
|
|
188
|
+
*/
|
|
189
|
+
async function testSupports(a, b, memory, helpers, relationNames, graphTerms) {
|
|
190
|
+
const entitiesA = entitiesOf(a, graphTerms);
|
|
191
|
+
const entitiesB = entitiesOf(b, graphTerms);
|
|
192
|
+
const shared = entitiesA.filter((e) => entitiesB.includes(e));
|
|
193
|
+
if (shared.length < 2) return null;
|
|
194
|
+
for (const subjectTerm of shared) {
|
|
195
|
+
for (const objectTerm of shared) {
|
|
196
|
+
if (subjectTerm === objectTerm) continue;
|
|
197
|
+
for (const name of relationNames) {
|
|
198
|
+
// eslint-disable-next-line no-await-in-loop -- deterministic fixed-order search, not a batch op
|
|
199
|
+
const hit = await resolveRelationChase(memory, name, subjectTerm, objectTerm, helpers);
|
|
200
|
+
if (hit) {
|
|
201
|
+
return {
|
|
202
|
+
licensingTest: `resolveRelationChase("${name}", "${subjectTerm}", "${objectTerm}") resolved a taught fact — both terms are entities shared by group ${a.id} and group ${b.id}'s text`,
|
|
203
|
+
evidence: { sharedEntities: shared, relationName: name, subject: subjectTerm, object: objectTerm, citation: hit.citation },
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* CONTRADICTS — group A and group B both mention the SAME graph-grounded entity, plus a
|
|
214
|
+
* second shared content token ("predicate"/aspect) that co-occurs with the entity in at least
|
|
215
|
+
* one sentence on each side — and one side's co-occurring sentence carries a closed-set
|
|
216
|
+
* negation marker while the other side's does not (opposite polarity about the same claim,
|
|
217
|
+
* PLAN_COMPLETIONS.md §1.3's own "simpler token-level polarity check"). Returns
|
|
218
|
+
* `{ licensingTest, evidence }` or null.
|
|
219
|
+
*/
|
|
220
|
+
function testContradicts(a, b, graphTerms) {
|
|
221
|
+
const entitiesA = entitiesOf(a, graphTerms);
|
|
222
|
+
const entitiesB = entitiesOf(b, graphTerms);
|
|
223
|
+
const sharedEntities = entitiesA.filter((e) => entitiesB.includes(e));
|
|
224
|
+
if (!sharedEntities.length) return null;
|
|
225
|
+
|
|
226
|
+
const tokensA = groupContentTokenSet(a);
|
|
227
|
+
const tokensB = groupContentTokenSet(b);
|
|
228
|
+
const sharedTokens = [...tokensA].filter((t) => tokensB.has(t)).sort();
|
|
229
|
+
|
|
230
|
+
const sentencesA = sentencesOf(a);
|
|
231
|
+
const sentencesB = sentencesOf(b);
|
|
232
|
+
|
|
233
|
+
for (const entity of sharedEntities) {
|
|
234
|
+
for (const aspect of sharedTokens) {
|
|
235
|
+
if (aspect === entity) continue;
|
|
236
|
+
const matchesA = sentencesA.filter((s) => s.tokens.has(entity) && s.tokens.has(aspect));
|
|
237
|
+
const matchesB = sentencesB.filter((s) => s.tokens.has(entity) && s.tokens.has(aspect));
|
|
238
|
+
if (!matchesA.length || !matchesB.length) continue;
|
|
239
|
+
const negatedA = matchesA.some((s) => s.negated);
|
|
240
|
+
const affirmedA = matchesA.some((s) => !s.negated);
|
|
241
|
+
const negatedB = matchesB.some((s) => s.negated);
|
|
242
|
+
const affirmedB = matchesB.some((s) => !s.negated);
|
|
243
|
+
if (affirmedA && negatedB) {
|
|
244
|
+
return {
|
|
245
|
+
licensingTest: `shared entity "${entity}" + shared token "${aspect}": group ${a.id}'s matching sentence carries no negation marker while group ${b.id}'s does (closed negation-marker set)`,
|
|
246
|
+
evidence: {
|
|
247
|
+
entity, aspect,
|
|
248
|
+
affirmedSentence: matchesA.find((s) => !s.negated).sentence,
|
|
249
|
+
negatedSentence: matchesB.find((s) => s.negated).sentence,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (negatedA && affirmedB) {
|
|
254
|
+
return {
|
|
255
|
+
licensingTest: `shared entity "${entity}" + shared token "${aspect}": group ${a.id}'s matching sentence carries a negation marker while group ${b.id}'s does not (closed negation-marker set)`,
|
|
256
|
+
evidence: {
|
|
257
|
+
entity, aspect,
|
|
258
|
+
negatedSentence: matchesA.find((s) => s.negated).sentence,
|
|
259
|
+
affirmedSentence: matchesB.find((s) => !s.negated).sentence,
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** small.size >= 1, small.size < big.size, and every element of small is in big — the
|
|
269
|
+
* contradicts/elaborates tests' shared "proper subset" primitive. */
|
|
270
|
+
function isProperSubset(small, big) {
|
|
271
|
+
return small.size > 0 && small.size < big.size && [...small].every((t) => big.has(t));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* ELABORATES — one group's graph-grounded entity set is a PROPER SUBSET of the other's: the
|
|
276
|
+
* WIDER group (the superset) elaborates the NARROWER one (the subset) — it covers everything
|
|
277
|
+
* the narrower group's entities do, plus more. Equal entity sets never count (neither is a
|
|
278
|
+
* *proper* subset of the other) — two groups about exactly the same entities are not in an
|
|
279
|
+
* elaboration relationship by this test. Returns `{ wider: "a"|"b", licensingTest, evidence }`
|
|
280
|
+
* or null.
|
|
281
|
+
*/
|
|
282
|
+
function testElaborates(a, b, graphTerms) {
|
|
283
|
+
const entitiesA = new Set(entitiesOf(a, graphTerms));
|
|
284
|
+
const entitiesB = new Set(entitiesOf(b, graphTerms));
|
|
285
|
+
if (!entitiesA.size || !entitiesB.size) return null;
|
|
286
|
+
if (isProperSubset(entitiesB, entitiesA)) {
|
|
287
|
+
return {
|
|
288
|
+
wider: "a",
|
|
289
|
+
licensingTest: `group ${b.id}'s entity set is a proper subset of group ${a.id}'s — ${a.id} elaborates ${b.id}'s narrower topic`,
|
|
290
|
+
evidence: { widerEntities: [...entitiesA].sort(), narrowerEntities: [...entitiesB].sort() },
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (isProperSubset(entitiesA, entitiesB)) {
|
|
294
|
+
return {
|
|
295
|
+
wider: "b",
|
|
296
|
+
licensingTest: `group ${a.id}'s entity set is a proper subset of group ${b.id}'s — ${b.id} elaborates ${a.id}'s narrower topic`,
|
|
297
|
+
evidence: { widerEntities: [...entitiesB].sort(), narrowerEntities: [...entitiesA].sort() },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* EXEMPLIFIES — `general` names a class-level term (some taught fact has it as the OBJECT of
|
|
305
|
+
* an ISA_PREDICATES edge — i.e. something else is taught to BE one of it), and `instance`
|
|
306
|
+
* names a graph-grounded entity taught to BE one, directly (ISA_PREDICATES edge: instance ->
|
|
307
|
+
* general). Asymmetric and directional by construction: `instance`'s group exemplifies
|
|
308
|
+
* `general`'s group, never the reverse in the same call — callers probe both directions by
|
|
309
|
+
* calling this twice with the groups swapped (see inferRelations below). Returns
|
|
310
|
+
* `{ licensingTest, evidence }` or null.
|
|
311
|
+
*/
|
|
312
|
+
function testExemplifies(general, instance, rows, graphTerms) {
|
|
313
|
+
const generalEntities = entitiesOf(general, graphTerms);
|
|
314
|
+
const instanceEntities = entitiesOf(instance, graphTerms);
|
|
315
|
+
for (const gA of generalEntities) {
|
|
316
|
+
const isClass = rows.some((r) => ISA_PREDICATES.has(r.predicate) && normFactTerm(r.object) === gA);
|
|
317
|
+
if (!isClass) continue;
|
|
318
|
+
for (const eB of instanceEntities) {
|
|
319
|
+
if (eB === gA) continue;
|
|
320
|
+
const instanceFact = rows.find(
|
|
321
|
+
(r) => ISA_PREDICATES.has(r.predicate) && normFactTerm(r.subject) === eB && normFactTerm(r.object) === gA,
|
|
322
|
+
);
|
|
323
|
+
if (instanceFact) {
|
|
324
|
+
return {
|
|
325
|
+
licensingTest: `"${gA}" is class-level in group ${general.id} (a taught fact has object "${gA}" under ${instanceFact.predicate}); "${eB}" in group ${instance.id} is a taught instance of it (${instanceFact.subject} ${instanceFact.predicate} ${instanceFact.object})`,
|
|
326
|
+
evidence: { generalTerm: gA, instanceTerm: eB, citation: `${instanceFact.subject} ${instanceFact.predicate} ${instanceFact.object}` },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Stage 3 — cross-group inference. For every unordered pair of groups (group.mjs's groupHits()
|
|
336
|
+
* output, or any `{ id, members: [{id, text}] }` array), tests each of the four closed
|
|
337
|
+
* relations (supports/contradicts/elaborates/exemplifies) via its own concrete, named,
|
|
338
|
+
* mechanical licensing test — never prose similarity. A relation only appears in the output
|
|
339
|
+
* when its own test function returns a hit; every hit carries `licensingTest` (a human-
|
|
340
|
+
* readable description of exactly what fired) and `evidence` (the concrete facts/tokens cited)
|
|
341
|
+
* — PLAN_COMPLETIONS.md §2's auditability bar ("every cross-group claim must cite the
|
|
342
|
+
* two-or-more groups and the inference kind that licensed it").
|
|
343
|
+
*
|
|
344
|
+
* @param {Array<{id:string, members:Array<{id:string,text:string}>}>} groups
|
|
345
|
+
* @param {object} memory an already-loaded memory/core.mjs loadMemory() payload
|
|
346
|
+
* @param {object} [opts] reserved for future tuning; unused today
|
|
347
|
+
* @returns {Promise<Array<{from:string, to:string, relation:"supports"|"contradicts"|"elaborates"|"exemplifies", licensingTest:string, evidence:object}>>}
|
|
348
|
+
* deterministic: groups are processed in id-sorted pairwise order, and the final list is
|
|
349
|
+
* additionally stable-sorted by (from, to, relation) so output order never depends on
|
|
350
|
+
* incidental iteration order anywhere upstream.
|
|
351
|
+
*/
|
|
352
|
+
// eslint-disable-next-line no-unused-vars -- opts reserved, see docblock
|
|
353
|
+
export async function inferRelations(groups, memory, opts = {}) {
|
|
354
|
+
const list = Array.isArray(groups) ? groups.filter((g) => g && g.id && Array.isArray(g.members)) : [];
|
|
355
|
+
if (list.length < 2) return [];
|
|
356
|
+
|
|
357
|
+
const rows = readFactRows(memory);
|
|
358
|
+
const graphTerms = buildGraphTerms(rows);
|
|
359
|
+
const helpers = makeHelpers(rows);
|
|
360
|
+
const relationNames = relationNameCandidates(rows);
|
|
361
|
+
|
|
362
|
+
const sorted = list.slice().sort((x, y) => x.id.localeCompare(y.id));
|
|
363
|
+
const out = [];
|
|
364
|
+
|
|
365
|
+
for (let i = 0; i < sorted.length; i += 1) {
|
|
366
|
+
for (let j = i + 1; j < sorted.length; j += 1) {
|
|
367
|
+
const A = sorted[i];
|
|
368
|
+
const B = sorted[j];
|
|
369
|
+
|
|
370
|
+
// eslint-disable-next-line no-await-in-loop -- deterministic fixed-order pairwise search
|
|
371
|
+
const sup = await testSupports(A, B, memory, helpers, relationNames, graphTerms);
|
|
372
|
+
if (sup) out.push({ from: A.id, to: B.id, relation: "supports", licensingTest: sup.licensingTest, evidence: sup.evidence });
|
|
373
|
+
|
|
374
|
+
const con = testContradicts(A, B, graphTerms);
|
|
375
|
+
if (con) out.push({ from: A.id, to: B.id, relation: "contradicts", licensingTest: con.licensingTest, evidence: con.evidence });
|
|
376
|
+
|
|
377
|
+
const ela = testElaborates(A, B, graphTerms);
|
|
378
|
+
if (ela) {
|
|
379
|
+
const from = ela.wider === "a" ? A.id : B.id;
|
|
380
|
+
const to = ela.wider === "a" ? B.id : A.id;
|
|
381
|
+
out.push({ from, to, relation: "elaborates", licensingTest: ela.licensingTest, evidence: ela.evidence });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Both directions probed independently — "A exemplifies B" and "B exemplifies A" are
|
|
385
|
+
// genuinely different claims, each licensed (or not) by its own class/instance test.
|
|
386
|
+
const bExemplifiesA = testExemplifies(A, B, rows, graphTerms);
|
|
387
|
+
if (bExemplifiesA) out.push({ from: B.id, to: A.id, relation: "exemplifies", licensingTest: bExemplifiesA.licensingTest, evidence: bExemplifiesA.evidence });
|
|
388
|
+
const aExemplifiesB = testExemplifies(B, A, rows, graphTerms);
|
|
389
|
+
if (aExemplifiesB) out.push({ from: A.id, to: B.id, relation: "exemplifies", licensingTest: aExemplifiesB.licensingTest, evidence: aExemplifiesB.evidence });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
out.sort((x, y) => x.from.localeCompare(y.from) || x.to.localeCompare(y.to) || x.relation.localeCompare(y.relation));
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// completions/prune.mjs — Stage 5 ("drop non-contributing elements") of PLAN_COMPLETIONS.md's
|
|
2
|
+
// six-stage mechanical-text-generation pipeline. §4's staging table (row 3, "Stage 5+6"): "any
|
|
3
|
+
// retrieved span that ends up in no surviving group, feeds no asserted inference, and is not
|
|
4
|
+
// selected by Stage 4's ranking gets cut, explicitly, with the drop recorded (not silently
|
|
5
|
+
// discarded) so the pipeline's own working set is auditable end to end" (§1.5).
|
|
6
|
+
//
|
|
7
|
+
// Reading the plan's own sentence precisely: the three conditions are joined by AND, so a span
|
|
8
|
+
// is dropped only when ALL three hold — equivalently, a span is KEPT when ANY of the three is
|
|
9
|
+
// false: it IS in a surviving group, OR it DOES feed an asserted inference, OR it IS selected by
|
|
10
|
+
// ranking. This module implements that OR exactly, at SENTENCE granularity (rank.mjs's own
|
|
11
|
+
// ranking unit — the thing that actually composes into the assembled prose), with a "hit never
|
|
12
|
+
// grouped" defensive check underneath it for the coarser block/hit granularity search.mjs hands
|
|
13
|
+
// group.mjs (group.mjs's own contract is that every hit lands in SOME group, so this branch is
|
|
14
|
+
// a guard against that invariant breaking silently, not an expected live path today).
|
|
15
|
+
//
|
|
16
|
+
// Inputs are the pipeline's own already-computed intermediate state — this module computes
|
|
17
|
+
// NOTHING new about relevance; it only DECIDES keep/drop from what search.mjs/group.mjs/
|
|
18
|
+
// infer.mjs/rank.mjs already produced, and records why. `rankedByGroup` is deliberately an
|
|
19
|
+
// INPUT (rank.mjs's own rankSentences() output per group), not recomputed here — pruning is a
|
|
20
|
+
// pure decision layer over ranking's output, not a second ranker.
|
|
21
|
+
//
|
|
22
|
+
// THRESHOLD + REASONING (the plan's own "your call on a sensible cutoff, document your
|
|
23
|
+
// reasoning"):
|
|
24
|
+
// - top-K per group (default 3, `opts.maxSentencesPerGroup`): rankSentences() is already
|
|
25
|
+
// best-first per group; keeping only the top K bounds the assembled completion's size in a
|
|
26
|
+
// VISIBLE, documented, override-able way. An unbounded "keep everything with any positive
|
|
27
|
+
// score" would make the completion grow without limit as a group's member count grows —
|
|
28
|
+
// that is itself a silent, unaudited cap in the opposite direction (nothing stops it from
|
|
29
|
+
// ballooning to the size of the whole retrieved corpus for a broad-enough prompt); a small
|
|
30
|
+
// explicit K is the "no silent caps" discipline applied honestly — the cap is visible in the
|
|
31
|
+
// signature, in this comment, and in every drop log entry it produces.
|
|
32
|
+
// - positive score (`score > 0`): rankSentences()'s own scoring is 0 exactly when a sentence
|
|
33
|
+
// shares no informative (or, under query-focus, no query-overlapping) token with anything —
|
|
34
|
+
// rank.mjs's own "never a guessed match" discipline. A zero-information sentence contributes
|
|
35
|
+
// nothing to the completion's content by rankSentences()'s own definition, so it never
|
|
36
|
+
// qualifies on ranking grounds alone, however small K is set.
|
|
37
|
+
// - relation-anchor salvage: a group that feeds an asserted cross-group inference (infer.mjs)
|
|
38
|
+
// but produces ZERO ranking-qualified sentences (every one of its sentences is either
|
|
39
|
+
// outside top-K or zero-scored) is not silenced outright — its single top-ranked sentence is
|
|
40
|
+
// kept as the group's extractive anchor, so a cross-group claim this pipeline actually
|
|
41
|
+
// ASSERTS (with cited licensing evidence) always has at least one real, traceable sentence
|
|
42
|
+
// behind it in the assembled text. This is the concrete realisation of the plan's OR: the
|
|
43
|
+
// three drop conditions must ALL hold, and "feeds no asserted inference" is one of them.
|
|
44
|
+
//
|
|
45
|
+
// Determinism: no randomness anywhere. Groups are processed in a fixed (id-sorted) order;
|
|
46
|
+
// rankedByGroup's own sentence order (rank.mjs's own deterministic tiebreak) is preserved
|
|
47
|
+
// exactly; the output kept/dropped lists are therefore fully deterministic given deterministic
|
|
48
|
+
// inputs — see test/completions-prune.test.mjs's own double-run diff.
|
|
49
|
+
|
|
50
|
+
const DEFAULT_MAX_SENTENCES_PER_GROUP = 3;
|
|
51
|
+
|
|
52
|
+
/** Every group id referenced as either side of an asserted relation (infer.mjs's inferRelations()
|
|
53
|
+
* output) — the "feeds an asserted inference" test, at group granularity (the granularity
|
|
54
|
+
* relations are actually asserted at). */
|
|
55
|
+
function relatedGroupIdsOf(relations) {
|
|
56
|
+
const set = new Set();
|
|
57
|
+
for (const r of relations) {
|
|
58
|
+
if (r && r.from) set.add(r.from);
|
|
59
|
+
if (r && r.to) set.add(r.to);
|
|
60
|
+
}
|
|
61
|
+
return set;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stage 5 — pruning. Decides, per sentence, KEEP or DROP, from the pipeline's own already-
|
|
66
|
+
* computed intermediate state — never recomputing relevance itself.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} state
|
|
69
|
+
* @param {Array<{id:string, text?:string}>} [state.hits] search.mjs's broadSearch() output (used
|
|
70
|
+
* only for the defensive "never grouped" check below — group.mjs's own contract is that this
|
|
71
|
+
* branch should never actually fire today).
|
|
72
|
+
* @param {Array<{id:string, memberIds:string[], label?:string}>} [state.groups] group.mjs's
|
|
73
|
+
* groupHits() output.
|
|
74
|
+
* @param {Array<{from:string, to:string, relation:string}>} [state.relations] infer.mjs's
|
|
75
|
+
* inferRelations() output.
|
|
76
|
+
* @param {Object<string, Array<{sentence:string, score:number, sourceBlockId:string}>>}
|
|
77
|
+
* [state.rankedByGroup] rank.mjs's rankSentences() output, ONE ENTRY PER GROUP, keyed by
|
|
78
|
+
* group id — an INPUT to this module, not recomputed here (see file header).
|
|
79
|
+
* @param {object} [opts]
|
|
80
|
+
* @param {number} [opts.maxSentencesPerGroup=3] the top-K-per-group ranking cutoff (see file
|
|
81
|
+
* header for the reasoning behind the default).
|
|
82
|
+
* @returns {{
|
|
83
|
+
* kept: Array<{sentence:string, score:number, sourceBlockId:string, groupId:string, groupLabel:string}>,
|
|
84
|
+
* dropped: Array<{item:object, reason:string}>
|
|
85
|
+
* }} `kept` is sentence-granular, ordered by (group id, rank order within the group) — a stable,
|
|
86
|
+
* deterministic order the caller can assemble directly. `dropped` is itemized, one entry per
|
|
87
|
+
* dropped hit/sentence, each carrying a human-readable `reason` (never a silent discard —
|
|
88
|
+
* PLAN_COMPLETIONS.md §1.5/§2's own auditability bar).
|
|
89
|
+
*/
|
|
90
|
+
export function pruneCompletion(state = {}, { maxSentencesPerGroup = DEFAULT_MAX_SENTENCES_PER_GROUP } = {}) {
|
|
91
|
+
const hits = Array.isArray(state.hits) ? state.hits : [];
|
|
92
|
+
const groups = Array.isArray(state.groups) ? state.groups.filter((g) => g && g.id) : [];
|
|
93
|
+
const relations = Array.isArray(state.relations) ? state.relations : [];
|
|
94
|
+
const rankedByGroup = state.rankedByGroup && typeof state.rankedByGroup === "object" ? state.rankedByGroup : {};
|
|
95
|
+
|
|
96
|
+
const kept = [];
|
|
97
|
+
const dropped = [];
|
|
98
|
+
|
|
99
|
+
// "never grouped" — defensive: every hit should land in SOME group (group.mjs never drops a
|
|
100
|
+
// hit), so this guards the invariant explicitly rather than silently assuming it holds.
|
|
101
|
+
const groupedHitIds = new Set();
|
|
102
|
+
for (const g of groups) for (const id of g.memberIds || []) groupedHitIds.add(id);
|
|
103
|
+
for (const h of hits) {
|
|
104
|
+
if (h && h.id != null && !groupedHitIds.has(h.id)) {
|
|
105
|
+
dropped.push({ item: { kind: "hit", id: h.id, text: h.text ?? "" }, reason: "never grouped" });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const relatedGroupIds = relatedGroupIdsOf(relations);
|
|
110
|
+
|
|
111
|
+
const sortedGroups = groups.slice().sort((a, b) => a.id.localeCompare(b.id));
|
|
112
|
+
for (const g of sortedGroups) {
|
|
113
|
+
const ranked = Array.isArray(rankedByGroup[g.id]) ? rankedByGroup[g.id] : [];
|
|
114
|
+
const groupFeedsInference = relatedGroupIds.has(g.id);
|
|
115
|
+
|
|
116
|
+
const qualifying = [];
|
|
117
|
+
const rest = [];
|
|
118
|
+
ranked.forEach((s, i) => {
|
|
119
|
+
const withinCutoff = i < maxSentencesPerGroup;
|
|
120
|
+
const informative = s.score > 0;
|
|
121
|
+
if (withinCutoff && informative) qualifying.push(s);
|
|
122
|
+
else rest.push(s);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
let anchor = null;
|
|
126
|
+
if (!qualifying.length && groupFeedsInference && ranked.length) {
|
|
127
|
+
// salvage: the group's own single top-ranked sentence, kept as the extractive anchor for
|
|
128
|
+
// its asserted cross-group relation even though it didn't clear the ranking cutoff alone.
|
|
129
|
+
anchor = ranked[0];
|
|
130
|
+
qualifying.push(anchor);
|
|
131
|
+
const idx = rest.indexOf(anchor);
|
|
132
|
+
if (idx >= 0) rest.splice(idx, 1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
for (const s of qualifying) {
|
|
136
|
+
kept.push({ sentence: s.sentence, score: s.score, sourceBlockId: s.sourceBlockId, groupId: g.id, groupLabel: g.label });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const s of rest) {
|
|
140
|
+
let reason;
|
|
141
|
+
if (s === anchor) reason = null; // unreachable (anchor is always spliced into qualifying)
|
|
142
|
+
else if (s.score <= 0 && groupFeedsInference) {
|
|
143
|
+
reason = "zero-information score, and its group's asserted relation was already anchored by a different sentence";
|
|
144
|
+
} else if (s.score <= 0) {
|
|
145
|
+
reason = "zero-information score (no informative/query-focused tokens) and its group feeds no asserted inference";
|
|
146
|
+
} else if (groupFeedsInference) {
|
|
147
|
+
reason = `ranked below the per-group keep cutoff (top ${maxSentencesPerGroup}); its group feeds an asserted inference but this sentence was not the anchor`;
|
|
148
|
+
} else {
|
|
149
|
+
reason = "grouped but zero relations touched it and it wasn't top-ranked";
|
|
150
|
+
}
|
|
151
|
+
dropped.push({ item: { kind: "sentence", sourceBlockId: s.sourceBlockId, groupId: g.id, sentence: s.sentence }, reason });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { kept, dropped };
|
|
156
|
+
}
|