@polycode-projects/the-mechanical-code-talker 6.0.18 → 6.0.19

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.
@@ -18,10 +18,11 @@
18
18
  // full NLU: nothing here ever paraphrases or invents a fact the recognizer
19
19
  // itself didn't produce.
20
20
  //
21
- // --optimistic ALSO run a bounded, lexicon-gated fuzzy tier over the sentences
22
- // the strict recognizer skipped (optimisticTriples below): a
23
- // copula or a known relation verb flanked by two resolvable
24
- // entities becomes a candidate triple, stored under its OWN
21
+ // --optimistic ALSO run a bounded fuzzy tier over the sentences the strict
22
+ // recognizer skipped (optimisticTriples below): a copula, a
23
+ // lexicon relation verb, or one of the closed newswire event
24
+ // verbs read in a tighter frame, flanked by two resolvable
25
+ // entities, becomes a candidate triple, stored under its OWN
25
26
  // low-trust source kind (optimistic-extract:<source>, prior 0.35 —
26
27
  // below every curated pack, memory/trust.mjs) with NO operator or
27
28
  // teach tag riding alongside, so a fuzzy candidate can never
@@ -174,12 +175,66 @@ const MAX_TRIPLES_PER_SENTENCE = 4;
174
175
  // abstains rather than guess, so a long noun pile never mints a stray class.
175
176
  const ATTRIBUTIVE_CHAIN_MAX_HOPS = 8;
176
177
 
178
+ // The verbs a news report states an event with. The relation arm above reads a
179
+ // verb only when the lexicon declares one, and the lexicon's verb list is a
180
+ // software vocabulary — so a whole newswire paragraph ("the moon will
181
+ // completely block the sun") carries no relation under it at all. This closed
182
+ // band sits beside it: transitive event verbs, each of which takes a direct
183
+ // object naming the thing the event happened to, so the frame below can demand
184
+ // an adjacent noun on each side and get the actor and the affected thing.
185
+ //
186
+ // Verbs of speech and attribution are deliberately absent — "say", "tell",
187
+ // "add", "report", "accuse", "claim" each open a reported clause, and the noun
188
+ // after one is the subject of what was said, never the object of the saying.
189
+ // So are verbs whose everyday reading swamps their news one ("hold", "face",
190
+ // "follow", "lead", "reach", "back", "pass", "cut"): a band that admits those
191
+ // buys a handful of events and pays for it in nonsense.
192
+ const NEWSWIRE_RELATION_VERBS = new Set([
193
+ "hit", "strike", "kill", "injure", "wound", "damage", "destroy", "devastate",
194
+ "ban", "halt", "block", "bar", "suspend", "impose",
195
+ "arrest", "detain", "jail", "charge", "convict", "sentence", "deport", "release", "free",
196
+ "elect", "appoint", "oust", "overthrow",
197
+ "sign", "adopt", "approve", "reject", "veto",
198
+ "launch", "unveil", "seize", "capture", "invade", "attack", "bomb", "target",
199
+ "discover", "uncover", "rescue", "evacuate",
200
+ "spark", "trigger", "cause", "force", "deploy", "restore", "expand",
201
+ ]);
202
+ // The of-frame heads a newswire event reads THROUGH to what it really touched:
203
+ // "discovers hundreds of ancient amphorae" is a fact about the amphorae, and
204
+ // "charged a group of Cuban men" about the men. Only counting and container
205
+ // heads qualify (OF_PARTITIVE_HEADS and the bare numerals below, plus the
206
+ // classifier heads a class rewrite already reads through) — every other of-
207
+ // chain names its own head, so "restore the sacred glow of fireflies" restores
208
+ // the glow, not the fireflies.
209
+ const OF_COUNT_HEADS = new Set(["hundred", "hundreds", "thousand", "thousands", "million", "millions", "dozen", "dozens", "score", "scores", "handful"]);
210
+ // A verb whose own auxiliary is a be-form heads a passive or a progressive
211
+ // ("was arrested by ICE", "are disappearing"), and there the noun on the
212
+ // subject side is what the event happened TO, not who did it — an active read
213
+ // of one states the reverse of the sentence. The scan crosses adverbs only, so
214
+ // a modal chain that is still active ("will completely block") reads on.
215
+ const BE_AUXILIARIES = new Set(["is", "are", "was", "were", "be", "been", "being", "am"]);
216
+
217
+ // wink's tokenizer keeps a sentence-final full stop glued to the word before
218
+ // it when that word ends the text ("… block the sun." tokenizes as one PROPN
219
+ // "sun."), so a term read off the last token would otherwise be stored with
220
+ // the sentence's own punctuation in its key. Only a LONE trailing stop comes
221
+ // off: an abbreviation carries interior stops too ("U.S.", "P.K.K.") and keeps
222
+ // every one of them.
223
+ const stripSentenceFinalStop = (word) => {
224
+ const text = String(word ?? "");
225
+ return text.endsWith(".") && !text.slice(0, -1).includes(".") ? text.slice(0, -1) : text;
226
+ };
227
+
177
228
  /** Fold an entity surface to its stored key: a lexicon noun's lemma, else the
178
229
  * word's own normFactTerm (the optimistic tier mints unlisted content nouns
179
230
  * the way the strict teach lane already mints "redis"). */
180
231
  function foldEntity(word, lexicon) {
181
- const noun = lookupNoun(lexicon, String(word).toLowerCase());
182
- return normFactTerm(noun ? noun.lemma : word);
232
+ const surface = stripSentenceFinalStop(word);
233
+ // A multi-word name is stored exactly as it reads. "United States" is the
234
+ // name; "united state" is a lemma fold of a word that was never on its own.
235
+ if (surface.includes(" ")) return normFactTerm(surface);
236
+ const noun = lookupNoun(lexicon, surface.toLowerCase());
237
+ return normFactTerm(noun ? noun.lemma : surface);
183
238
  }
184
239
 
185
240
  // The shortest word ingestText's fact-degree scan treats as a content-noun
@@ -187,10 +242,40 @@ function foldEntity(word, lexicon) {
187
242
  // lexical fallback's stopword set doesn't already carry.
188
243
  const CANDIDATE_TERM_MIN_LENGTH = 3;
189
244
 
245
+ // An abbreviated personal title carries a trailing stop, which is why wink
246
+ // tags it PROPN and glues it to the surname ("Mr./PROPN Gilman/PROPN"). A
247
+ // title addresses a person; it never names one, so it comes off the front of a
248
+ // name run however short the run is.
249
+ const HONORIFIC_NAME_PREFIXES = new Set([
250
+ "mr", "mrs", "ms", "mx", "dr", "prof", "rev",
251
+ "sen", "rep", "gov", "gen", "capt", "col", "lt", "sgt", "maj",
252
+ ]);
253
+
254
+ /** The name a run of capitalized tokens states, front-trimmed. A run of three
255
+ * or more sheds a leading role noun the lexicon knows ("Prime Minister Keir
256
+ * Starmer" → "Keir Starmer") or a hyphenated compound that only Title Case
257
+ * lifted to a proper noun ("Ex-Marine Robert Gilman" → "Robert Gilman"). The
258
+ * trim stops at two tokens, so "Count Binface" and "Lake Kariba" keep the word
259
+ * that belongs to the name. An honorific comes off at any length. */
260
+ function trimNameRun(words, lexicon) {
261
+ const bare = (word) => stripSentenceFinalStop(word).toLowerCase();
262
+ let start = 0;
263
+ while (start < words.length - 1 && HONORIFIC_NAME_PREFIXES.has(bare(words[start]))) start += 1;
264
+ while (words.length - start >= 3) {
265
+ const first = bare(words[start]);
266
+ if (!first.includes("-") && !lookupNoun(lexicon, first)) break;
267
+ start += 1;
268
+ }
269
+ return words.slice(start);
270
+ }
271
+
190
272
  /** Every NOUN/PROPN token `sentences` names, surface-form occurrence-counted —
191
273
  * a POS tagger reads unknown words by context, so an unlisted noun ("wombat")
192
- * counts exactly like a lexicon-known one. */
193
- function candidateTermOccurrencesPos(sentences, nlp) {
274
+ * counts exactly like a lexicon-known one. A contiguous run of two or more
275
+ * PROPN tokens counts ONCE, as the whole name it spells ("Robert Gilman",
276
+ * "United States"); the run's own words never count beside it, because half a
277
+ * name is half a lookup. */
278
+ function candidateTermOccurrencesPos(sentences, nlp, lexicon) {
194
279
  const counts = new Map();
195
280
  for (const sentence of sentences) {
196
281
  let values;
@@ -202,21 +287,77 @@ function candidateTermOccurrencesPos(sentences, nlp) {
202
287
  } catch { continue; }
203
288
  for (let i = 0; i < values.length; i += 1) {
204
289
  if (pos[i] !== "NOUN" && pos[i] !== "PROPN") continue;
290
+ let hi = i;
291
+ if (pos[i] === "PROPN") while (hi + 1 < values.length && pos[hi + 1] === "PROPN") hi += 1;
292
+ if (hi > i) {
293
+ // A Title Case headline lifts its verbs to PROPN too ("Thailand Halts
294
+ // New Gun Licenses…"), gluing a clause into one run. A token the verb
295
+ // tables know splits the run: what stands before it is the name.
296
+ const verbShaped = (w) => {
297
+ const word = stripSentenceFinalStop(String(w)).toLowerCase();
298
+ const lemma = word.endsWith("s") ? word.slice(0, -1) : word;
299
+ return NEWSWIRE_RELATION_VERBS.has(word) || NEWSWIRE_RELATION_VERBS.has(lemma)
300
+ || Boolean(lookupVerb(lexicon, word));
301
+ };
302
+ let cut = -1;
303
+ for (let k = i + 1; k <= hi; k += 1) if (verbShaped(values[k])) { cut = k; break; }
304
+ const runEnd = cut === -1 ? hi : cut - 1;
305
+ if (runEnd > i) {
306
+ const name = trimNameRun(values.slice(i, runEnd + 1), lexicon).join(" ");
307
+ counts.set(name, (counts.get(name) || 0) + 1);
308
+ } else {
309
+ counts.set(values[i], (counts.get(values[i]) || 0) + 1);
310
+ }
311
+ i = cut === -1 ? hi : cut;
312
+ continue;
313
+ }
205
314
  counts.set(values[i], (counts.get(values[i]) || 0) + 1);
206
315
  }
207
316
  }
208
317
  return counts;
209
318
  }
210
319
 
320
+ /** A sentence whose substantial words are nearly all capitalized is a headline
321
+ * set in Title Case, where a capital says nothing about which words spell a
322
+ * name. The POS tier reads such a sentence by tag and is unaffected; the
323
+ * lexical fallback has only the capitals, so it reads no name runs there. */
324
+ function readsAsTitleCase(words) {
325
+ const substantial = words.filter((word) => word.length >= 4);
326
+ if (substantial.length < 4) return false;
327
+ const capitalized = substantial.filter((word) => /^[A-Z]/.test(word)).length;
328
+ return capitalized / substantial.length >= 0.8;
329
+ }
330
+
211
331
  /** The no-wink-model fallback: every word that is neither a closed-class
212
332
  * scaffolding token nor a lexicon-known verb/adjective counts as a candidate
213
333
  * noun — narrower than the POS tier (no context to lean on), but the same
214
- * "an unlisted word can still be a content noun" posture. */
334
+ * "an unlisted word can still be a content noun" posture. Capitalization
335
+ * stands in for the missing tags when it carries information: two or more
336
+ * capitalized words separated by nothing but spaces count once, as one name. */
215
337
  function candidateTermOccurrencesLexical(sentences, lexicon) {
216
338
  const counts = new Map();
217
339
  for (const sentence of sentences) {
218
- const words = String(sentence || "").match(/[A-Za-z][A-Za-z'-]*/g) || [];
219
- for (const word of words) {
340
+ const text = String(sentence || "");
341
+ const matches = [...text.matchAll(/[A-Za-z][A-Za-z'-]*/g)];
342
+ const words = matches.map((match) => match[0]);
343
+ const titleCase = readsAsTitleCase(words);
344
+ const spacedRunEnd = (start) => {
345
+ let hi = start;
346
+ while (hi + 1 < matches.length && /^[A-Z]/.test(words[hi + 1])
347
+ && !text.slice(matches[hi].index + words[hi].length, matches[hi + 1].index).trim()) hi += 1;
348
+ return hi;
349
+ };
350
+ for (let i = 0; i < words.length; i += 1) {
351
+ const word = words[i];
352
+ if (!titleCase && /^[A-Z]/.test(word)) {
353
+ const hi = spacedRunEnd(i);
354
+ if (hi > i) {
355
+ const name = trimNameRun(words.slice(i, hi + 1), lexicon).join(" ");
356
+ counts.set(name, (counts.get(name) || 0) + 1);
357
+ i = hi;
358
+ continue;
359
+ }
360
+ }
220
361
  const lower = word.toLowerCase();
221
362
  if (lower.length < CANDIDATE_TERM_MIN_LENGTH) continue;
222
363
  if (OPTIMISTIC_SKIP.has(lower)) continue;
@@ -227,14 +368,47 @@ function candidateTermOccurrencesLexical(sentences, lexicon) {
227
368
  return counts;
228
369
  }
229
370
 
371
+ /** A single-word term that is one word of exactly one multi-word name the same
372
+ * text captured is that name's fragment, not a term of its own: "Gilman"
373
+ * beside "Robert Gilman" is the same person, and only the whole name is a
374
+ * question a reference lookup can answer. Its occurrences move onto the name.
375
+ * A word no captured name holds ("Russia", standing alone) is left where it
376
+ * is, and a word two names share is dropped rather than guessed onto one. */
377
+ function foldNameFragments(counts) {
378
+ const namesByWord = new Map();
379
+ for (const term of counts.keys()) {
380
+ if (!term.includes(" ")) continue;
381
+ for (const word of term.split(" ")) {
382
+ if (!namesByWord.has(word)) namesByWord.set(word, new Set());
383
+ namesByWord.get(word).add(term);
384
+ }
385
+ }
386
+ for (const [term, occurrences] of [...counts]) {
387
+ if (term.includes(" ")) continue;
388
+ const names = namesByWord.get(term);
389
+ if (!names) continue;
390
+ counts.delete(term);
391
+ if (names.size !== 1) continue;
392
+ const [name] = names;
393
+ counts.set(name, (counts.get(name) || 0) + occurrences);
394
+ }
395
+ return counts;
396
+ }
397
+
230
398
  /** Every fact-ungrounded term `sentences` names: a candidate noun folded to
231
399
  * its stored term key (`foldEntity`, so a ledger entry and a stored fact key
232
400
  * the same term identically) that `rows` holds zero fact rows for — the
233
401
  * fact-degree rule an ungrounded-term ledger admits by, independent of
234
402
  * whether the lexicon happens to know the word. Occurrence-counted, so a
235
- * term named three times outranks one named once. */
236
- function ungroundedTermOccurrences(sentences, rows, { lexicon, nlp }) {
237
- const raw = nlp ? candidateTermOccurrencesPos(sentences, nlp) : candidateTermOccurrencesLexical(sentences, lexicon);
403
+ * term named three times outranks one named once.
404
+ *
405
+ * `nlp` follows this module's own convention: absent means the shared wink
406
+ * instance, and an explicit null forces the lexical fallback. */
407
+ export function ungroundedTermOccurrences(sentences, rows, { lexicon = loadLexicon(), nlp } = {}) {
408
+ const engine = nlp === undefined ? winkInstance() : nlp;
409
+ const raw = engine
410
+ ? candidateTermOccurrencesPos(sentences, engine, lexicon)
411
+ : candidateTermOccurrencesLexical(sentences, lexicon);
238
412
  const grounded = new Set();
239
413
  for (const row of rows) {
240
414
  grounded.add(normFactTerm(row.subject));
@@ -243,9 +417,13 @@ function ungroundedTermOccurrences(sentences, rows, { lexicon, nlp }) {
243
417
  const counts = new Map();
244
418
  for (const [word, n] of raw) {
245
419
  const term = foldEntity(word, lexicon);
246
- if (!term || grounded.has(term)) continue;
420
+ if (!term) continue;
247
421
  counts.set(term, (counts.get(term) || 0) + n);
248
422
  }
423
+ // Fragments fold onto their whole name BEFORE the fact-degree filter, so a
424
+ // name a fact already grounds takes its own fragments out with it.
425
+ foldNameFragments(counts);
426
+ for (const term of [...counts.keys()]) if (grounded.has(term)) counts.delete(term);
249
427
  return counts;
250
428
  }
251
429
 
@@ -257,10 +435,12 @@ function ungroundedTermOccurrences(sentences, rows, { lexicon, nlp }) {
257
435
  function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false } = {}) {
258
436
  let values;
259
437
  let pos;
438
+ let lemmas;
260
439
  try {
261
440
  const doc = nlp.readDoc(String(sentence || ""));
262
441
  values = doc.tokens().out(nlp.its.value);
263
442
  pos = doc.tokens().out(nlp.its.pos);
443
+ lemmas = doc.tokens().out(nlp.its.lemma);
264
444
  } catch { return { triples: [], declined: [], minted: [] }; }
265
445
  // A found noun is read as its whole contiguous NOUN/PROPN run, head-lemma
266
446
  // folded — "a string instrument" is the class "string instrument", never
@@ -273,8 +453,9 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
273
453
  while (lo - 1 >= 0 && isNounish(lo - 1)) lo -= 1;
274
454
  while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
275
455
  if (lo === hi) return foldEntity(values[i], lexicon);
276
- const head = lookupNoun(lexicon, String(values[hi]).toLowerCase());
277
- return normFactTerm([...values.slice(lo, hi), head ? head.lemma : values[hi]].join(" "));
456
+ const last = stripSentenceFinalStop(values[hi]);
457
+ const head = lookupNoun(lexicon, last.toLowerCase());
458
+ return normFactTerm([...values.slice(lo, hi), head ? head.lemma : last].join(" "));
278
459
  };
279
460
  const nearestEntityIndex = (idx, step, blocked = null) => {
280
461
  for (let i = idx + step; i >= 0 && i < values.length; i += step) {
@@ -346,7 +527,9 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
346
527
  // class ("a type of mammal" → mammal); a partitive container head states
347
528
  // composition, never a class ("a large body of ice" — no isa at all).
348
529
  const copulaObjectAt = (i) => {
530
+ let sawDeterminer = false;
349
531
  for (let j = i + 1; j < values.length; j += 1) {
532
+ if (pos[j] === "DET" || pos[j] === "NUM") sawDeterminer = true;
350
533
  // A naming periphrasis ("… termed as …", "… known as …") keeps the
351
534
  // frame copular: skip the participle and its "as" and read on.
352
535
  if ((pos[j] === "VERB" || pos[j] === "AUX") && COPULA_NAMING_PARTICIPLES.has(values[j]?.toLowerCase())
@@ -378,6 +561,12 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
378
561
  while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
379
562
  }
380
563
  const headWord = String(values[hi]).toLowerCase();
564
+ // A bare "-ed" complement straight after the copula is a predicative
565
+ // participle the tagger mis-read as a noun ("dozens have been rescued"),
566
+ // never a class: a real class complement carries a determiner or number
567
+ // ("has been a doctor"). Short true nouns in -ed (bed, seed, need) stay
568
+ // under the length bound.
569
+ if (!sawDeterminer && headWord.length >= 5 && headWord.endsWith("ed")) return null;
381
570
  const nextWord = values[hi + 1]?.toLowerCase();
382
571
  // "latency is the name for the time period …" defines latency; it does not
383
572
  // put latency under the class "name". The isa is declined and the object
@@ -418,6 +607,17 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
418
607
  return { label: entityRunAt(climbed), hi };
419
608
  };
420
609
 
610
+ // Does the verb complex headed at `i` open with a be-form auxiliary? Adverbs
611
+ // in between are crossed; anything else ends the complex.
612
+ const beAuxiliaryBefore = (i) => {
613
+ for (let k = i - 1; k >= 0; k -= 1) {
614
+ if (pos[k] === "ADV" || pos[k] === "PART") continue;
615
+ if (pos[k] !== "AUX") return false;
616
+ if (BE_AUXILIARIES.has(String(values[k]).toLowerCase())) return true;
617
+ }
618
+ return false;
619
+ };
620
+
421
621
  const triples = [];
422
622
  const declined = [];
423
623
  const minted = [];
@@ -433,6 +633,65 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
433
633
  };
434
634
  const decline = (finding, candidate) => { declined.push({ finding, candidate }); };
435
635
 
636
+ // The newswire event frame — a closed band of transitive event verbs
637
+ // (NEWSWIRE_RELATION_VERBS) read in a much tighter frame than the lexicon
638
+ // arm's. The lexicon's own verbs keep their loose scans; a verb only this
639
+ // band knows has to earn its triple:
640
+ //
641
+ // - wink must have tagged the token VERB, and its LEMMA must be in the
642
+ // band, so a past tense ("released", "adopted") reads where the
643
+ // lexicon's -s-only fold cannot;
644
+ // - the verb complex must not open with a be-form, so a passive or a
645
+ // progressive never mints its own reverse;
646
+ // - it must not sit in a relative clause, which has no subject of its own
647
+ // here;
648
+ // - subject and object are each the NEAREST noun run on their side with
649
+ // the copula frame's blockers applied, so neither scan crosses a verb, a
650
+ // preposition or a conjunction into another clause. "resigned from
651
+ // Cambridge" yields no object at all rather than "resign Cambridge".
652
+ // The subject scan starts left of the verb's OWN modal chain ("will
653
+ // completely block"), which is one verb complex rather than a crossing.
654
+ // A counting of-chain on either side reads through to what the event
655
+ // really touched ("hundreds of ancient amphorae" → amphorae).
656
+ //
657
+ // A lemma the lexicon itself declares keeps the lexicon's predicate, so
658
+ // "releases" (the lexicon arm) and "released" (this one) land on one edge.
659
+ const verbComplexStart = (i) => {
660
+ let k = i - 1;
661
+ while (k >= 0 && (pos[k] === "ADV" || pos[k] === "AUX" || pos[k] === "PART")) k -= 1;
662
+ return k + 1;
663
+ };
664
+ const readsThroughOf = (word) => {
665
+ const w = String(word ?? "").toLowerCase();
666
+ return OF_COUNT_HEADS.has(w) || OF_PARTITIVE_HEADS.has(w) || OF_CLASSIFIER_HEADS.has(w);
667
+ };
668
+ const countChainEntity = (idx, step) => {
669
+ let at = nearestEntityIndex(idx, step, COPULA_FRAME_BLOCKERS);
670
+ for (let hop = 0; at !== null && hop < 2; hop += 1) {
671
+ let hi = at;
672
+ while (hi + 1 < values.length && isNounish(hi + 1)) hi += 1;
673
+ if (values[hi + 1]?.toLowerCase() !== "of" || !readsThroughOf(values[hi])) break;
674
+ const inner = nearestEntityIndex(hi + 1, +1);
675
+ if (inner === null) break;
676
+ at = inner;
677
+ }
678
+ return at === null ? null : entityRunAt(at);
679
+ };
680
+ const readNewswireFrame = () => {
681
+ for (let i = 1; i < values.length - 1; i += 1) {
682
+ if (pos[i] !== "VERB") continue;
683
+ if (lookupVerb(lexicon, String(values[i]).toLowerCase())) continue;
684
+ const lemma = String(lemmas?.[i] ?? values[i]).toLowerCase();
685
+ if (!NEWSWIRE_RELATION_VERBS.has(lemma)) continue;
686
+ if (beAuxiliaryBefore(i) || relativePronounBefore(i) >= 0) continue;
687
+ const subject = countChainEntity(verbComplexStart(i), -1);
688
+ const object = countChainEntity(i, +1);
689
+ if (!subject || !object) continue;
690
+ const declared = lookupVerb(lexicon, lemma);
691
+ push(subject, declared ? predicateOf(declared) : `mgx:${lemma}`, object);
692
+ }
693
+ };
694
+
436
695
  // Pass 1 — the first clean copula frame yields the isa (all guards unchanged);
437
696
  // its subject and object-run end anchor the relative-clause continuation.
438
697
  let copulaSubject = null;
@@ -494,6 +753,7 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
494
753
  if (subject === null) continue;
495
754
  push(subject, predicateOf(verb), nearestEntity(i, +1));
496
755
  }
756
+ readNewswireFrame();
497
757
  return { triples, declined, minted };
498
758
  }
499
759
 
@@ -516,6 +776,7 @@ function optimisticTriplesPos(sentence, lexicon, nlp, { mintDefinitional = false
516
776
  if (subject === null) continue;
517
777
  push(subject, predicateOf(verb), nearestEntity(i, +1));
518
778
  }
779
+ readNewswireFrame();
519
780
  return { triples, declined, minted };
520
781
  }
521
782
 
@@ -559,8 +820,9 @@ function optimisticTriplesLexical(sentence, lexicon) {
559
820
 
560
821
  /**
561
822
  * The bounded triple candidates from a sentence the strict recognizer skipped:
562
- * a copula (→ rdfs:subClassOf) and, past its object, the relation verbs it
563
- * grounds (→ their predicates), so one sentence contributes every fact it holds
823
+ * a copula (→ rdfs:subClassOf), past its object the relation verbs it grounds
824
+ * (→ their predicates), and the closed newswire event band in its own tighter
825
+ * frame, so one sentence contributes every fact it holds
564
826
  * ("a volcano is a mountain that has lava" → volcano ⊑ mountain AND volcano has
565
827
  * lava). Every triple passes the same entity/guard checks on its own, deduped,
566
828
  * capped at MAX_TRIPLES_PER_SENTENCE so a run-on never shatters into noise; []
@@ -1093,6 +1355,9 @@ export async function ingestText(text, {
1093
1355
  const key = normFactTerm(term);
1094
1356
  if (key && !ungroundedCounts.has(key)) ungroundedCounts.set(key, 1);
1095
1357
  }
1358
+ // A decline names the word it tripped over, so the legacy set carries name
1359
+ // fragments too ("Gilman" out of "Robert Gilman"). Same fold, same reason.
1360
+ foldNameFragments(ungroundedCounts);
1096
1361
 
1097
1362
  const result = {
1098
1363
  sentences: sentenceCount,
@@ -60,7 +60,7 @@ import {
60
60
  import { DEFAULT_MIN_INTERVAL_MS } from "../adapters/corpus/courtesy.mjs";
61
61
  import { researchFacts } from "../adapters/corpus/research-source.mjs";
62
62
  import { throughSourceBreaker, sourceSkipStatusLine } from "../domain/source-breaker.mjs";
63
- import { ingestText, readsAsEntityTerm } from "./extract-facts.mjs";
63
+ import { ingestText, readsAsEntityTerm, ungroundedTermOccurrences } from "./extract-facts.mjs";
64
64
 
65
65
  export { NEWS_SOURCE_RECORDS, DEFAULT_NEWS_SOURCE_IDS, DEFAULT_NEWS_KB_IDS };
66
66
 
@@ -77,6 +77,9 @@ export const NEWS_DEFAULTS = Object.freeze({
77
77
  enrichTermsPerCycle: 3,
78
78
  negativeCacheTtlHours: 24,
79
79
  syllogismsPerIngest: 12,
80
+ // Per source, not across the whole poll: each enabled source keeps its own
81
+ // window of up to this many snapshots, so one prolific source can never
82
+ // crowd another out of the window before either has been read.
80
83
  itemCap: 30,
81
84
  newsFactCap: 4000,
82
85
  feedTop: 3,
@@ -246,18 +249,19 @@ function abortSignalOf(ctx) {
246
249
  return typeof ctx?.shouldAbort === "function" ? ctx.shouldAbort : () => false;
247
250
  }
248
251
 
249
- // A feed title rarely carries its own terminal punctuation ("Scientists
250
- // Discover New Species"); a fixture built from a full sentence ("A module is
251
- // a component.") already does. Joining unconditionally with ". " doubles the
252
- // period in the second case and corrupts sentence splitting downstream so
253
- // the separator is a bare space when the title already ends the sentence.
254
- const SENTENCE_END_RE = /[.!?]["')\]]*$/;
252
+ // The headline and the item's own description are two separate texts, so they
253
+ // reach the ingest as two PARAGRAPHS. Run together on one line they merge into
254
+ // one sentence whenever the headline ends on something the sentence splitter
255
+ // will not break after an abbreviation ("… Arrives in the U.S." + "Russia
256
+ // released …" reads as one 26-word sentence, and the fact that falls out of it
257
+ // has "u.s. russia" for a subject) or a description that opens on a quotation.
258
+ // A blank line between them is the boundary ingestText already splits on.
255
259
  function joinTitleAndSummary(title, summary) {
256
260
  const t = String(title || "").trim();
257
261
  const s = String(summary || "").trim();
258
262
  if (!t) return s;
259
263
  if (!s) return t;
260
- return `${t}${SENTENCE_END_RE.test(t) ? " " : ". "}${s}`;
264
+ return `${t}\n\n${s}`;
261
265
  }
262
266
 
263
267
  function toMs(value) {
@@ -305,6 +309,23 @@ function buildSourcesByFactId(items) {
305
309
  return map;
306
310
  }
307
311
 
312
+ /** The entity names a card's own article text carries, read through the same
313
+ * capture the enrichment queue is fed from (`ungroundedTermOccurrences`), so a
314
+ * name reaches a card's background under exactly the key enrichment stored it
315
+ * under. The fact set handed in is empty on purpose: that call's own filter
316
+ * drops a term the graph already holds facts about, and a card wants precisely
317
+ * those. A single word the lexicon reads as an everyday noun drops out here —
318
+ * "developer" names nothing a lookup could define — while a name run keeps its
319
+ * whole spelling, "tim king" and "amigados" alike. */
320
+ export function articleEntityNames(texts, { lexicon } = {}) {
321
+ const lex = lexicon || loadLexicon();
322
+ const names = [];
323
+ for (const term of ungroundedTermOccurrences(texts, [], { lexicon: lex }).keys()) {
324
+ if (term.includes(" ") || !isVocabGroundedTerm(lex, term)) names.push(term);
325
+ }
326
+ return names.sort();
327
+ }
328
+
308
329
  // ---------------------------------------------------------------------------
309
330
  // grounding definitions (10.2)
310
331
  // ---------------------------------------------------------------------------
@@ -639,10 +660,15 @@ export async function pollNewsSources(ctx) {
639
660
  recordSuccess(health, nowVal, "not-modified");
640
661
  } else {
641
662
  recordSuccess(health, nowVal, "ok");
642
- const merged = mergeSnapshots(state.items, result.items, {
663
+ // itemCap bounds THIS source's own window: mergeSnapshots sees only
664
+ // sourceId's existing snapshots, so one prolific source's window
665
+ // never crowds out another's before either has been read.
666
+ const ownItems = (state.items || []).filter((snap) => snap?.sourceId === sourceId);
667
+ const otherItems = (state.items || []).filter((snap) => snap?.sourceId !== sourceId);
668
+ const merged = mergeSnapshots(ownItems, result.items, {
643
669
  cap: config.itemCap, seen: state.seenItemKeys,
644
670
  });
645
- state.items = merged.items;
671
+ state.items = [...otherItems, ...merged.items].sort(byFetchedAtThenId);
646
672
  added = merged.added;
647
673
  newItemsTotal += added.length;
648
674
  }
@@ -875,7 +901,15 @@ export async function enrichTopTerms(ctx, { limit } = {}) {
875
901
  *
876
902
  * `newName` is a display-only badge (never a gate): true when the lexicon
877
903
  * has no everyday-noun reading for the hub, computed here rather than in
878
- * buildNewsItems because the domain layer carries no lexicon. */
904
+ * buildNewsItems because the domain layer carries no lexicon.
905
+ *
906
+ * `articleEntityNames` is wired in for the same reason: reading the entity
907
+ * names out of a card's own article text takes the lexicon and the wink
908
+ * tagger, so the domain asks for them through a seam and this layer answers
909
+ * with the capture the enrichment queue already uses. It is what puts a
910
+ * definition and the card that needed it on the same page — a lookup on
911
+ * "amigados" reaches a card whose only fact is that a site discussed the
912
+ * headline the name sits inside. */
879
913
  export async function buildFeed(ctx) {
880
914
  const { memoryDir, store, config, state, now, lexicon } = ctx;
881
915
  const nowVal = resolveNow(now);
@@ -886,7 +920,12 @@ export async function buildFeed(ctx) {
886
920
 
887
921
  const lex = lexicon || loadLexicon();
888
922
  const items = buildNewsItems(rows, {
889
- now: nowVal, windowMs, limit: config.itemCap, sourcesByFactId, readsAsEntityTerm,
923
+ now: nowVal,
924
+ windowMs,
925
+ limit: config.itemCap,
926
+ sourcesByFactId,
927
+ readsAsEntityTerm,
928
+ articleEntityNames: (texts) => articleEntityNames(texts, { lexicon: lex }),
890
929
  }).map((item) => ({ ...item, newName: !isVocabGroundedTerm(lex, item.hub) }));
891
930
  return { items, seedFallback: false, builtAt: nowVal };
892
931
  }