@polycode-projects/the-mechanical-code-talker 6.0.19 → 6.0.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -23
- package/bin/tmct.mjs +16 -33
- package/corpus/LICENSES.json +0 -21
- package/corpus/README.md +10 -13
- package/corpus/reference/manifest.json +19 -19
- package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
- package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
- package/corpus/tier2/generate.mjs +6 -142
- package/corpus/tier2/manifest.json +0 -42
- package/package.json +4 -4
- package/src/adapters/corpus/child-seed.mjs +74 -0
- package/src/adapters/corpus/conceptnet.mjs +45 -26
- package/src/adapters/memory/blocks.mjs +7 -1
- package/src/adapters/memory/core.mjs +453 -103
- package/src/adapters/memory/corpus-bands.mjs +33 -10
- package/src/adapters/memory/inspect.mjs +24 -5
- package/src/adapters/memory/rows.mjs +106 -9
- package/src/adapters/memory/shacl.mjs +10 -3
- package/src/domain/ask.mjs +27 -10
- package/src/domain/cli-verbs.mjs +3 -4
- package/src/domain/completions/group.mjs +8 -3
- package/src/domain/completions/infer.mjs +7 -2
- package/src/domain/completions/prune.mjs +5 -1
- package/src/domain/completions/rank.mjs +7 -2
- package/src/domain/digest/compose.mjs +5 -1
- package/src/domain/digest/select.mjs +12 -6
- package/src/domain/domain.mjs +15 -8
- package/src/domain/el-classify.mjs +11 -2
- package/src/domain/fact-phrase.mjs +86 -4
- package/src/domain/hash.mjs +9 -0
- package/src/domain/memory/bias.mjs +8 -4
- package/src/domain/memory/capability.mjs +12 -6
- package/src/domain/memory/fact-order.mjs +29 -0
- package/src/domain/memory/resolution.mjs +3 -0
- package/src/domain/news-feed.mjs +422 -56
- package/src/domain/reference-pack.mjs +5 -0
- package/src/domain/sense-scope.mjs +116 -0
- package/src/domain/sense-split.mjs +1 -1
- package/src/domain/syllogise.mjs +21 -13
- package/src/domain/tableau.mjs +23 -14
- package/src/domain/worlds-pack.mjs +5 -1
- package/src/services/adventure-autoplay.mjs +6 -1
- package/src/services/adventure-editor.mjs +43 -21
- package/src/services/adventure-viz.mjs +26 -9
- package/src/services/adventure.mjs +40 -10
- package/src/services/chat.mjs +253 -113
- package/src/services/extensions.mjs +51 -58
- package/src/services/extract-facts.mjs +670 -95
- package/src/services/init.mjs +4 -4
- package/src/services/ledger-viz.mjs +9 -4
- package/src/services/memory-panel-viz.mjs +4 -5
- package/src/services/mud-editor.mjs +40 -16
- package/src/services/mud-viz.mjs +8 -2
- package/src/services/mudiii-turn.mjs +5 -3
- package/src/services/mudiii-viz.mjs +8 -2
- package/src/services/news.mjs +277 -11
- package/src/services/research-viz.mjs +1 -1
- package/src/services/sprite-catalog-viz.mjs +10 -5
- package/src/surfaces/web/adventure-browser-entry.mjs +6 -12
- package/src/surfaces/web/memory-ask-browser.bundle.js +152 -151
- package/src/surfaces/web/mud-browser-entry.mjs +7 -11
- package/src/surfaces/web/research-browser-entry.mjs +5 -2
- package/corpus/tier2/aws.jsonl +0 -39
- package/corpus/tier2/java.jsonl +0 -31
- package/corpus/tier2/python.jsonl +0 -30
package/src/domain/news-feed.mjs
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
555
|
-
if (!visited.has(
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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({
|
|
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(
|
|
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
|
-
? [{
|
|
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 })
|
|
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 })
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
1369
|
-
const prior = priorTerms(
|
|
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(
|
|
1695
|
+
const hubs = titledHubs(newsworthyHubs(claimRows, reported, hubOptions), claimRows, reported, rowsByTerm);
|
|
1374
1696
|
const coverage = storyCoverage(hubs, rowsByTerm);
|
|
1375
|
-
const concepts = conceptTerms(
|
|
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
|
|
1702
|
+
const seeds = hubSeedTerms(term);
|
|
1703
|
+
const hubWalk = {
|
|
1380
1704
|
adjacency,
|
|
1381
1705
|
priorityIds: reportedIds,
|
|
1382
|
-
seedTerms:
|
|
1706
|
+
seedTerms: seeds,
|
|
1383
1707
|
excludeIds: coveredRowIds,
|
|
1384
|
-
}
|
|
1385
|
-
|
|
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(
|
|
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
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1428
|
-
*
|
|
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
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
const
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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
|
}
|