File size: 19,021 Bytes
8494d00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
// reason.js β€” bounded REASONING by chain-finding. The composer retrieves the
// right premises but dumps them unordered with no conclusion (reason-test R53).
// Reasoning = find a PATH of facts connected by shared entities, from the
// question's entities toward an answer, ORDERED. Every fact is verbatim corpus,
// so the chain is bounded; the inference is the SELECTION of the path. The
// conclusion is then composed from the path's tail entities (also corpus spans).
'use strict';

const STOP = new Set('a an the is are was were be been being of to in on at and or but if then so as with for from by that this these those it its their his her my your our we you they he she i do does can cannot not only any anyone where what who why how when which there here now during has have had will would may might must shall'.split(/\s+/));

// significant tokens of a fact = content words (proper nouns + nouns/verbs),
// lowercased. These are the LINKS between facts.
function tokensOf(text) {
  const words = (text.match(/[A-Za-z][A-Za-z'-]*/g) || []);
  const toks = new Set();
  for (const w of words) {
    const lw = w.toLowerCase();
    if (STOP.has(lw) || lw.length < 3) continue;
    toks.add(lw);
  }
  return toks;
}

// build the fact graph: facts sharing a significant token are linked by it.
function buildGraph(facts) {
  const nodes = facts.map((text, i) => ({ i, text, toks: tokensOf(text) }));
  // token -> fact indices containing it
  const byTok = new Map();
  for (const n of nodes) for (const t of n.toks) { if (!byTok.has(t)) byTok.set(t, []); byTok.get(t).push(n.i); }
  // adjacency: i -> [{j, via}]
  const adj = nodes.map(() => []);
  for (const [tok, idxs] of byTok) {
    if (idxs.length < 2) continue;                 // a shared token links facts
    for (let a = 0; a < idxs.length; a++) for (let b = a + 1; b < idxs.length; b++) {
      adj[idxs[a]].push({ j: idxs[b], via: tok });
      adj[idxs[b]].push({ j: idxs[a], via: tok });
    }
  }
  return { nodes, adj, byTok };
}

// find reasoning chains: BFS from facts that match the question's entities,
// expanding along shared-entity links, preferring paths that introduce NEW
// query-relevant tokens (progress toward an answer). Returns ranked chains
// (each an ordered list of fact indices).
function findChains(graph, qTokens, opts = {}) {
  const maxLen = opts.maxLen || 5, maxChains = opts.maxChains || 8;
  const { nodes, adj } = graph;
  // seed facts: those sharing the most tokens with the question
  const seeds = nodes.map(n => [n.i, [...n.toks].filter(t => qTokens.has(t)).length])
    .filter(([, k]) => k > 0).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([i]) => i);
  const chains = [];
  for (const seed of seeds) {
    // DFS up to maxLen, scoring by coverage of NEW tokens + query relevance
    const stack = [{ path: [seed], used: new Set([seed]), toks: new Set(nodes[seed].toks) }];
    while (stack.length) {
      const cur = stack.pop();
      chains.push({ path: cur.path.slice(), score: scoreChain(cur, qTokens, nodes) });
      if (cur.path.length >= maxLen) continue;
      const tail = cur.path[cur.path.length - 1];
      // prefer neighbors that add query-relevant tokens
      const nbrs = adj[tail].filter(e => !cur.used.has(e.j))
        .sort((a, b) => newRel(nodes[b.j], cur.toks, qTokens) - newRel(nodes[a.j], cur.toks, qTokens))
        .slice(0, 3);
      for (const e of nbrs) {
        const used = new Set(cur.used); used.add(e.j);
        const toks = new Set(cur.toks); for (const t of nodes[e.j].toks) toks.add(t);
        stack.push({ path: [...cur.path, e.j], used, toks });
      }
    }
  }
  // dedupe by path-set, rank by score
  const seen = new Set(); const out = [];
  for (const c of chains.sort((a, b) => b.score - a.score)) {
    const key = c.path.slice().sort((a, b) => a - b).join(',');
    if (seen.has(key)) continue; seen.add(key);
    out.push(c);
    if (out.length >= maxChains) break;
  }
  return out;
}

function newRel(node, haveToks, qToks) {
  let s = 0;
  for (const t of node.toks) { if (!haveToks.has(t)) { s += 1; if (qToks.has(t)) s += 2; } }
  return s;
}
// a chain scores high if it covers many query tokens AND stays connected AND
// isn't padded with irrelevant facts.
function scoreChain(c, qToks, nodes) {
  const covered = new Set();
  for (const i of c.path) for (const t of nodes[i].toks) if (qToks.has(t)) covered.add(t);
  const qCover = covered.size / Math.max(1, qToks.size);
  const lenPenalty = 1 / (1 + Math.max(0, c.path.length - 3) * 0.4);   // prefer 2-3 hop
  return qCover * 2 + c.path.length * 0.15 * lenPenalty;
}

// TRIM a chain to the minimal prefix that still covers all the query tokens the
// full chain reaches β€” drops the irrelevant tail hops DFS tacked on.
function trimChain(path, qToks, nodes) {
  const target = new Set();
  for (const i of path) for (const t of nodes[i].toks) if (qToks.has(t)) target.add(t);
  const got = new Set(); const kept = [];
  for (const i of path) {
    kept.push(i);
    for (const t of nodes[i].toks) if (qToks.has(t)) got.add(t);
    if (got.size >= target.size) break;            // covered everything reachable
  }
  return kept;
}

// EXTRACT the answer: the entity the chain DERIVES that the question asks for.
// proper-noun questions (where/who/what X) β†’ the new proper noun the chain
// arrives at (not already in the question). why β†’ the leading causal fact.
const PROPER = /\b([A-Z][a-z]+)\b/g;
// proper-noun PHRASES: consecutive capitalized words = one entity ("Helix
// Academy", "Queen Ilse"), so extraction returns the whole name not the tail.
const PROPER_PHRASE = /\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b/g;
const ART = /^(The|A|An)\s+/;
function properPhrases(text) {
  return (text.match(PROPER_PHRASE) || []).map(s => s.replace(ART, '').trim()).filter(Boolean);
}
function extractAnswer(path, q, nodes) {
  const facts = path.map(i => nodes[i].text);
  const qLow = q.toLowerCase();
  const qProper = new Set(properPhrases(q));
  const qWords = new Set((q.match(PROPER) || []));
  if (/\bwhy\b/.test(qLow)) return facts[0];        // the causal premise leads
  if (/\bwhat would|what.*end|how .* end/.test(qLow)) return facts[0];
  // collect proper-noun PHRASES along the chain not already in the question
  const found = [];
  for (const f of facts) for (const m of properPhrases(f)) {
    if (qProper.has(m)) continue;
    if (m.split(/\s+/).every(w => qWords.has(w))) continue;   // all parts in q
    if (!found.includes(m)) found.push(m);
  }
  if (found.length) return found[found.length - 1];
  // no proper noun β†’ COMMON-NOUN object: the phrase after the relation verb in
  // the last fact ("Theralin treats a rare blood disorder" β†’ "rare blood
  // disorder"; "requires a compound called zerinol" β†’ "zerinol").
  const last = facts[facts.length - 1];
  const qLower = new Set([...qWords].map(w => w.toLowerCase()));
  const relToks = [...tokensOf(q)].filter(t => !qLower.has(t));
  for (const v of relToks) {
    const m = last.match(new RegExp('\\b' + stem(v) + "\\w*\\s+(.+?)[.!?]?$", 'i'));
    if (m) return objectPhrase(m[1]);
  }
  return last;
}
// clean an object phrase: drop leading article; "a compound called X" β†’ "X"
function objectPhrase(s) {
  s = s.trim().replace(/[.!?]+$/, '');
  const called = s.match(/\bcalled\s+(.+)$/i);
  if (called) return called[1].trim();
  return s.replace(/^(a|an|the)\s+/i, '').trim();
}

// ---- yes/no reasoning by CONFLICT detection ----
// A hallucination-free reasoner must be able to say NO with proof. "Can SUBJECT
// reach GOAL?" β†’ find what the goal REQUIRES (chain from the goal object), then
// check if a CONSTRAINT fact forbids SUBJECT's class from a required entity.
const NEG = /\b(cannot|can't|forbidden|barred|never|must not|may not|not allowed|are not|is not)\b/i;
const PROPN = /\b([A-Z][a-z]+)\b/g;

// classes SUBJECT belongs to: "Mara is a healer" -> {healer}
function classesOf(subject, facts) {
  const out = new Set();
  for (const f of facts) {
    const m = f.match(new RegExp('\\b' + subject + "\\b\\s+is\\s+(?:a|an)\\s+([a-z]+)", 'i'));
    if (m) out.add(m[1].toLowerCase().replace(/s$/, ''));
  }
  return out;
}
// entities the GOAL requires: chain from the goal object, collecting the places/
// things gating it ("grows only in X", "must pass through Y").
function requiredEntities(goalToks, graph) {
  const { nodes } = graph;
  const req = new Set();
  const seeds = nodes.filter(n => [...n.toks].some(t => goalToks.has(t)));
  const gate = /\b(only in|grows only|must pass through|must enter|lies beneath|beneath|sealed behind|behind|requires|reached? through|access to)\b/i;
  // gated entities may be PROPER nouns (Korr) OR lowercase common nouns (bridge,
  // reactor) β€” collect both from gating facts so generalization isn't tied to
  // capitalization. Also seed with the goal's own content nouns.
  for (const t of goalToks) if (!STOP.has(t) && t.length > 3) req.add(t);
  let frontier = seeds, seen = new Set(seeds.map(n => n.i));
  for (let d = 0; d < 3; d++) {
    const next = [];
    for (const n of frontier) {
      if (gate.test(n.text)) {
        for (const m of (n.text.match(PROPN) || [])) req.add(m);
        for (const t of n.toks) if (!STOP.has(t) && t.length > 3) req.add(t);
      }
      for (const e of graph.adj[n.i]) if (!seen.has(e.j)) { seen.add(e.j); next.push(nodes[e.j]); }
    }
    frontier = next;
  }
  return req;
}
// answer a "can SUBJECT ...?" question: NO if a constraint blocks SUBJECT's
// class from a required entity; else YES (if the goal is reachable).
const QWORD = new Set(['Can', 'Does', 'Do', 'Is', 'Are', 'Could', 'Will', 'Would', 'May', 'Should', 'Who', 'What', 'Where', 'Why', 'When', 'Which', 'How', 'The', 'A', 'An']);
function answerCan(q, facts, graph) {
  const subject = (q.match(PROPN) || []).find(w => !QWORD.has(w)) || null;
  if (!subject) return null;
  const qToks = tokensOf(q);
  const goalToks = new Set([...qToks].filter(t => !t.includes(subject.toLowerCase())));
  const req = requiredEntities(goalToks, graph);
  const classes = classesOf(subject, facts);
  // look for a constraint: NEG fact mentioning a required entity + SUBJECT's class
  for (const f of facts) {
    if (!NEG.test(f)) continue;
    const ents = (f.match(PROPN) || []);
    const hitsReq = ents.find(e => req.has(e));
    if (!hitsReq) continue;
    const clsHit = [...classes].find(c => new RegExp('\\b' + c + 's?\\b', 'i').test(f));
    if (clsHit) {
      // SUBJECT is a <class>, <class> is barred from <required entity> -> NO
      const why = facts.filter(x =>
        new RegExp('\\b' + subject + "\\b\\s+is\\s+(?:a|an)\\s+" + clsHit, 'i').test(x) || x === f
      );
      // include a gating fact that shows the goal needs that entity
      const gate = facts.find(x => x.includes(hitsReq) && /\b(must pass through|only in|grows only|beneath)\b/i.test(x));
      if (gate) why.push(gate);
      return { yesno: 'NO', subject, blockedBy: hitsReq, cls: clsHit, why };
    }
  }
  // no block β†’ YES only with an explicit enabler: a subject-ability fact that
  // mentions a goal entity (proper noun in the question other than subject).
  const goalProper = (q.match(PROPN) || []).filter(w => !QWORD.has(w) && w !== subject);
  for (const f of facts) {
    if (!new RegExp('^' + subject + '\\b').test(f)) continue;
    if (!/\b(can|travels?|travel|move|go|freely|may|enter)\b/i.test(f)) continue;
    if (!goalProper.length || goalProper.some(e => f.includes(e))) return { yesno: 'YES', subject, why: [f] };
  }
  return { yesno: 'UNKNOWN', subject, unknown: true };   // no block, no enabler β†’ can't know
}

// ---- constraint-satisfaction: "who/which X can REACH the goal?" ----
// the goal requires some gated entity (Korr). Find the people who CAN reach it:
// either an explicit enabler fact ("X travels freely ... Korr", "apprentice may
// go where master cannot") OR a class NOT barred from it. Returns the viable
// people with their justification β€” bounded (all facts verbatim).
function answerWho(q, facts, graph) {
  const qToks = tokensOf(q);
  const subj = (q.match(PROPN) || []).find(w => !QWORD.has(w)) || null;   // Mara
  const goalToks = new Set([...qToks].filter(t => subj ? !t.includes(subj.toLowerCase()) : true));
  const req = requiredEntities(goalToks, graph);                          // {Korr, Deeprock,...}
  // the gated location that actually has a barring constraint
  const barred = facts.filter(f => NEG.test(f) && (f.match(PROPN) || []).some(e => req.has(e)));
  const gateEnt = barred.length ? (barred[0].match(PROPN) || []).find(e => req.has(e)) : [...req][0];
  // people = proper nouns with an "X is ..." identity fact, excluding subject
  const people = new Set();
  for (const f of facts) { const m = f.match(/^([A-Z][a-z]+)\s+is\b/); if (m && m[1] !== subj && !QWORD.has(m[1])) people.add(m[1]); }
  // also catch people with an explicit ability fact
  for (const f of facts) { const m = f.match(/^([A-Z][a-z]+)\s+can\b/); if (m && m[1] !== subj) people.add(m[1]); }
  // GENERAL role-enabler rule: "a <role> may go where their <superior> cannot"
  // (works for apprentice/master, deckhand/officer, anything). Captures the role.
  const roleRule = facts.find(f => /\b(\w+)\s+may go where\b.*\b(cannot|can't|can not)\b/i.test(f));
  const enabledRole = roleRule ? (roleRule.match(/\b(\w+)\s+may go where/i) || [])[1].toLowerCase().replace(/s$/, '') : null;
  const reqList = [...req].concat(gateEnt ? [gateEnt] : []);
  const viable = [];
  for (const p of people) {
    const why = [];
    // explicit enabler: "P ... travel/go/enter freely ... <any required entity>"
    const expl = facts.find(f => new RegExp('^' + p + '\\b').test(f) && /\b(travel|go|move|enter|freely|clearance|access|reach)\b/i.test(f) && (!reqList.length || reqList.some(e => f.includes(e))));
    if (expl) why.push(expl);
    // role-enabler: "P is <subj>'s <enabledRole>" + the rule (P may go where subj cannot)
    if (enabledRole && subj) {
      const roleFact = facts.find(f => new RegExp('^' + p + "\\b.*\\b" + subj + "'s\\s+" + enabledRole, 'i').test(f));
      if (roleFact) { why.push(roleFact); why.push(roleRule); }
    }
    // class NOT barred: P explicitly not the barred class ("P is not a/an <class>")
    const notBarredFact = facts.find(f => new RegExp('^' + p + "\\b.*\\bnot a[n]?\\b", 'i').test(f));
    const pClasses = classesOf(p, facts);
    const barredClass = barred.length && [...pClasses].some(c => barred.some(b => new RegExp('\\b' + c + 's?\\b', 'i').test(b)));
    if (notBarredFact && !barredClass) why.push(notBarredFact);
    if (why.length) viable.push({ who: p, why: [...new Set(why)] });
  }
  return { gateEnt, viable };
}

// stem-ish prefix so trained~train, ruled~rule, gather~gathers match
const stem = t => t.toLowerCase().slice(0, 5);
function factCovers(fact, tok) { return new RegExp('\\b' + stem(tok), 'i').test(fact); }

// ---- the honest dispatcher: answer, or admit you can't know ----
// The crown of a bounded reasoner: it can only answer when the asked RELATION is
// attested in a fact CONNECTED to the subject. Otherwise β†’ unknown. No confident
// confabulation: "I can't determine that from what I know."
function answer(q, facts, graph) {
  const qt = q.trim().toLowerCase();
  const qToks = tokensOf(q);
  // subject = the first proper-noun PHRASE (consecutive caps, e.g. "Queen Ilse"),
  // else the first content noun. Capturing the phrase keeps "Ilse" out of the
  // relation tokens (else every Ilse-fact looks like an anchor).
  const phrases = (q.match(/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\b/g) || []).map(s => s.trim())
    .filter(s => !s.split(/\s+/).every(w => QWORD.has(w)));
  const subjPhrase = phrases[0] || [...qToks].find(t => !STOP.has(t)) || null;
  const subjTokSet = new Set((subjPhrase || '').toLowerCase().split(/\s+/).map(stem));
  const relToks = [...qToks].filter(t => !subjTokSet.has(stem(t)));
  const classes = subjPhrase ? classesOf(subjPhrase.split(/\s+/).pop(), facts) : new Set();
  const subjForms = subjPhrase ? [...subjPhrase.split(/\s+/), ...classes] : [];
  // is there a fact ABOUT the subject that covers a relation token?
  const anchorFacts = graph.nodes.filter(n =>
    subjForms.some(s => factCovers(n.text, s)) && relToks.some(t => factCovers(n.text, t)));
  const hasAnchor = anchorFacts.length > 0;

  // YES/NO β†’ conflict / enabler / unknown
  if (/^(can|does|do|is|are|could|will|would|may|should)\b/.test(qt)) {
    const a = answerCan(q, facts, graph);
    if (a && a.yesno === 'NO') return { answer: `NO (${a.subject} is a ${a.cls}; ${a.cls}s barred from ${a.blockedBy})`, why: a.why, unknown: false };
    if (a && a.yesno === 'YES') return { answer: 'YES', why: a.why, unknown: false };
    return { unknown: true };                       // no block, no enabler β†’ can't know
  }
  // WHO/WHICH + ability β†’ constraint satisfaction
  if (/^(who|which)\b/.test(qt) && /\b(can|could|would|may|get|reach|do|go)\b/.test(qt)) {
    const a = answerWho(q, facts, graph);
    if (a.viable.length) return { answer: a.viable.map(v => v.who).join(' or '), why: a.viable.flatMap(v => v.why), unknown: false };
    return { unknown: true };
  }
  // WHY β†’ the causal fact ABOUT the subject (anchor)
  if (/^why\b/.test(qt)) {
    return hasAnchor ? { answer: anchorFacts[0].text, why: [anchorFacts[0].text], unknown: false } : { unknown: true };
  }
  // WHAT-WOULD / WHAT-END β†’ the conditional fact covering the relation
  if (/what would|what .*\bend\b/.test(qt)) {
    const cf = graph.nodes.find(n => /\b(end|will end|ends)\b/i.test(n.text) && relToks.some(t => factCovers(n.text, t)));
    return cf ? { answer: cf.text, why: [cf.text], unknown: false } : { unknown: true };
  }
  // WHERE / WHAT / WHICH β€” answer from the ANCHOR fact directly (robust to graph
  // size), chaining ONE hop only if the asked object-type isn't in the anchor.
  if (!hasAnchor) return { unknown: true };          // relation not attested about subject β†’ can't know
  const anchor = anchorFacts[0];
  // the asked object-type noun (city/country/...): the last relation token that
  // is itself a generic category word, if the anchor doesn't already name it.
  const objType = relToks.find(t => /^(city|country|state|town|region|planet|place|person)$/.test(t));
  if (objType && !factCovers(anchor.text, objType)) {
    // chain one hop to a fact that covers the object-type, then extract
    for (const e of graph.adj[anchor.i]) {
      const nb = graph.nodes[e.j];
      if (factCovers(nb.text, objType)) {
        return { answer: extractAnswer([anchor.i, nb.i], q, graph.nodes), why: [anchor.text, nb.text], unknown: false };
      }
    }
    // object-type asked but not reachable β†’ we lack that fact
    return { unknown: true };
  }
  return { answer: extractAnswer([anchor.i], q, graph.nodes), why: [anchor.text], unknown: false };
}

module.exports = { tokensOf, buildGraph, findChains, trimChain, extractAnswer, classesOf, requiredEntities, answerCan, answerWho, answer, STOP };