@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.
@@ -527,9 +527,13 @@ export function hubSeedTerms(hub) {
527
527
  * `seedTerms` starts the walk from more than the hub itself (hubSeedTerms).
528
528
  * Everything downstream that asks "is this the hub" — the report sentences,
529
529
  * the sources, the neighbourhood — still reads the hub term alone, so a seed
530
- * widens only what the card can draw background from. */
530
+ * widens only what the card can draw background from.
531
+ *
532
+ * `excludeIds` drops rows before the cap rather than after it, so a card that
533
+ * gives a report away to another card (storyCoverage) spends the freed budget
534
+ * on rows it will actually show. */
531
535
  export function subgraphAround(rows, hub, {
532
- hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null, seedTerms = null,
536
+ hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null, seedTerms = null, excludeIds = null,
533
537
  } = {}) {
534
538
  const adj = adjacency ?? buildTermAdjacency(rows);
535
539
  const hubTerm = normFactTerm(hub);
@@ -538,11 +542,13 @@ export function subgraphAround(rows, hub, {
538
542
  let frontier = [...new Set(seeds)].sort();
539
543
  const collected = new Map();
540
544
  const hopOf = new Map();
545
+ const isExcluded = excludeIds instanceof Set ? (id) => excludeIds.has(id) : () => false;
541
546
  for (let hop = 0; hop < hops; hop += 1) {
542
547
  const nextFrontier = new Set();
543
548
  for (const term of [...frontier].sort()) {
544
549
  for (const idx of adj.byTerm.get(term) ?? []) {
545
550
  const row = rows[idx];
551
+ if (isExcluded(row.id)) continue;
546
552
  collected.set(row.id, row);
547
553
  if (!hopOf.has(row.id)) hopOf.set(row.id, hop);
548
554
  const [s, o] = adj.terms[idx];
@@ -561,6 +567,32 @@ export function subgraphAround(rows, hub, {
561
567
  .slice(0, cap);
562
568
  }
563
569
 
570
+ // How far the walk goes from an entity the ARTICLE names rather than a fact,
571
+ // and how many rows it may bring back. One hop: what the graph says about that
572
+ // entity itself, never what it says about everything that entity touches. The
573
+ // hub's own two-hop walk is unchanged; this one runs beside it.
574
+ const ARTICLE_ENTITY_HOPS = 1;
575
+ const ARTICLE_ENTITY_ROW_CAP = 24;
576
+
577
+ /** The rows sitting one hop from an entity the card's article names. Seeded
578
+ * from `terms` rather than the hub, so a definition the graph holds about a
579
+ * name inside the headline reaches the card even when no fact of the card's
580
+ * own touches that name — "amigados is a disk operating system" beside a
581
+ * report whose only fact is that a site discussed the headline.
582
+ *
583
+ * `excludeIds` carries the card's reported rows as well as the ones another
584
+ * card claimed, so this walk returns background and nothing else: what a
585
+ * source reported is the hub walk's business. */
586
+ export function articleEntityRows(rows, terms, {
587
+ adjacency = null, excludeIds = null, cap = ARTICLE_ENTITY_ROW_CAP,
588
+ } = {}) {
589
+ const seedTerms = (terms || []).map((term) => normFactTerm(term)).filter(Boolean);
590
+ if (!seedTerms.length) return [];
591
+ return subgraphAround(rows, seedTerms[0], {
592
+ hops: ARTICLE_ENTITY_HOPS, cap, adjacency, seedTerms, excludeIds,
593
+ });
594
+ }
595
+
564
596
  /** The strongest prior kind among `rows`, for the item's trust chip — read
565
597
  * off each row's own `trust` (a number) and `sourceTypes` (the kind array
566
598
  * readFactRows already computes), never a re-derivation of SOURCE_PRIOR. */
@@ -768,40 +800,58 @@ function identityFans(subgraphRows) {
768
800
  return { senseFan, sourcedSenseFan, categoryFan };
769
801
  }
770
802
 
771
- /** What this card is ABOUT: its hub, the region of a hub the source spelled
772
- * "settlement, region", and the other terms its own report sentences name
773
- * minus any of those that reads across senses. A quake card's report
803
+ // Where a background row's anchor term came from, and so which rows the
804
+ // disclosure names first: the card's own hub, then a term its report names,
805
+ // then an entity its article names that no fact of the card's own touches.
806
+ const ANCHOR_RANK_HUB = 0;
807
+ const ANCHOR_RANK_REPORT = 1;
808
+ const ANCHOR_RANK_ARTICLE = 2;
809
+
810
+ /** What this card is ABOUT, each term mapped to its anchor rank: its hub, the
811
+ * region of a hub the source spelled "settlement, region", the other terms its
812
+ * own report sentences name, and the entities its article names (`articleTerms`)
813
+ * — minus any of those that reads across senses. A quake card's report
774
814
  * names "earthquake" and a place; the class term is where the graph's
775
815
  * knowledge is thinnest and its senses widest, so background drawn through it
776
816
  * is background about earthquakes in general, never about this quake. The hub
777
- * itself is always in, whatever its sense count — the card is about it. */
778
- function cardSubjectTerms(hub, subgraphRows, { reportedIds = null, senseFan }) {
817
+ * itself is always in, whatever its sense count — the card is about it.
818
+ *
819
+ * An article entity earns the same reading a report term gets, one rank
820
+ * behind it: the story's own words name it, but no fact the card reports
821
+ * does. */
822
+ function cardSubjectTerms(hub, subgraphRows, { reportedIds = null, senseFan, articleTerms = [] }) {
779
823
  const hubTerm = normFactTerm(hub);
780
824
  const isReported = idMembership(reportedIds);
781
- const terms = new Set(hubSeedTerms(hubTerm));
825
+ const terms = new Map();
826
+ for (const seed of hubSeedTerms(hubTerm)) terms.set(seed, ANCHOR_RANK_HUB);
827
+ const admit = (raw, rank) => {
828
+ const term = normFactTerm(raw);
829
+ if (!term || terms.has(term) || STOP_SET.has(term)) return;
830
+ if ((senseFan.get(term) || 0) > IDENTITY_MAX_CLASSES) return;
831
+ terms.set(term, rank);
832
+ };
782
833
  for (const row of hubReportRows(hubTerm, subgraphRows, { reportedIds })) {
783
834
  if (!isReported(row.id)) continue;
784
- for (const raw of [row.subject, row.object]) {
785
- const term = normFactTerm(raw);
786
- if (!term || terms.has(term) || STOP_SET.has(term)) continue;
787
- if ((senseFan.get(term) || 0) > IDENTITY_MAX_CLASSES) continue;
788
- terms.add(term);
789
- }
835
+ for (const raw of [row.subject, row.object]) admit(raw, ANCHOR_RANK_REPORT);
790
836
  }
837
+ for (const raw of articleTerms) admit(raw, ANCHOR_RANK_ARTICLE);
791
838
  return terms;
792
839
  }
793
840
 
794
841
  /** The background rows worth telling a reader about, ranked: a row touching
795
842
  * one of this card's own subject terms, whose other side is not a category
796
843
  * node, and — for an identity row — whose subject is not read across senses.
797
- * Ranks the hub's own rows first, then by how specific the other side is,
798
- * then by content-addressed id, so the same fact set always yields the same
799
- * lines in the same order. */
800
- export function knownFactRows(hub, subgraphRows, { reportedIds = null, limit = KNOWN_FACT_ROW_LIMIT } = {}) {
844
+ * Ranks the hub's own rows first, then the ones its report names, then the
845
+ * ones an entity in its article names, then by how specific the other side
846
+ * is, then by content-addressed id, so the same fact set always yields the
847
+ * same lines in the same order. */
848
+ export function knownFactRows(hub, subgraphRows, {
849
+ reportedIds = null, limit = KNOWN_FACT_ROW_LIMIT, articleTerms = [],
850
+ } = {}) {
801
851
  const hubTerm = normFactTerm(hub);
802
852
  const isReported = idMembership(reportedIds);
803
853
  const { senseFan, sourcedSenseFan, categoryFan } = identityFans(subgraphRows);
804
- const subjects = cardSubjectTerms(hub, subgraphRows, { reportedIds, senseFan });
854
+ const subjects = cardSubjectTerms(hub, subgraphRows, { reportedIds, senseFan, articleTerms });
805
855
  const neighbourIds = new Set(neighbourRows(hub, subgraphRows, { reportedIds }).map((r) => r.id));
806
856
 
807
857
  const scored = [];
@@ -822,14 +872,14 @@ export function knownFactRows(hub, subgraphRows, { reportedIds = null, limit = K
822
872
  if ((categoryFan.get(other) || 0) > CATEGORY_FAN_MAX) continue;
823
873
  scored.push({
824
874
  row,
825
- anchorIsHub: anchor === hubTerm,
875
+ anchorRank: subjects.get(anchor),
826
876
  otherCategoryFan: categoryFan.get(other) || 0,
827
877
  otherSenseFan: senseFan.get(other) || 0,
828
878
  });
829
879
  }
830
880
 
831
881
  return scored
832
- .sort((a, b) => (Number(b.anchorIsHub) - Number(a.anchorIsHub))
882
+ .sort((a, b) => (a.anchorRank - b.anchorRank)
833
883
  || (a.otherCategoryFan - b.otherCategoryFan)
834
884
  || (a.otherSenseFan - b.otherSenseFan)
835
885
  || byId(a.row, b.row))
@@ -856,30 +906,49 @@ function predicatesInRenderOrder(rows) {
856
906
 
857
907
  /** One sentence per (subject, predicate) group over `rows`, in the order the
858
908
  * rows arrive — the same shape the hub's own relation sentences take, so a
859
- * background line reads like the rest of the paragraph rather than a dump. */
860
- function groupedFactSentences(rows) {
909
+ * background line reads like the rest of the paragraph rather than a dump.
910
+ * Each entry carries the group's own rows alongside its text, so a caller
911
+ * that needs to know which facts a sentence came from (the bench's noisy-
912
+ * line scoring) reads them off the same grouping the sentence itself used,
913
+ * never a second derivation of it. */
914
+ function groupedFactSentenceEntries(rows) {
861
915
  const groups = new Map();
862
916
  for (const row of rows) {
863
917
  const key = `${row.subject}${row.predicate}`;
864
918
  let group = groups.get(key);
865
- if (!group) groups.set(key, (group = { subject: row.subject, predicate: row.predicate, objects: [] }));
919
+ if (!group) groups.set(key, (group = { subject: row.subject, predicate: row.predicate, objects: [], rows: [] }));
866
920
  group.objects.push(row.object);
921
+ group.rows.push(row);
867
922
  }
868
- return [...groups.values()].map(({ subject, predicate, objects }) => {
923
+ return [...groups.values()].map(({ subject, predicate, objects, rows: groupRows }) => {
869
924
  const sorted = [...objects].sort();
870
- const rendered = IDENTITY_PREDICATES.has(predicate)
871
- ? sorted.map((object) => `${articleFor(object)} ${object}`)
872
- : sorted;
873
- return `${subject} ${predicatePhrase(predicate, subject)} ${joinObjects(rendered)}`;
925
+ // "rdf:type" reads "is a" from the curated phrase table, which is the wrong
926
+ // article before a vowel and a second one in front of an object articleFor
927
+ // has already spelled. The identity clause the paragraph opens with says a
928
+ // bare "is" and lets articleFor choose; a background line says it the same
929
+ // way. Every other predicate, "is a kind of" included, carries whatever
930
+ // article it needs inside its own phrase and takes the object bare.
931
+ const text = predicate === "rdf:type"
932
+ ? `${subject} is ${joinObjects(sorted.map((object) => `${articleFor(object)} ${object}`))}`
933
+ : `${subject} ${predicatePhrase(predicate, subject)} ${joinObjects(sorted)}`;
934
+ return { text, rows: groupRows };
874
935
  });
875
936
  }
876
937
 
938
+ function groupedFactSentences(rows) {
939
+ return groupedFactSentenceEntries(rows).map((entry) => entry.text);
940
+ }
941
+
877
942
  /** The sentences a card's paragraph is made of, as four ordered blocks: the
878
943
  * report (what a source said inside the window), the identity clause, the
879
- * related facts the graph already held, and the neighbourhood. Callers that
880
- * render only one block — the "what the graph already knew" disclosure
881
- * read the block they want instead of re-deriving it. */
882
- function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
944
+ * related facts the graph already held, and the neighbourhood. Each entry is
945
+ * `{ text, rows }` — the rendered sentence and the fact row(s) it came from
946
+ * so a caller that needs to know which facts actually reached the printed
947
+ * text (the bench's noisy-line scoring) reads them off the same blocks
948
+ * `renderNewsParagraph` itself slices, never a second derivation of it.
949
+ * Callers that render only one block — the "what the graph already knew"
950
+ * disclosure — read the block they want instead of re-deriving it. */
951
+ function paragraphBlocks(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
883
952
  const hubTerm = normFactTerm(hub);
884
953
  const isReported = idMembership(reportedIds);
885
954
  const hubRows = subgraphRows.filter((r) => normFactTerm(r.subject) === hubTerm);
@@ -887,12 +956,10 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
887
956
  const report = [];
888
957
  for (const predicate of predicatesInRenderOrder(reportedHubRows)) {
889
958
  if (IDENTITY_PREDICATES.has(predicate) || report.length >= REPORT_SENTENCE_CAP) continue;
890
- const objects = reportedHubRows
891
- .filter((r) => r.predicate === predicate)
892
- .map((r) => r.object)
893
- .sort();
959
+ const groupRows = reportedHubRows.filter((r) => r.predicate === predicate);
960
+ const objects = groupRows.map((r) => r.object).sort();
894
961
  if (!objects.length) continue;
895
- report.push(`${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`);
962
+ report.push({ text: `${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`, rows: groupRows });
896
963
  }
897
964
 
898
965
  // A hub that only ever appears as an OBJECT — the place a quake struck, the
@@ -900,12 +967,13 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
900
967
  // and its card came out blank. What was reported about it still says
901
968
  // something, so those rows render whole, subject and all.
902
969
  if (!report.length) {
903
- const aboutHub = subgraphRows
970
+ const aboutHubRows = subgraphRows
904
971
  .filter((r) => normFactTerm(r.object) === hubTerm && normFactTerm(r.subject) !== hubTerm && isReported(r.id))
905
972
  .sort(byId)
906
- .slice(0, OBJECTS_PER_SENTENCE)
907
- .map((r) => factSentence(r));
908
- if (aboutHub.length) report.push(aboutHub.join("; "));
973
+ .slice(0, OBJECTS_PER_SENTENCE);
974
+ if (aboutHubRows.length) {
975
+ report.push({ text: aboutHubRows.map((r) => factSentence(r)).join("; "), rows: aboutHubRows });
976
+ }
909
977
  }
910
978
 
911
979
  // The identity clause follows the news, never leads it, and only when
@@ -913,23 +981,41 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
913
981
  // closure names thirteen classes for "france" — a list nobody asked for,
914
982
  // most of it the wrong sense — so it opens no card.
915
983
  const identity = [];
916
- const identityObjects = hubRows
917
- .filter((r) => IDENTITY_PREDICATES.has(r.predicate) && !isDerivedRow(r))
918
- .map((r) => r.object)
919
- .sort();
984
+ const identityRows = hubRows.filter((r) => IDENTITY_PREDICATES.has(r.predicate) && !isDerivedRow(r));
985
+ const identityObjects = identityRows.map((r) => r.object).sort();
920
986
  const identityIsSingleSense = identityObjects.length > 0 && identityObjects.length <= IDENTITY_MAX_CLASSES;
921
987
  if (identityIsSingleSense) {
922
- identity.push(`${hub} is ${joinObjects(identityObjects.map((object) => `${articleFor(object)} ${object}`))}`);
988
+ identity.push({
989
+ text: `${hub} is ${joinObjects(identityObjects.map((object) => `${articleFor(object)} ${object}`))}`,
990
+ rows: identityRows,
991
+ });
923
992
  }
924
993
 
925
- const known = groupedFactSentences(knownFactRows(hub, subgraphRows, { reportedIds }));
994
+ const known = groupedFactSentenceEntries(knownFactRows(hub, subgraphRows, { reportedIds, articleTerms }));
926
995
 
927
996
  const neighbours = neighbourRows(hub, subgraphRows, { reportedIds });
928
- const around = neighbours.length ? [`Around it: ${neighbours.map((r) => factSentence(r)).join("; ")}`] : [];
997
+ const around = neighbours.length
998
+ ? [{ text: `Around it: ${neighbours.map((r) => factSentence(r)).join("; ")}`, rows: neighbours }]
999
+ : [];
929
1000
 
930
1001
  return { report, identity, known, around };
931
1002
  }
932
1003
 
1004
+ /** The paragraph's own sentence entries (`{ text, rows }`), in print order and
1005
+ * sliced to exactly what `renderNewsParagraph` shows — the per-block caps
1006
+ * (identity, known) and the paragraph-wide `SENTENCE_CAP` both applied.
1007
+ * Shared by `renderNewsParagraph` and `printedParagraphRows` so the two can
1008
+ * 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 });
1011
+ return [
1012
+ ...report,
1013
+ ...identity.slice(0, IDENTITY_SENTENCE_CAP),
1014
+ ...known.slice(0, KNOWN_FACT_SENTENCE_CAP),
1015
+ ...around,
1016
+ ].slice(0, SENTENCE_CAP);
1017
+ }
1018
+
933
1019
  /** A card's paragraph: what a source reported, then what the thing is, then
934
1020
  * the related facts the graph already held about it, then its own
935
1021
  * neighbourhood (neighbourRows). Every sentence shown is a grounded fact,
@@ -940,27 +1026,307 @@ function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
940
1026
  * into what was reported (the lead sentences) and what the graph already held
941
1027
  * (the background ones). Defaults to null, meaning every row counts as
942
1028
  * reported. */
943
- export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } = {}) {
944
- const { report, identity, known, around } = paragraphBlocks(hub, subgraphRows, { reportedIds });
945
- const sentences = [
946
- ...report,
947
- ...identity.slice(0, IDENTITY_SENTENCE_CAP),
948
- ...known.slice(0, KNOWN_FACT_SENTENCE_CAP),
949
- ...around,
950
- ].slice(0, SENTENCE_CAP);
1029
+ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
1030
+ const sentences = paragraphSentenceEntries(hub, subgraphRows, { reportedIds, articleTerms }).map((entry) => entry.text);
951
1031
  return sentences.length ? `${sentences.join(". ")}.` : "";
952
1032
  }
953
1033
 
1034
+ /** Every fact row that survives into the card's rendered main paragraph —
1035
+ * the same rows `renderNewsParagraph` drew its sentences from, after every
1036
+ * cap it applies (per-block and the paragraph-wide `SENTENCE_CAP`). A row
1037
+ * computed but sliced away before render (an "Around it" clause cut by the
1038
+ * overall cap, an identity class beyond `IDENTITY_MAX_CLASSES`) never
1039
+ * 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);
1042
+ }
1043
+
954
1044
  /** The "what the graph already knew" disclosure: the same related facts the
955
1045
  * paragraph leads with, at the disclosure's own fuller depth, and nothing the
956
1046
  * card already reported. Empty when the graph held nothing about this card's
957
1047
  * own subjects — a card with no background says so rather than filling the
958
1048
  * space with whatever the two-hop walk happened to reach. */
959
- export function renderKnownFactsParagraph(hub, subgraphRows, { reportedIds = null } = {}) {
960
- const sentences = groupedFactSentences(knownFactRows(hub, subgraphRows, { reportedIds }));
1049
+ export function renderKnownFactsParagraph(hub, subgraphRows, { reportedIds = null, articleTerms = [] } = {}) {
1050
+ const sentences = groupedFactSentences(knownFactRows(hub, subgraphRows, { reportedIds, articleTerms }));
961
1051
  return sentences.length ? `${sentences.join(". ")}.` : "";
962
1052
  }
963
1053
 
1054
+ // ---------------------------------------------------------------------------
1055
+ // Which story a card tells, and what it is called.
1056
+ // ---------------------------------------------------------------------------
1057
+
1058
+ // The item tag a news row carries, at whatever depth the ingest wrapper
1059
+ // nested it ("news:<sourceId>@<itemId>", "optimistic-extract:news:…"), plus
1060
+ // the fixture replay's own twin. Every sentence a card prints comes from a
1061
+ // row, so which story a card is telling is readable from the fact set alone —
1062
+ // no source map, no arrival order, no clock.
1063
+ const NEWS_STORY_TAG_RE = /(?:^|:)news(?:-fixture)?:([^\s|]+)/;
1064
+
1065
+ /** The newsworthy item one row reported, or "" when its provenance names
1066
+ * none. Pure. */
1067
+ export function newsStoryKey(row) {
1068
+ return NEWS_STORY_TAG_RE.exec(String(row?.provenance || ""))?.[1] || "";
1069
+ }
1070
+
1071
+ /** Every reported row touching each term, subject side or object side. Built
1072
+ * once per feed assembly and shared, for the same reason buildTermAdjacency
1073
+ * is. */
1074
+ function reportedRowsByTerm(reported) {
1075
+ const byTerm = new Map();
1076
+ for (const row of reported) {
1077
+ for (const term of new Set([normFactTerm(row.subject), normFactTerm(row.object)])) {
1078
+ if (!term) continue;
1079
+ let rowsFor = byTerm.get(term);
1080
+ if (!rowsFor) byTerm.set(term, (rowsFor = []));
1081
+ rowsFor.push(row);
1082
+ }
1083
+ }
1084
+ return byTerm;
1085
+ }
1086
+
1087
+ // How many stories a term's own reports must span before it stops being one
1088
+ // story's subject. Two is enough: a term the day's second story also names is
1089
+ // a publication, a wire desk or a class every item shares, and a card headed
1090
+ // by one repeats whatever the per-story cards already said.
1091
+ const PUBLICATION_STORY_MIN = 2;
1092
+
1093
+ /** How many distinct newsworthy items each term's own reports came from. */
1094
+ function storyCountsByTerm(rowsByTerm) {
1095
+ const counts = new Map();
1096
+ for (const [term, rowsFor] of rowsByTerm) {
1097
+ const keys = new Set();
1098
+ for (const row of rowsFor) {
1099
+ const key = newsStoryKey(row);
1100
+ if (key) keys.add(key);
1101
+ }
1102
+ counts.set(term, keys.size);
1103
+ }
1104
+ return counts;
1105
+ }
1106
+
1107
+ // The classes the graph's own identity rows use to say a thing is a place or
1108
+ // a person. The same closed set the bench's entity-preservation metric reads
1109
+ // off the seed, duplicated here (not imported) because the domain layer never
1110
+ // imports a script.
1111
+ const PLACE_OR_PERSON_CLASS_TERMS = new Set([
1112
+ "place", "person", "city", "country", "location", "nation", "continent",
1113
+ "capital", "state", "province", "town", "region",
1114
+ ]);
1115
+
1116
+ /** Every term the graph itself types as a place or a person, whoever stated
1117
+ * the identity. A card prefers one of these for its own title: it is the
1118
+ * thing the story is about, where a clause the same report threw off is only
1119
+ * something that happened to it. Pure over `rows`. */
1120
+ export function placeAndPersonTerms(rows) {
1121
+ const grounded = new Set();
1122
+ for (const row of rows) {
1123
+ if (row.predicate !== "rdf:type" && row.predicate !== "rdfs:subClassOf") continue;
1124
+ if (!PLACE_OR_PERSON_CLASS_TERMS.has(normFactTerm(row.object))) continue;
1125
+ const subject = normFactTerm(row.subject);
1126
+ if (subject) grounded.add(subject);
1127
+ }
1128
+ return grounded;
1129
+ }
1130
+
1131
+ // A predicate minted from a source's own verb, lemma only: "mgx:hit",
1132
+ // "mgx:discuss". The curated table's entries carry a capital ("mgx:partOf")
1133
+ // and a folded preposition carries a hyphen ("mgx:strike-near"), and neither
1134
+ // can be a single word inside a term, so neither belongs in the verb set.
1135
+ const MINTED_VERB_PREDICATE_RE = /^mgx:([a-z]+)$/;
1136
+
1137
+ /** The verbs this window's own reports minted a predicate from. A term
1138
+ * carrying one of them is a clause the extraction cut in half, and the graph
1139
+ * says so itself rather than a hand-written verb list saying it. */
1140
+ export function reportedVerbWords(reported) {
1141
+ const verbs = new Set();
1142
+ for (const row of reported) {
1143
+ const lemma = MINTED_VERB_PREDICATE_RE.exec(String(row.predicate || ""))?.[1];
1144
+ if (lemma) verbs.add(lemma);
1145
+ }
1146
+ return verbs;
1147
+ }
1148
+
1149
+ // How many words a name runs to before it reads as a sentence about a thing
1150
+ // rather than the thing's name. "south sandwich islands region" is a place;
1151
+ // "boats hit by mystery attackers" is what happened to some.
1152
+ const CLAUSE_TITLE_MAX_WORDS = 4;
1153
+ const CLAUSE_WORD_EDGE_RE = /^[^a-z0-9]+|[^a-z0-9]+$/g;
1154
+
1155
+ /** Does `term` read as a clause rather than a name — longer than a name runs,
1156
+ * or carrying one of the verbs the window's own reports minted? A clause
1157
+ * never takes a card's title from a grounded entity term. */
1158
+ export function readsAsClauseTerm(term, verbWords) {
1159
+ const words = String(term ?? "").trim().toLowerCase().split(/\s+/).filter(Boolean);
1160
+ if (!words.length) return false;
1161
+ if (words.length > CLAUSE_TITLE_MAX_WORDS) return true;
1162
+ const verbs = verbWords instanceof Set ? verbWords : new Set(verbWords || []);
1163
+ return words.some((word) => verbs.has(word.replace(CLAUSE_WORD_EDGE_RE, "")));
1164
+ }
1165
+
1166
+ /** The term a card is titled and keyed by, given the hub the gate chose and
1167
+ * the reported rows that hub sits in. In order:
1168
+ *
1169
+ * 1. the hub itself, when the graph already types it as a place or a person;
1170
+ * 2. a place/person term the hub's own reports name — "bali" over "sacred
1171
+ * glow", "clacton" over "election";
1172
+ * 3. the subject those reports share, but only when the hub reads as a clause
1173
+ * and only when that subject tells one story of its own — the publication
1174
+ * every headline hangs off is never a card's name;
1175
+ * 4. the hub, unchanged.
1176
+ *
1177
+ * Ties inside a step go to the term the reports name most, then alphabetical,
1178
+ * so the same fact set always titles a card the same way. Nothing here invents
1179
+ * a term: every candidate is already on one of the card's own rows. */
1180
+ export function hubTitleTerm(hubTerm, hubRows, { placeOrPerson, verbWords, storyCountByTerm }) {
1181
+ if (placeOrPerson.has(hubTerm)) return hubTerm;
1182
+
1183
+ const namesAThing = (term) => Boolean(term) && term !== hubTerm && !STOP_SET.has(term)
1184
+ && !isQuantityTerm(term) && looksLikeEntityTerm(term) && !readsAsClauseTerm(term, verbWords);
1185
+
1186
+ const groundedCounts = new Map();
1187
+ const subjectCounts = new Map();
1188
+ for (const row of hubRows) {
1189
+ const subject = normFactTerm(row.subject);
1190
+ const object = normFactTerm(row.object);
1191
+ for (const term of new Set([subject, object])) {
1192
+ if (namesAThing(term) && placeOrPerson.has(term)) groundedCounts.set(term, (groundedCounts.get(term) || 0) + 1);
1193
+ }
1194
+ if (object !== hubTerm || !namesAThing(subject)) continue;
1195
+ if ((storyCountByTerm.get(subject) || 0) >= PUBLICATION_STORY_MIN) continue;
1196
+ subjectCounts.set(subject, (subjectCounts.get(subject) || 0) + 1);
1197
+ }
1198
+
1199
+ const mostNamed = (counts) => [...counts.entries()]
1200
+ .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))[0]?.[0] || "";
1201
+
1202
+ return mostNamed(groundedCounts)
1203
+ || (readsAsClauseTerm(hubTerm, verbWords) ? mostNamed(subjectCounts) : "")
1204
+ || hubTerm;
1205
+ }
1206
+
1207
+ /** True when a source spelled this term as a settlement and its region at once
1208
+ * — "wana, pakistan", "mina, nevada". The source has already said which of a
1209
+ * report's terms is the thing that happened somewhere, so the term reads as a
1210
+ * place even where no identity row types it as one. hubSeedTerms holds the
1211
+ * same reading for the walk. */
1212
+ const namesASettlementAndRegion = (term) => hubSeedTerms(term).length > 1;
1213
+
1214
+ /** The gate's hubs retitled by hubTitleTerm, with two hubs that answer to the
1215
+ * same name merged into one. Each keeps the two readings storyCoverage ranks
1216
+ * cards by, taken on the title the card will actually wear. Keeps the gate's
1217
+ * own sort — changed count desc, then term asc. */
1218
+ function titledHubs(hubs, rows, reported, rowsByTerm) {
1219
+ const placeOrPerson = placeAndPersonTerms(rows);
1220
+ const verbWords = reportedVerbWords(reported);
1221
+ const storyCountByTerm = storyCountsByTerm(rowsByTerm);
1222
+ const changedByTitle = new Map();
1223
+ for (const { term, changed } of hubs) {
1224
+ const title = hubTitleTerm(term, rowsByTerm.get(term) || [], { placeOrPerson, verbWords, storyCountByTerm });
1225
+ const held = changedByTitle.get(title);
1226
+ if (held === undefined || changed > held) changedByTitle.set(title, changed);
1227
+ }
1228
+ return [...changedByTitle.entries()]
1229
+ .map(([term, changed]) => ({
1230
+ term,
1231
+ changed,
1232
+ namesAnEntity: placeOrPerson.has(term) || namesASettlementAndRegion(term),
1233
+ clauseShaped: readsAsClauseTerm(term, verbWords),
1234
+ reportSubject: (rowsByTerm.get(term) || []).some((row) => normFactTerm(row.subject) === term),
1235
+ }))
1236
+ .sort((a, b) => b.changed - a.changed || (a.term < b.term ? -1 : a.term > b.term ? 1 : 0));
1237
+ }
1238
+
1239
+ /** How a story picks between the cards that want to tell it. In order: the
1240
+ * hub whose own reports span fewest stories, since a card about one story
1241
+ * beats a publication's roundup of it; then the hub that names a place or a
1242
+ * person, since that is what the story is about; then the hub that
1243
+ * reads as a name rather than a clause; then the one the report puts on the
1244
+ * subject side, the story's actor where the object is what happened to it;
1245
+ * then the gate's own changed count; then the term itself, so the answer never
1246
+ * comes down to arrival order. */
1247
+ function byCardClaim(a, b) {
1248
+ return a.storyCount - b.storyCount
1249
+ || (Number(b.namesAnEntity) - Number(a.namesAnEntity))
1250
+ || (Number(a.clauseShaped) - Number(b.clauseShaped))
1251
+ || (Number(b.reportSubject) - Number(a.reportSubject))
1252
+ || (b.changed - a.changed)
1253
+ || (a.term < b.term ? -1 : a.term > b.term ? 1 : 0);
1254
+ }
1255
+
1256
+ /** Which stories each hub gets to tell, and which of its reports belong to
1257
+ * another card. One story mints one card: the hubs bid for it in byCardClaim
1258
+ * order and the first takes it, so a report that threw off both a subject hub
1259
+ * and an object hub ("ukraine" beside "air war", "london" beside "glass")
1260
+ * stops minting a card each. A hub left with no story of its own does not
1261
+ * mint, and one that keeps some carries only those — the "hackernews" card
1262
+ * that repeated both of the day's Hacker News cards wholesale is gone, and one
1263
+ * that repeated three of four carries the fourth alone.
1264
+ *
1265
+ * A row whose provenance names no story is never claimed and never dropped, so
1266
+ * a hub built only from those still mints.
1267
+ *
1268
+ * Returns hub term -> `{ mints, coveredRowIds }`. */
1269
+ export function storyCoverage(hubs, rowsByTerm) {
1270
+ const rowIdsByStory = new Map();
1271
+ for (const { term } of hubs) {
1272
+ const byStory = new Map();
1273
+ for (const row of rowsByTerm.get(term) || []) {
1274
+ const key = newsStoryKey(row);
1275
+ if (!key) continue;
1276
+ let ids = byStory.get(key);
1277
+ if (!ids) byStory.set(key, (ids = []));
1278
+ ids.push(row.id);
1279
+ }
1280
+ rowIdsByStory.set(term, byStory);
1281
+ }
1282
+
1283
+ const bidders = hubs
1284
+ .map((hub) => ({ ...hub, storyCount: rowIdsByStory.get(hub.term).size }))
1285
+ .sort(byCardClaim);
1286
+
1287
+ const claimed = new Set();
1288
+ const coverage = new Map();
1289
+ for (const { term, storyCount } of bidders) {
1290
+ const stories = rowIdsByStory.get(term);
1291
+ const coveredRowIds = new Set();
1292
+ let ownStories = 0;
1293
+ for (const key of [...stories.keys()].sort()) {
1294
+ if (claimed.has(key)) {
1295
+ for (const id of stories.get(key)) coveredRowIds.add(id);
1296
+ continue;
1297
+ }
1298
+ claimed.add(key);
1299
+ ownStories += 1;
1300
+ }
1301
+ coverage.set(term, { mints: storyCount === 0 || ownStories > 0, coveredRowIds });
1302
+ }
1303
+ return coverage;
1304
+ }
1305
+
1306
+ /** The entities this card's own article names, as terms the graph could hold
1307
+ * facts about. `articleEntityNames` reads them out of the text the card
1308
+ * already shows — each source's headline and the description beneath it — and
1309
+ * the same discipline the hub gate applies then filters them: no stop word, no
1310
+ * quantity, no class the graph's own identity rows name, nothing that reads as
1311
+ * a clause rather than a name. Sorted, so a card's background never depends on
1312
+ * the order the names came back in. */
1313
+ function cardArticleTerms(sources, articleEntityNames, { concepts, readsAsEntityTerm }) {
1314
+ const texts = [];
1315
+ for (const source of sources) {
1316
+ if (source.title) texts.push(source.title);
1317
+ if (source.summary) texts.push(source.summary);
1318
+ }
1319
+ if (!texts.length) return [];
1320
+ const terms = new Set();
1321
+ for (const name of articleEntityNames(texts) || []) {
1322
+ const term = normFactTerm(name);
1323
+ if (!term || STOP_SET.has(term) || isQuantityTerm(term) || concepts.has(term)) continue;
1324
+ if (!readsAsEntityTerm(term)) continue;
1325
+ terms.add(term);
1326
+ }
1327
+ return [...terms].sort();
1328
+ }
1329
+
964
1330
  /** newsworthyHubs -> one item per hub (PLAN_NEWS_FEED.md section 6.6),
965
1331
  * paragraph included, sorted builtAt desc then id asc. `sourcesByFactId`
966
1332
  * maps fact ids to snapshot source links ({ title, url, name, publishedAt?
@@ -977,19 +1343,57 @@ export function renderKnownFactsParagraph(hub, subgraphRows, { reportedIds = nul
977
1343
  * nothing else. The two-hop walk reaches every row a shared class node
978
1344
  * touches, so attributing the whole sub-graph gave one quake's card all 44 of
979
1345
  * the day's quake headlines. An "Around it" neighbour is context the card
980
- * borrows, and it carries its own citation on its own card. */
981
- export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId = new Map(), readsAsEntityTerm } = {}) {
1346
+ * borrows, and it carries its own citation on its own card.
1347
+ *
1348
+ * Between the gate and the render sit two more reads over the same reported
1349
+ * rows: `titledHubs` names each card after the entity its own report grounds
1350
+ * rather than a clause the report threw off, and `storyCoverage` leaves a
1351
+ * publication with only the stories no other card tells. Both are pure over
1352
+ * the fact set, so a feed built from the same rows in two orders still comes
1353
+ * back byte for byte.
1354
+ *
1355
+ * `articleEntityNames`, when the caller supplies one, reads the entity names
1356
+ * out of the text of the sources a card shows (news.mjs wires the services
1357
+ * layer's own capture, the same one the enrichment ledger admits terms by).
1358
+ * Those entities widen the card's background: what the graph holds about a
1359
+ * name inside the headline now reaches the card, where before only the
1360
+ * endpoints of its own facts did. They never widen its report — the walk they
1361
+ * seed excludes every reported row — so what a card claims a source said is
1362
+ * untouched. */
1363
+ export function buildNewsItems(rows, {
1364
+ now, windowMs, limit = 6, sourcesByFactId = new Map(), readsAsEntityTerm, articleEntityNames = null,
1365
+ } = {}) {
982
1366
  const reported = reportedRows(rows, { now, windowMs });
983
1367
  const reportedIds = new Set(reported.map((r) => r.id));
984
1368
  const adjacency = buildTermAdjacency(rows);
985
1369
  const prior = priorTerms(rows);
986
1370
  const hubOptions = { now, windowMs, limit, adjacency, prior };
987
1371
  if (readsAsEntityTerm) hubOptions.readsAsEntityTerm = readsAsEntityTerm;
988
- const hubs = newsworthyHubs(rows, reported, hubOptions);
989
- const items = hubs.map(({ term, changed }) => {
990
- const subgraphRows = subgraphAround(rows, term, {
991
- adjacency, priorityIds: reportedIds, seedTerms: hubSeedTerms(term),
1372
+ const rowsByTerm = reportedRowsByTerm(reported);
1373
+ const hubs = titledHubs(newsworthyHubs(rows, reported, hubOptions), rows, reported, rowsByTerm);
1374
+ const coverage = storyCoverage(hubs, rowsByTerm);
1375
+ const concepts = conceptTerms(rows);
1376
+ const namesEntities = readsAsEntityTerm || looksLikeEntityTerm;
1377
+ const items = hubs.filter(({ term }) => coverage.get(term).mints).map(({ term, changed }) => {
1378
+ const coveredRowIds = coverage.get(term).coveredRowIds;
1379
+ const hubRows = subgraphAround(rows, term, {
1380
+ adjacency,
1381
+ priorityIds: reportedIds,
1382
+ seedTerms: hubSeedTerms(term),
1383
+ excludeIds: coveredRowIds,
992
1384
  });
1385
+ const sources = collectSources(hubReportRows(term, hubRows, { reportedIds }), sourcesByFactId);
1386
+ const articleTerms = articleEntityNames
1387
+ ? cardArticleTerms(sources, articleEntityNames, { concepts, readsAsEntityTerm: namesEntities })
1388
+ : [];
1389
+ const heldIds = new Set(hubRows.map((r) => r.id));
1390
+ const articleRows = articleTerms.length
1391
+ ? articleEntityRows(rows, articleTerms, {
1392
+ adjacency,
1393
+ excludeIds: new Set([...coveredRowIds, ...reportedIds]),
1394
+ }).filter((r) => !heldIds.has(r.id))
1395
+ : [];
1396
+ const subgraphRows = articleRows.length ? [...hubRows, ...articleRows] : hubRows;
993
1397
  const factIds = subgraphRows.map((r) => r.id).sort();
994
1398
  const { background } = splitCardRows(subgraphRows, reportedIds);
995
1399
  return {
@@ -998,11 +1402,11 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
998
1402
  factIds,
999
1403
  changedCount: changed,
1000
1404
  builtAt: now,
1001
- paragraph: renderNewsParagraph(term, subgraphRows, { reportedIds }),
1405
+ paragraph: renderNewsParagraph(term, subgraphRows, { reportedIds, articleTerms }),
1002
1406
  tier: tierOf(subgraphRows),
1003
- sources: collectSources(hubReportRows(term, subgraphRows, { reportedIds }), sourcesByFactId),
1407
+ sources,
1004
1408
  background: background.map((r) => r.id).sort(),
1005
- backgroundParagraph: renderKnownFactsParagraph(term, subgraphRows, { reportedIds }),
1409
+ backgroundParagraph: renderKnownFactsParagraph(term, subgraphRows, { reportedIds, articleTerms }),
1006
1410
  };
1007
1411
  });
1008
1412
  return items.sort((a, b) => (toMs(b.builtAt) - toMs(a.builtAt)) || byId(a, b));