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

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.
Files changed (72) hide show
  1. package/README.md +20 -23
  2. package/bin/tmct.mjs +16 -33
  3. package/corpus/LICENSES.json +0 -21
  4. package/corpus/README.md +10 -13
  5. package/corpus/reference/manifest.json +19 -19
  6. package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
  7. package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
  8. package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
  9. package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
  10. package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
  11. package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
  12. package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
  13. package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
  14. package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
  15. package/corpus/tier2/generate.mjs +6 -142
  16. package/corpus/tier2/manifest.json +0 -42
  17. package/package.json +4 -4
  18. package/src/adapters/corpus/child-seed.mjs +74 -0
  19. package/src/adapters/corpus/conceptnet.mjs +45 -26
  20. package/src/adapters/memory/blocks.mjs +7 -1
  21. package/src/adapters/memory/core.mjs +453 -103
  22. package/src/adapters/memory/corpus-bands.mjs +27 -10
  23. package/src/adapters/memory/inspect.mjs +24 -5
  24. package/src/adapters/memory/rows.mjs +106 -9
  25. package/src/adapters/memory/shacl.mjs +10 -3
  26. package/src/domain/ask.mjs +27 -10
  27. package/src/domain/cli-verbs.mjs +3 -4
  28. package/src/domain/completions/group.mjs +8 -3
  29. package/src/domain/completions/infer.mjs +7 -2
  30. package/src/domain/completions/prune.mjs +5 -1
  31. package/src/domain/completions/rank.mjs +7 -2
  32. package/src/domain/digest/compose.mjs +5 -1
  33. package/src/domain/digest/select.mjs +12 -6
  34. package/src/domain/domain.mjs +15 -8
  35. package/src/domain/el-classify.mjs +11 -2
  36. package/src/domain/fact-phrase.mjs +86 -4
  37. package/src/domain/hash.mjs +9 -0
  38. package/src/domain/memory/bias.mjs +8 -4
  39. package/src/domain/memory/capability.mjs +12 -6
  40. package/src/domain/memory/fact-order.mjs +29 -0
  41. package/src/domain/memory/resolution.mjs +3 -0
  42. package/src/domain/news-feed.mjs +422 -56
  43. package/src/domain/reference-pack.mjs +5 -0
  44. package/src/domain/sense-scope.mjs +116 -0
  45. package/src/domain/sense-split.mjs +1 -1
  46. package/src/domain/syllogise.mjs +21 -13
  47. package/src/domain/tableau.mjs +23 -14
  48. package/src/domain/worlds-pack.mjs +5 -1
  49. package/src/services/adventure-autoplay.mjs +6 -1
  50. package/src/services/adventure-editor.mjs +43 -21
  51. package/src/services/adventure-viz.mjs +26 -9
  52. package/src/services/adventure.mjs +40 -10
  53. package/src/services/chat.mjs +253 -113
  54. package/src/services/extensions.mjs +51 -58
  55. package/src/services/extract-facts.mjs +670 -95
  56. package/src/services/init.mjs +4 -4
  57. package/src/services/ledger-viz.mjs +9 -4
  58. package/src/services/memory-panel-viz.mjs +4 -5
  59. package/src/services/mud-editor.mjs +40 -16
  60. package/src/services/mud-viz.mjs +8 -2
  61. package/src/services/mudiii-turn.mjs +5 -3
  62. package/src/services/mudiii-viz.mjs +8 -2
  63. package/src/services/news.mjs +257 -11
  64. package/src/services/research-viz.mjs +1 -1
  65. package/src/services/sprite-catalog-viz.mjs +10 -5
  66. package/src/surfaces/web/adventure-browser-entry.mjs +6 -12
  67. package/src/surfaces/web/memory-ask-browser.bundle.js +152 -151
  68. package/src/surfaces/web/mud-browser-entry.mjs +7 -11
  69. package/src/surfaces/web/research-browser-entry.mjs +5 -2
  70. package/corpus/tier2/aws.jsonl +0 -39
  71. package/corpus/tier2/java.jsonl +0 -31
  72. package/corpus/tier2/python.jsonl +0 -30
@@ -6,10 +6,11 @@
6
6
  // sortFactIndividualsById holds for a CRDT-merged fact set.
7
7
 
8
8
  import { sha256Bytes, normFactTerm } from "./hash.mjs";
9
- import { FACT_PREDICATE_PHRASES, predicatePhrase, factSentence } from "./fact-phrase.mjs";
9
+ import { FACT_PREDICATE_PHRASES, predicatePhrase, predicateVerb, factSentence } from "./fact-phrase.mjs";
10
10
  import { STOP_SET } from "./hub-terms.mjs";
11
11
  import { articleFor } from "./digest/words.mjs";
12
12
  import { provenanceTagToSource } from "./memory/trust.mjs";
13
+ import { buildSenseScope } from "./sense-scope.mjs";
13
14
 
14
15
  export const NEWS_HUB_HOPS = 2; // fixed by design, not a knob
15
16
 
@@ -71,6 +72,72 @@ export function newsWindowRows(rows, { now, windowMs }) {
71
72
  });
72
73
  }
73
74
 
75
+ // ---------------------------------------------------------------------------
76
+ // Attributions: who a report said its claim came from.
77
+ // ---------------------------------------------------------------------------
78
+
79
+ // A reified attribution names its claim by that claim's own group id, so its
80
+ // subject is "fact:" and sixteen hex — normFactTerm's own carve-out shape, and
81
+ // nothing a source ever writes as a term.
82
+ const FACT_REFERENCE_TERM_RE = /^fact:[0-9a-f]{16}$/;
83
+
84
+ // A fact id is minted lowercase, and both the claim's own `id` and the
85
+ // attribution's subject go through this before either is used as a key — a
86
+ // speaker matched on one spelling and stored under another is a silent drop.
87
+ const factIdKey = (term) => String(term ?? "").trim().toLowerCase();
88
+
89
+ const namesAFactRow = (term) => FACT_REFERENCE_TERM_RE.test(factIdKey(term));
90
+
91
+ /** The fact `row` is ABOUT, when either of its sides names one rather than a
92
+ * thing, else "". */
93
+ function referencedFactId(row) {
94
+ const subject = factIdKey(row?.subject);
95
+ if (namesAFactRow(subject)) return subject;
96
+ const object = factIdKey(row?.object);
97
+ return namesAFactRow(object) ? object : "";
98
+ }
99
+
100
+ /** True when `row` is about another row rather than about the world. Every card
101
+ * lane scores, walks and prints terms, and `looksLikeEntityTerm` reads a bare
102
+ * `fact:285cf1618315591b` as a perfectly good one-word name, so a row like this
103
+ * loose in a lane can head a card with a hex id. */
104
+ export function isFactReferenceRow(row) {
105
+ return Boolean(referencedFactId(row));
106
+ }
107
+
108
+ const ATTRIBUTED_TO_PREDICATE = "mgx:attributedTo";
109
+
110
+ /** Splits a fact set once, at the door: the claims a card may read, and the
111
+ * speakers each claim was attributed to (claim group id -> speaker[], sorted).
112
+ * Everything downstream — the hub gate, the adjacency index, the walk, the
113
+ * sentences, a card's own `factIds` — takes `claims`, so an attribution reaches
114
+ * no lane at all and the suppression cannot be missed one lane at a time.
115
+ *
116
+ * Pure and order-independent: one claim's speakers come back in the same sorted
117
+ * order whichever order the attributions arrived in, and a claim the fact set
118
+ * never names simply has no entry — an attribution can arrive before its claim,
119
+ * after it, or without it. */
120
+ export function partitionAttributions(rows) {
121
+ const claims = [];
122
+ const named = new Map();
123
+ for (const row of rows) {
124
+ if (!isFactReferenceRow(row)) {
125
+ claims.push(row);
126
+ continue;
127
+ }
128
+ if (row.predicate !== ATTRIBUTED_TO_PREDICATE) continue;
129
+ const claimId = factIdKey(row.subject);
130
+ const speaker = String(row.object ?? "").trim();
131
+ if (!claimId || !speaker) continue;
132
+ let speakers = named.get(claimId);
133
+ if (!speakers) named.set(claimId, (speakers = new Set()));
134
+ speakers.add(speaker);
135
+ }
136
+ const speakersByClaimId = new Map();
137
+ for (const [claimId, speakers] of named) speakersByClaimId.set(claimId, [...speakers].sort());
138
+ return { claims, speakersByClaimId };
139
+ }
140
+
74
141
  // ---------------------------------------------------------------------------
75
142
  // Item identity: what makes two fetched snapshots the same newsworthy item.
76
143
  // ---------------------------------------------------------------------------
@@ -306,6 +373,27 @@ const ENTITY_TRAILING_AUXILIARY_WORDS = new Set([
306
373
  "is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
307
374
  ]);
308
375
 
376
+ // A name is one noun phrase, so a word that opens a new phrase or clause
377
+ // BETWEEN a term's first and last word marks a headline a frame tore into
378
+ // subject + predicate + remainder ("colombia as rescuers free quake victim").
379
+ // Mirrors extract-facts.mjs's INTERIOR_CLAUSE_WORDS, for the same reason the
380
+ // sets above mirror their originals. "of" stays out: real names are built with
381
+ // it ("house of representatives").
382
+ // A term the source itself wrapped in quotation marks is a title it quoted, and
383
+ // a title is free to read as a clause. Mirrors extract-facts.mjs's own
384
+ // QUOTED_TERM_RE, and exempts the interior rule alone.
385
+ const ENTITY_QUOTED_TERM_RE = /^["“'‘].*["”'’]$/;
386
+ const ENTITY_INTERIOR_CLAUSE_WORDS = new Set([
387
+ "a", "an", "the",
388
+ "and", "or", "but", "because", "since", "although", "though", "whereas", "while", "so",
389
+ "if", "when", "then", "however", "as", "that", "which", "who", "whom", "whose",
390
+ "is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
391
+ "do", "does", "did", "can", "could", "will", "would", "should", "may", "might", "must",
392
+ "in", "on", "at", "for", "to", "with", "from", "by", "into", "onto",
393
+ "over", "under", "after", "before", "between", "during", "about", "near", "through",
394
+ "against", "among", "within", "without", "per",
395
+ ]);
396
+
309
397
  /** Does `term` read as a thing's name rather than a clause fragment? Bounds
310
398
  * the word count and rejects a leading conjunction, auxiliary or
311
399
  * preposition (test E's condition 3, PLAN_NEWSWORTHINESS.md section 2), plus
@@ -321,6 +409,11 @@ function looksLikeEntityTerm(term) {
321
409
  if (ENTITY_PARTICLE_LEAD_WORDS.has(first)) return false;
322
410
  if (ENTITY_PRONOUN_LEAD_WORDS.has(first.replace(ENTITY_CLITIC_SUFFIX_RE, ""))) return false;
323
411
  if (ENTITY_TRAILING_AUXILIARY_WORDS.has(words[words.length - 1].toLowerCase())) return false;
412
+ if (!ENTITY_QUOTED_TERM_RE.test(`${words[0]} ${words[words.length - 1]}`)) {
413
+ for (let i = 1; i < words.length - 1; i += 1) {
414
+ if (ENTITY_INTERIOR_CLAUSE_WORDS.has(words[i].toLowerCase())) return false;
415
+ }
416
+ }
324
417
  return true;
325
418
  }
326
419
 
@@ -531,35 +624,48 @@ export function hubSeedTerms(hub) {
531
624
  *
532
625
  * `excludeIds` drops rows before the cap rather than after it, so a card that
533
626
  * gives a report away to another card (storyCoverage) spends the freed budget
534
- * on rows it will actually show. */
627
+ * on rows it will actually show.
628
+ *
629
+ * `inSense` is the same-sense discipline (sense-scope.mjs): a `(term) =>
630
+ * boolean` test the walk applies to every term that is not a seed. A term it
631
+ * refuses collects no row and joins no frontier, so the walk stays inside the
632
+ * seeds' own sense instead of climbing a shared class node and coming back
633
+ * down another meaning of it. What a source actually REPORTED about a seed is
634
+ * exempt: a card never drops its own news to keep a sense tidy. */
535
635
  export function subgraphAround(rows, hub, {
536
- hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null, seedTerms = null, excludeIds = null,
636
+ hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null, seedTerms = null,
637
+ excludeIds = null, inSense = null,
537
638
  } = {}) {
538
639
  const adj = adjacency ?? buildTermAdjacency(rows);
539
640
  const hubTerm = normFactTerm(hub);
540
641
  const seeds = (seedTerms?.length ? seedTerms : [hubTerm]).map((t) => normFactTerm(t)).filter(Boolean);
541
642
  const visited = new Set(seeds);
643
+ const seedSet = new Set(seeds);
542
644
  let frontier = [...new Set(seeds)].sort();
543
645
  const collected = new Map();
544
646
  const hopOf = new Map();
545
647
  const isExcluded = excludeIds instanceof Set ? (id) => excludeIds.has(id) : () => false;
648
+ const isPriority = (id) => (priorityIds instanceof Set ? priorityIds.has(id) : Boolean(priorityIds?.includes?.(id)));
649
+ const inScope = typeof inSense === "function" ? (term) => seedSet.has(term) || inSense(term) : () => true;
546
650
  for (let hop = 0; hop < hops; hop += 1) {
547
651
  const nextFrontier = new Set();
548
652
  for (const term of [...frontier].sort()) {
549
653
  for (const idx of adj.byTerm.get(term) ?? []) {
550
654
  const row = rows[idx];
551
655
  if (isExcluded(row.id)) continue;
656
+ const [s, o] = adj.terms[idx];
657
+ const staysInSense = inScope(s) && inScope(o);
658
+ const isOwnReport = isPriority(row.id) && (seedSet.has(s) || seedSet.has(o));
659
+ if (!staysInSense && !isOwnReport) continue;
552
660
  collected.set(row.id, row);
553
661
  if (!hopOf.has(row.id)) hopOf.set(row.id, hop);
554
- const [s, o] = adj.terms[idx];
555
- if (!visited.has(s)) nextFrontier.add(s);
556
- if (!visited.has(o)) nextFrontier.add(o);
662
+ if (!visited.has(s) && inScope(s)) nextFrontier.add(s);
663
+ if (!visited.has(o) && inScope(o)) nextFrontier.add(o);
557
664
  }
558
665
  }
559
666
  for (const term of nextFrontier) visited.add(term);
560
667
  frontier = [...nextFrontier].sort();
561
668
  }
562
- const isPriority = (id) => (priorityIds instanceof Set ? priorityIds.has(id) : Boolean(priorityIds?.includes?.(id)));
563
669
  return [...collected.values()]
564
670
  .sort((a, b) => (isPriority(b.id) - isPriority(a.id))
565
671
  || (hopOf.get(a.id) - hopOf.get(b.id))
@@ -584,12 +690,12 @@ const ARTICLE_ENTITY_ROW_CAP = 24;
584
690
  * card claimed, so this walk returns background and nothing else: what a
585
691
  * source reported is the hub walk's business. */
586
692
  export function articleEntityRows(rows, terms, {
587
- adjacency = null, excludeIds = null, cap = ARTICLE_ENTITY_ROW_CAP,
693
+ adjacency = null, excludeIds = null, cap = ARTICLE_ENTITY_ROW_CAP, inSense = null,
588
694
  } = {}) {
589
695
  const seedTerms = (terms || []).map((term) => normFactTerm(term)).filter(Boolean);
590
696
  if (!seedTerms.length) return [];
591
697
  return subgraphAround(rows, seedTerms[0], {
592
- hops: ARTICLE_ENTITY_HOPS, cap, adjacency, seedTerms, excludeIds,
698
+ hops: ARTICLE_ENTITY_HOPS, cap, adjacency, seedTerms, excludeIds, inSense,
593
699
  });
594
700
  }
595
701
 
@@ -648,6 +754,50 @@ function joinWithAnd(items) {
648
754
  return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
649
755
  }
650
756
 
757
+ /** The speakers `speakersByClaimId` attributes to `rows`, deduped and sorted.
758
+ * Empty for rows nothing attributed, which is every row until a report stores
759
+ * one. */
760
+ function speakersFor(rows, speakersByClaimId) {
761
+ if (!(speakersByClaimId instanceof Map) || !speakersByClaimId.size) return [];
762
+ const named = new Set();
763
+ for (const row of rows) {
764
+ for (const speaker of speakersByClaimId.get(factIdKey(row.id)) ?? []) named.add(speaker);
765
+ }
766
+ return [...named].sort();
767
+ }
768
+
769
+ /** ", president trump said" — the article's own construction, folded onto the
770
+ * end of the claim's own sentence rather than printed as apparatus beside it. */
771
+ function speakerClause(speakers) {
772
+ return speakers.length ? `, ${joinWithAnd(speakers)} said` : "";
773
+ }
774
+
775
+ /** One row's whole sentence with its own speaker folded in. */
776
+ function attributedFactSentence(row, speakersByClaimId) {
777
+ return `${factSentence(row)}${speakerClause(speakersFor([row], speakersByClaimId))}`;
778
+ }
779
+
780
+ /** The speaker clause a GROUPED sentence may carry. One sentence stands for
781
+ * several rows, so the clause is only true where the attributed rows among them
782
+ * already name every object the sentence prints. A group whose UNattributed row
783
+ * brings an object of its own would put that object in a speaker's mouth, so
784
+ * the clause drops whole rather than narrowing to a sentence it no longer
785
+ * describes. */
786
+ function groupSpeakerClause(rows, printedObjects, speakersByClaimId) {
787
+ if (!(speakersByClaimId instanceof Map) || !speakersByClaimId.size) return "";
788
+ const named = new Set();
789
+ const spokenObjects = new Set();
790
+ for (const row of rows) {
791
+ const speakers = speakersByClaimId.get(factIdKey(row.id));
792
+ if (!speakers?.length) continue;
793
+ for (const speaker of speakers) named.add(speaker);
794
+ spokenObjects.add(row.object);
795
+ }
796
+ if (!named.size) return "";
797
+ if (!printedObjects.every((object) => spokenObjects.has(object))) return "";
798
+ return speakerClause([...named].sort());
799
+ }
800
+
651
801
  // ---------------------------------------------------------------------------
652
802
  // What one card reports, and whose neighbourhood it sits in.
653
803
  // ---------------------------------------------------------------------------
@@ -753,6 +903,32 @@ const KNOWN_FACT_SENTENCE_CAP = 2;
753
903
  // wall of text.
754
904
  const OBJECTS_PER_SENTENCE = 6;
755
905
 
906
+ // Two report rows on one card can state ONE act under two words: a headline
907
+ // says a prisoner was "freed", the description says "released", and the graph
908
+ // is right to hold both edges because two different sentences really said so.
909
+ // The card is the thing that should say it once.
910
+ //
911
+ // The fold is here, at assembly, and NOT at extraction, because only here is
912
+ // the whole row set in view. Extraction reads one verb at a time, and folding
913
+ // "free" onto "release" there turned "rescuers free quake victim" into a jail
914
+ // delivery: the two senses of "free" split on the subject's kind, an open set,
915
+ // and a headline carries nothing to tell them apart. A card folds only where
916
+ // BOTH verbs already stand over the SAME subject naming the SAME people, which
917
+ // is the evidence extraction never had. A rescue card carries "free" alone, so
918
+ // nothing folds and it still reads as a rescue.
919
+ //
920
+ // The bar for a pair is that the two words name one act wherever a card can
921
+ // hold both. Verbs that merely share a subject and an object stay apart,
922
+ // opposites first among them: "russia detains X" and "russia releases X" are
923
+ // two claims about one prisoner, they are in no group together, and each keeps
924
+ // its own sentence.
925
+ const ONE_ACT_VERB_GROUPS = [
926
+ ["free", "release"],
927
+ ];
928
+ const ONE_ACT_CANONICAL_VERB = new Map(
929
+ ONE_ACT_VERB_GROUPS.flatMap((group) => group.map((verb) => [verb, group[0]])),
930
+ );
931
+
756
932
  // A term the graph says is more than this many things is read across senses:
757
933
  // "earthquake" is a natural event, a cognition, a social station and nine more,
758
934
  // so no single class line about it is trustworthy on a card about one quake.
@@ -904,6 +1080,18 @@ function predicatesInRenderOrder(rows) {
904
1080
  return [...curated, ...rest];
905
1081
  }
906
1082
 
1083
+ /** The act a report predicate states, as the key two predicates share when
1084
+ * they say it in one word: the predicate's verb lemma folded onto its group's
1085
+ * canonical verb, with any particle beside it, so `tmct:releases` and
1086
+ * `mgx:free` both read "release" while `mgx:strike-near` stays apart from
1087
+ * `mgx:strike`. Empty for a predicate that states no act at all (an identity
1088
+ * row, a comparative, a passive participle), which folds with nothing. */
1089
+ function oneActKey(predicate) {
1090
+ const verb = predicateVerb(predicate);
1091
+ if (!verb) return "";
1092
+ return `${ONE_ACT_CANONICAL_VERB.get(verb.lemma) ?? verb.lemma} ${verb.particle}`;
1093
+ }
1094
+
907
1095
  /** One sentence per (subject, predicate) group over `rows`, in the order the
908
1096
  * rows arrive — the same shape the hub's own relation sentences take, so a
909
1097
  * background line reads like the rest of the paragraph rather than a dump.
@@ -911,7 +1099,7 @@ function predicatesInRenderOrder(rows) {
911
1099
  * that needs to know which facts a sentence came from (the bench's noisy-
912
1100
  * line scoring) reads them off the same grouping the sentence itself used,
913
1101
  * never a second derivation of it. */
914
- function groupedFactSentenceEntries(rows) {
1102
+ function groupedFactSentenceEntries(rows, speakersByClaimId = null) {
915
1103
  const groups = new Map();
916
1104
  for (const row of rows) {
917
1105
  const key = `${row.subject}${row.predicate}`;
@@ -931,12 +1119,12 @@ function groupedFactSentenceEntries(rows) {
931
1119
  const text = predicate === "rdf:type"
932
1120
  ? `${subject} is ${joinObjects(sorted.map((object) => `${articleFor(object)} ${object}`))}`
933
1121
  : `${subject} ${predicatePhrase(predicate, subject)} ${joinObjects(sorted)}`;
934
- return { text, rows: groupRows };
1122
+ return { text: `${text}${groupSpeakerClause(groupRows, sorted, speakersByClaimId)}`, rows: groupRows };
935
1123
  });
936
1124
  }
937
1125
 
938
- function groupedFactSentences(rows) {
939
- return groupedFactSentenceEntries(rows).map((entry) => entry.text);
1126
+ function groupedFactSentences(rows, speakersByClaimId = null) {
1127
+ return groupedFactSentenceEntries(rows, speakersByClaimId).map((entry) => entry.text);
940
1128
  }
941
1129
 
942
1130
  /** The sentences a card's paragraph is made of, as four ordered blocks: the
@@ -948,19 +1136,41 @@ function groupedFactSentences(rows) {
948
1136
  * `renderNewsParagraph` itself slices, never a second derivation of it.
949
1137
  * Callers that render only one block — the "what the graph already knew"
950
1138
  * disclosure — read the block they want instead of re-deriving it. */
951
- function paragraphBlocks(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
1139
+ function paragraphBlocks(hub, subgraphRows, { reportedIds = null, articleTerms = [], speakersByClaimId = null } = {}) {
952
1140
  const hubTerm = normFactTerm(hub);
953
1141
  const isReported = idMembership(reportedIds);
954
1142
  const hubRows = subgraphRows.filter((r) => normFactTerm(r.subject) === hubTerm);
955
1143
  const reportedHubRows = hubRows.filter((r) => isReported(r.id));
956
- const report = [];
1144
+ // Every sentence here shares the one subject, so the act key and the objects
1145
+ // are all that separate two of them. A predicate whose act a sentence above
1146
+ // it has already stated, over people that sentence already names, adds no
1147
+ // word a reader has not read — its rows join the sentence that says their
1148
+ // act, so a fact still counts as printed and the fold can lose nothing. A
1149
+ // predicate that brings a new name to the act keeps its own sentence.
1150
+ const reportGroups = [];
1151
+ const statedActs = new Map();
957
1152
  for (const predicate of predicatesInRenderOrder(reportedHubRows)) {
958
- if (IDENTITY_PREDICATES.has(predicate) || report.length >= REPORT_SENTENCE_CAP) continue;
1153
+ if (IDENTITY_PREDICATES.has(predicate) || reportGroups.length >= REPORT_SENTENCE_CAP) continue;
959
1154
  const groupRows = reportedHubRows.filter((r) => r.predicate === predicate);
960
1155
  const objects = groupRows.map((r) => r.object).sort();
961
1156
  if (!objects.length) continue;
962
- report.push({ text: `${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`, rows: groupRows });
1157
+ const actKey = oneActKey(predicate);
1158
+ const stated = actKey ? statedActs.get(actKey) : null;
1159
+ if (stated && objects.every((object) => stated.objects.has(object))) {
1160
+ stated.group.rows.push(...groupRows);
1161
+ continue;
1162
+ }
1163
+ const group = { text: `${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`, rows: groupRows, objects };
1164
+ reportGroups.push(group);
1165
+ if (actKey && !stated) statedActs.set(actKey, { group, objects: new Set(objects) });
963
1166
  }
1167
+ // The speaker is read off the whole group, once the fold above has finished
1168
+ // moving rows into it — the row an article attributed is often the FOLDED one,
1169
+ // not the row whose words the sentence ended up wearing.
1170
+ const report = reportGroups.map(({ text, rows: groupRows, objects }) => ({
1171
+ text: `${text}${groupSpeakerClause(groupRows, objects, speakersByClaimId)}`,
1172
+ rows: groupRows,
1173
+ }));
964
1174
 
965
1175
  // A hub that only ever appears as an OBJECT — the place a quake struck, the
966
1176
  // story a site discussed — has no subject-side row to build a sentence from,
@@ -972,7 +1182,10 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null, articleTerms =
972
1182
  .sort(byId)
973
1183
  .slice(0, OBJECTS_PER_SENTENCE);
974
1184
  if (aboutHubRows.length) {
975
- report.push({ text: aboutHubRows.map((r) => factSentence(r)).join("; "), rows: aboutHubRows });
1185
+ report.push({
1186
+ text: aboutHubRows.map((r) => attributedFactSentence(r, speakersByClaimId)).join("; "),
1187
+ rows: aboutHubRows,
1188
+ });
976
1189
  }
977
1190
  }
978
1191
 
@@ -985,17 +1198,24 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null, articleTerms =
985
1198
  const identityObjects = identityRows.map((r) => r.object).sort();
986
1199
  const identityIsSingleSense = identityObjects.length > 0 && identityObjects.length <= IDENTITY_MAX_CLASSES;
987
1200
  if (identityIsSingleSense) {
1201
+ const said = groupSpeakerClause(identityRows, identityObjects, speakersByClaimId);
988
1202
  identity.push({
989
- text: `${hub} is ${joinObjects(identityObjects.map((object) => `${articleFor(object)} ${object}`))}`,
1203
+ text: `${hub} is ${joinObjects(identityObjects.map((object) => `${articleFor(object)} ${object}`))}${said}`,
990
1204
  rows: identityRows,
991
1205
  });
992
1206
  }
993
1207
 
994
- const known = groupedFactSentenceEntries(knownFactRows(hub, subgraphRows, { reportedIds, articleTerms }));
1208
+ const known = groupedFactSentenceEntries(
1209
+ knownFactRows(hub, subgraphRows, { reportedIds, articleTerms }),
1210
+ speakersByClaimId,
1211
+ );
995
1212
 
996
1213
  const neighbours = neighbourRows(hub, subgraphRows, { reportedIds });
997
1214
  const around = neighbours.length
998
- ? [{ text: `Around it: ${neighbours.map((r) => factSentence(r)).join("; ")}`, rows: neighbours }]
1215
+ ? [{
1216
+ text: `Around it: ${neighbours.map((r) => attributedFactSentence(r, speakersByClaimId)).join("; ")}`,
1217
+ rows: neighbours,
1218
+ }]
999
1219
  : [];
1000
1220
 
1001
1221
  return { report, identity, known, around };
@@ -1006,8 +1226,8 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null, articleTerms =
1006
1226
  * (identity, known) and the paragraph-wide `SENTENCE_CAP` both applied.
1007
1227
  * Shared by `renderNewsParagraph` and `printedParagraphRows` so the two can
1008
1228
  * never drift: one reads `.text`, the other reads `.rows`. */
1009
- function paragraphSentenceEntries(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
1010
- const { report, identity, known, around } = paragraphBlocks(hub, subgraphRows, { reportedIds, articleTerms });
1229
+ function paragraphSentenceEntries(hub, subgraphRows, { reportedIds = null, articleTerms = [], speakersByClaimId = null } = {}) {
1230
+ const { report, identity, known, around } = paragraphBlocks(hub, subgraphRows, { reportedIds, articleTerms, speakersByClaimId });
1011
1231
  return [
1012
1232
  ...report,
1013
1233
  ...identity.slice(0, IDENTITY_SENTENCE_CAP),
@@ -1026,8 +1246,9 @@ function paragraphSentenceEntries(hub, subgraphRows, { reportedIds = null, artic
1026
1246
  * into what was reported (the lead sentences) and what the graph already held
1027
1247
  * (the background ones). Defaults to null, meaning every row counts as
1028
1248
  * reported. */
1029
- export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
1030
- const sentences = paragraphSentenceEntries(hub, subgraphRows, { reportedIds, articleTerms }).map((entry) => entry.text);
1249
+ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null, articleTerms = [], speakersByClaimId = null } = {}) {
1250
+ const sentences = paragraphSentenceEntries(hub, subgraphRows, { reportedIds, articleTerms, speakersByClaimId })
1251
+ .map((entry) => entry.text);
1031
1252
  return sentences.length ? `${sentences.join(". ")}.` : "";
1032
1253
  }
1033
1254
 
@@ -1037,8 +1258,9 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null, art
1037
1258
  * computed but sliced away before render (an "Around it" clause cut by the
1038
1259
  * overall cap, an identity class beyond `IDENTITY_MAX_CLASSES`) never
1039
1260
  * appears here, because it never appears on the card either. */
1040
- export function printedParagraphRows(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
1041
- return paragraphSentenceEntries(hub, subgraphRows, { reportedIds, articleTerms }).flatMap((entry) => entry.rows);
1261
+ export function printedParagraphRows(hub, subgraphRows, { reportedIds = null, articleTerms = [], speakersByClaimId = null } = {}) {
1262
+ return paragraphSentenceEntries(hub, subgraphRows, { reportedIds, articleTerms, speakersByClaimId })
1263
+ .flatMap((entry) => entry.rows);
1042
1264
  }
1043
1265
 
1044
1266
  /** The "what the graph already knew" disclosure: the same related facts the
@@ -1046,11 +1268,98 @@ export function printedParagraphRows(hub, subgraphRows, { reportedIds = null, ar
1046
1268
  * card already reported. Empty when the graph held nothing about this card's
1047
1269
  * own subjects — a card with no background says so rather than filling the
1048
1270
  * space with whatever the two-hop walk happened to reach. */
1049
- export function renderKnownFactsParagraph(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
1050
- const sentences = groupedFactSentences(knownFactRows(hub, subgraphRows, { reportedIds, articleTerms }));
1271
+ export function renderKnownFactsParagraph(hub, subgraphRows, { reportedIds = null, articleTerms = [], speakersByClaimId = null } = {}) {
1272
+ const sentences = groupedFactSentences(
1273
+ knownFactRows(hub, subgraphRows, { reportedIds, articleTerms }),
1274
+ speakersByClaimId,
1275
+ );
1051
1276
  return sentences.length ? `${sentences.join(". ")}.` : "";
1052
1277
  }
1053
1278
 
1279
+ // ---------------------------------------------------------------------------
1280
+ // What a card says beyond its own headline.
1281
+ // ---------------------------------------------------------------------------
1282
+
1283
+ // Two spellings of one headline — the source's own capitals and punctuation,
1284
+ // and the quoted, lower-cased form a fact's object carries — fold to the same
1285
+ // key, so the comparison answers to the words alone.
1286
+ const HEADLINE_NOISE_RE = /[^a-z0-9]+/g;
1287
+
1288
+ const headlineKey = (text) => String(text ?? "").toLowerCase().replace(HEADLINE_NOISE_RE, " ").trim();
1289
+
1290
+ /** Every term this card is about: its hub, whatever its own reports name, and
1291
+ * the entities its article names. Deliberately looser than
1292
+ * `cardSubjectTerms` — that one picks what a SENTENCE may draw on, and drops
1293
+ * a term the graph reads across senses; this one asks the plainer question of
1294
+ * what the card is about at all, which is what a count of held background
1295
+ * answers to. */
1296
+ function cardAboutTerms(hub, reports, articleTerms) {
1297
+ const terms = new Set(hubSeedTerms(normFactTerm(hub)));
1298
+ for (const row of reports) {
1299
+ for (const raw of [row.subject, row.object]) {
1300
+ const term = normFactTerm(raw);
1301
+ if (term) terms.add(term);
1302
+ }
1303
+ }
1304
+ for (const raw of articleTerms) {
1305
+ const term = normFactTerm(raw);
1306
+ if (term) terms.add(term);
1307
+ }
1308
+ return terms;
1309
+ }
1310
+
1311
+ /** What one card is carrying, and the reason a feed puts one card above
1312
+ * another.
1313
+ *
1314
+ * `claims` are the card's own reports (`hubReportRows`) that say something
1315
+ * about the world. `headlineMentions` are its own reports whose object IS one
1316
+ * of its headlines — "hackernews discuss <that headline>", a true fact that
1317
+ * tells a reader nothing the card's own quoted report already prints.
1318
+ * `background` is what the graph holds about the terms the card is about:
1319
+ * the rows behind its "what the graph already knew" lines, counted before the
1320
+ * paragraph's own caps cut them, and never another card's report.
1321
+ *
1322
+ * A source whose wire format carries no body can only ever mint headline
1323
+ * mentions, so a card off one lands at zero on both counts unless a lookup
1324
+ * attached something to a name in its headline. Those cards read last. They
1325
+ * still build, still cite their source, and `thin` marks them so a reader or
1326
+ * a bench run can count how many there were.
1327
+ *
1328
+ * Pure over the rows and the headline strings. A card with no headlines to
1329
+ * compare against (a caller that wired no sources) counts every report as a
1330
+ * claim: nothing is demoted without the evidence to demote it. */
1331
+ export function cardSubstance(hub, subgraphRows, { reportedIds = null, articleTerms = [], headlines = [] } = {}) {
1332
+ const isReported = idMembership(reportedIds);
1333
+ const ownHeadlines = new Set(headlines.map(headlineKey).filter(Boolean));
1334
+
1335
+ const reports = hubReportRows(hub, subgraphRows, { reportedIds });
1336
+ let claims = 0;
1337
+ let headlineMentions = 0;
1338
+ for (const row of reports) {
1339
+ if (ownHeadlines.has(headlineKey(row.object))) headlineMentions += 1;
1340
+ else claims += 1;
1341
+ }
1342
+
1343
+ const about = cardAboutTerms(hub, reports, articleTerms);
1344
+ let background = 0;
1345
+ for (const row of subgraphRows) {
1346
+ if (isReported(row.id) || isDerivedRow(row)) continue;
1347
+ if (about.has(normFactTerm(row.subject)) || about.has(normFactTerm(row.object))) background += 1;
1348
+ }
1349
+
1350
+ return { claims, background, headlineMentions, thin: claims === 0 && background === 0 };
1351
+ }
1352
+
1353
+ /** How a feed orders two cards once their build moment is equal: the one that
1354
+ * reports more about the world first, then the one the graph holds more
1355
+ * around, then id. Reports outrank background on purpose — a card leads with
1356
+ * what a source said, and a hub whose common-noun object the seed graph
1357
+ * happens to know hundreds of edges about does not outrank a card carrying
1358
+ * more news. */
1359
+ function bySubstance(a, b) {
1360
+ return (b.substance.claims - a.substance.claims) || (b.substance.background - a.substance.background);
1361
+ }
1362
+
1054
1363
  // ---------------------------------------------------------------------------
1055
1364
  // Which story a card tells, and what it is called.
1056
1365
  // ---------------------------------------------------------------------------
@@ -1328,7 +1637,12 @@ function cardArticleTerms(sources, articleEntityNames, { concepts, readsAsEntity
1328
1637
  }
1329
1638
 
1330
1639
  /** newsworthyHubs -> one item per hub (PLAN_NEWS_FEED.md section 6.6),
1331
- * paragraph included, sorted builtAt desc then id asc. `sourcesByFactId`
1640
+ * paragraph included, sorted builtAt desc, then by what the card carries
1641
+ * (`cardSubstance` through `bySubstance`), then id asc — every card of one
1642
+ * build shares a `builtAt`, so substance is what actually orders a feed, and
1643
+ * a card whose only fact restates its own headline reads after every card
1644
+ * with something to say. Nothing is dropped for being thin: each item carries
1645
+ * its own `substance` count instead. `sourcesByFactId`
1332
1646
  * maps fact ids to snapshot source links ({ title, url, name, publishedAt?
1333
1647
  * }); publishedAt is present only when the source snapshot carried one.
1334
1648
  * The gate (PLAN_NEWS_FEED.md section 17): `reportedRows` replaces `newsWindowRows`
@@ -1359,38 +1673,64 @@ function cardArticleTerms(sources, articleEntityNames, { concepts, readsAsEntity
1359
1673
  * name inside the headline now reaches the card, where before only the
1360
1674
  * endpoints of its own facts did. They never widen its report — the walk they
1361
1675
  * seed excludes every reported row — so what a card claims a source said is
1362
- * untouched. */
1676
+ * untouched. They also carry the card's sense when its hub is a phrase the
1677
+ * bands never place: the hub walk then keeps to the senses of the names the
1678
+ * article uses, where before it kept to nothing. */
1363
1679
  export function buildNewsItems(rows, {
1364
1680
  now, windowMs, limit = 6, sourcesByFactId = new Map(), readsAsEntityTerm, articleEntityNames = null,
1365
1681
  } = {}) {
1366
- const reported = reportedRows(rows, { now, windowMs });
1682
+ // Every lane below reads `claimRows`, never `rows`: the hub gate, the
1683
+ // adjacency index both walks share, the prior/concept/sense reads, the
1684
+ // sentences, and each card's own `factIds`. An attribution says something
1685
+ // about a row rather than about the world, and a lane handed one can score it
1686
+ // as a hub and head a card with a hex id.
1687
+ const { claims: claimRows, speakersByClaimId } = partitionAttributions(rows);
1688
+ const reported = reportedRows(claimRows, { now, windowMs });
1367
1689
  const reportedIds = new Set(reported.map((r) => r.id));
1368
- const adjacency = buildTermAdjacency(rows);
1369
- const prior = priorTerms(rows);
1690
+ const adjacency = buildTermAdjacency(claimRows);
1691
+ const prior = priorTerms(claimRows);
1370
1692
  const hubOptions = { now, windowMs, limit, adjacency, prior };
1371
1693
  if (readsAsEntityTerm) hubOptions.readsAsEntityTerm = readsAsEntityTerm;
1372
1694
  const rowsByTerm = reportedRowsByTerm(reported);
1373
- const hubs = titledHubs(newsworthyHubs(rows, reported, hubOptions), rows, reported, rowsByTerm);
1695
+ const hubs = titledHubs(newsworthyHubs(claimRows, reported, hubOptions), claimRows, reported, rowsByTerm);
1374
1696
  const coverage = storyCoverage(hubs, rowsByTerm);
1375
- const concepts = conceptTerms(rows);
1697
+ const concepts = conceptTerms(claimRows);
1376
1698
  const namesEntities = readsAsEntityTerm || looksLikeEntityTerm;
1699
+ const senseScope = buildSenseScope(claimRows);
1377
1700
  const items = hubs.filter(({ term }) => coverage.get(term).mints).map(({ term, changed }) => {
1378
1701
  const coveredRowIds = coverage.get(term).coveredRowIds;
1379
- const hubRows = subgraphAround(rows, term, {
1702
+ const seeds = hubSeedTerms(term);
1703
+ const hubWalk = {
1380
1704
  adjacency,
1381
1705
  priorityIds: reportedIds,
1382
- seedTerms: hubSeedTerms(term),
1706
+ seedTerms: seeds,
1383
1707
  excludeIds: coveredRowIds,
1384
- });
1385
- const sources = collectSources(hubReportRows(term, hubRows, { reportedIds }), sourcesByFactId);
1708
+ };
1709
+ // The card's sources have to be read before its sense can be chosen, and a
1710
+ // walk that admits no background at all is enough to read them: a report on
1711
+ // the hub sits on a seed, and every scope admits those. So this pass and
1712
+ // the real one below hand `hubReportRows` the same rows.
1713
+ const reportedRowsOnly = subgraphAround(claimRows, term, { ...hubWalk, inSense: () => false });
1714
+ const sources = collectSources(hubReportRows(term, reportedRowsOnly, { reportedIds }), sourcesByFactId);
1386
1715
  const articleTerms = articleEntityNames
1387
1716
  ? cardArticleTerms(sources, articleEntityNames, { concepts, readsAsEntityTerm: namesEntities })
1388
1717
  : [];
1718
+ // A hub the bands never place has no sense of its own, so a scope anchored
1719
+ // on it refuses nothing and the walk fills the card with whatever it meets.
1720
+ // The entities the article names carry the card's sense instead, so its
1721
+ // background stays tied to its own text. When the bands place none of those
1722
+ // names either, the card keeps to the unplaced and reads sparse, which is
1723
+ // the price of not filling a card about a coined phrase with strays.
1724
+ const hubSense = senseScope.hasPlacedSense(seeds)
1725
+ ? senseScope.sameSenseAs(seeds)
1726
+ : senseScope.sameSenseAs([...seeds, ...articleTerms], { admitAllWhenUnplaced: false });
1727
+ const hubRows = subgraphAround(claimRows, term, { ...hubWalk, inSense: hubSense });
1389
1728
  const heldIds = new Set(hubRows.map((r) => r.id));
1390
1729
  const articleRows = articleTerms.length
1391
- ? articleEntityRows(rows, articleTerms, {
1730
+ ? articleEntityRows(claimRows, articleTerms, {
1392
1731
  adjacency,
1393
1732
  excludeIds: new Set([...coveredRowIds, ...reportedIds]),
1733
+ inSense: senseScope.sameSenseAs(articleTerms),
1394
1734
  }).filter((r) => !heldIds.has(r.id))
1395
1735
  : [];
1396
1736
  const subgraphRows = articleRows.length ? [...hubRows, ...articleRows] : hubRows;
@@ -1401,15 +1741,18 @@ export function buildNewsItems(rows, {
1401
1741
  hub: term,
1402
1742
  factIds,
1403
1743
  changedCount: changed,
1744
+ substance: cardSubstance(term, subgraphRows, {
1745
+ reportedIds, articleTerms, headlines: sources.map((s) => s.title),
1746
+ }),
1404
1747
  builtAt: now,
1405
- paragraph: renderNewsParagraph(term, subgraphRows, { reportedIds, articleTerms }),
1748
+ paragraph: renderNewsParagraph(term, subgraphRows, { reportedIds, articleTerms, speakersByClaimId }),
1406
1749
  tier: tierOf(subgraphRows),
1407
1750
  sources,
1408
1751
  background: background.map((r) => r.id).sort(),
1409
- backgroundParagraph: renderKnownFactsParagraph(term, subgraphRows, { reportedIds, articleTerms }),
1752
+ backgroundParagraph: renderKnownFactsParagraph(term, subgraphRows, { reportedIds, articleTerms, speakersByClaimId }),
1410
1753
  };
1411
1754
  });
1412
- return items.sort((a, b) => (toMs(b.builtAt) - toMs(a.builtAt)) || byId(a, b));
1755
+ return items.sort((a, b) => (toMs(b.builtAt) - toMs(a.builtAt)) || bySubstance(a, b) || byId(a, b));
1413
1756
  }
1414
1757
 
1415
1758
  // A `news:` tag, matched wherever it sits in a fact's provenance — bare
@@ -1422,19 +1765,42 @@ export function buildNewsItems(rows, {
1422
1765
  // is the deliberate exclusion the fixture-replay rows need.
1423
1766
  const NEWS_PROVENANCE_RE = /(?:^|[:|]\s*)news:/;
1424
1767
 
1425
- /** News-tagged fact ids to retract, oldest observedAt first, ties by id —
1426
- * the eviction the service applies at ingest time so the graph cannot grow
1427
- * past `cap` unattended. Never selects a seed/taught/research/fixture-
1428
- * replay row. */
1768
+ /** News-tagged fact ids to retract, oldest observation first, ties by id — the
1769
+ * eviction the service applies at ingest time so the graph cannot grow past
1770
+ * `cap` unattended. Never selects a seed/taught/research/fixture-replay row.
1771
+ *
1772
+ * A claim and the attributions naming it evict as ONE unit. They carry the same
1773
+ * news tag and the same stamp but not the same id, so choosing row by row
1774
+ * routinely kept one half and dropped the other, leaving a speaker with no
1775
+ * claim or a claim whose surface can no longer say who said it. A unit that
1776
+ * straddles the cap goes whole: the graph lands under `cap`, never on half a
1777
+ * pair.
1778
+ *
1779
+ * The stamp comes from `rowObservedMs`, which is where a read row actually
1780
+ * carries it — `readFactRows` keeps observedAt on the assertion records, so
1781
+ * reading `row.observedAt` scored every real news row 0 and left the cap
1782
+ * evicting by id order. */
1429
1783
  export function evictNewsFacts(rows, { cap }) {
1430
1784
  const newsRows = rows.filter((r) => NEWS_PROVENANCE_RE.test(String(r.provenance || "")));
1431
1785
  if (newsRows.length <= cap) return [];
1432
- const sorted = newsRows.slice().sort((a, b) => {
1433
- const at = toMs(a.observedAt || "");
1434
- const bt = toMs(b.observedAt || "");
1435
- const an = Number.isFinite(at) ? at : 0;
1436
- const bn = Number.isFinite(bt) ? bt : 0;
1437
- return an - bn || byId(a, b);
1438
- });
1439
- return sorted.slice(0, newsRows.length - cap).map((r) => r.id);
1786
+
1787
+ const units = new Map();
1788
+ for (const row of newsRows) {
1789
+ const key = referencedFactId(row) || row.id;
1790
+ let unit = units.get(key);
1791
+ if (!unit) units.set(key, (unit = { key, ids: [], observedMs: Infinity }));
1792
+ unit.ids.push(row.id);
1793
+ const t = rowObservedMs(row);
1794
+ unit.observedMs = Math.min(unit.observedMs, Number.isFinite(t) ? t : 0);
1795
+ }
1796
+
1797
+ const target = newsRows.length - cap;
1798
+ const evicted = [];
1799
+ const oldestFirst = [...units.values()]
1800
+ .sort((a, b) => a.observedMs - b.observedMs || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
1801
+ for (const unit of oldestFirst) {
1802
+ if (evicted.length >= target) break;
1803
+ evicted.push(...unit.ids.slice().sort());
1804
+ }
1805
+ return evicted;
1440
1806
  }