@polycode-projects/the-mechanical-code-talker 6.0.15 → 6.0.16
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycode-projects/the-mechanical-code-talker",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.16",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
|
|
@@ -118,6 +118,8 @@
|
|
|
118
118
|
"gen:screenshots": "node scripts/gen-screenshots.mjs",
|
|
119
119
|
"gen:og-images": "node scripts/gen-og-images.mjs",
|
|
120
120
|
"probe:news-sources": "node scripts/probe-news-sources.mjs",
|
|
121
|
+
"bench:news": "node scripts/news-bench/run.mjs --seed=xl",
|
|
122
|
+
"bench:news:fast": "node scripts/news-bench/run.mjs --seed=fixture",
|
|
121
123
|
"check:links": "node scripts/check-links.mjs",
|
|
122
124
|
"check:pii": "node scripts/pii-lint.mjs",
|
|
123
125
|
"check:pack": "node scripts/check-pack-manifest.mjs",
|
|
@@ -117,6 +117,41 @@ export function gerundVerbSurface(verb) {
|
|
|
117
117
|
return /[^aeiou]e$/i.test(base) && base.length > 2 ? `${base.slice(0, -1)}ing` : `${base}ing`;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** Irregular plural nouns whose surface carries no "-s" at all, so the suffix
|
|
121
|
+
* check in isSubjectPlural below would read them as singular. A small closed
|
|
122
|
+
* table, not this file's own pluralizer — it answers one question only
|
|
123
|
+
* ("is this head noun plural"), never generates a form. */
|
|
124
|
+
const IRREGULAR_PLURAL_NOUNS = new Set([
|
|
125
|
+
"people", "men", "women", "children", "mice", "geese", "feet", "teeth", "oxen",
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
/** Nouns that end in "s" but stay grammatically SINGULAR ("the news
|
|
129
|
+
* spreads", not "the news spread") — the suffix check below would otherwise
|
|
130
|
+
* misread them as plural. */
|
|
131
|
+
const SINGULAR_NOUNS_ENDING_S = new Set([
|
|
132
|
+
"news", "physics", "species", "series", "means", "measles", "mathematics", "politics", "economics",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Is a stored fact's SUBJECT text grammatically plural, for the one thing
|
|
137
|
+
* this file needs it for: choosing a minted verb's surface form. Reads the
|
|
138
|
+
* HEAD noun only — the word right after any leading article, before a
|
|
139
|
+
* trailing "of ..." phrase, so "the group of scientists" agrees on "group"
|
|
140
|
+
* ("the group of scientists reports"), not "scientists". From there: the
|
|
141
|
+
* closed irregular table above, then the regular "-s" suffix, same
|
|
142
|
+
* naive-morphology trade thirdPersonSingularSurface already takes above. A
|
|
143
|
+
* subject this can't read (empty, or a plural-invariant noun like "sheep")
|
|
144
|
+
* defaults to singular — English's own unmarked form, and also this file's
|
|
145
|
+
* pre-existing default for every caller that passes no subject at all.
|
|
146
|
+
*/
|
|
147
|
+
export function isSubjectPlural(subject) {
|
|
148
|
+
const head = String(subject || "").trim().replace(/^(?:the|a|an)\s+/i, "").split(/\s+/)[0]?.toLowerCase() ?? "";
|
|
149
|
+
if (!head) return false;
|
|
150
|
+
if (IRREGULAR_PLURAL_NOUNS.has(head)) return true;
|
|
151
|
+
if (SINGULAR_NOUNS_ENDING_S.has(head)) return false;
|
|
152
|
+
return /[a-z]s$/.test(head) && !/ss$/.test(head);
|
|
153
|
+
}
|
|
154
|
+
|
|
120
155
|
/**
|
|
121
156
|
* How a stored predicate reads in English: a curated table hit, else the
|
|
122
157
|
* mechanical surface form of a minted predicate, else the predicate's local
|
|
@@ -124,11 +159,21 @@ export function gerundVerbSurface(verb) {
|
|
|
124
159
|
* none). The local-name tail is what keeps a namespace tmct itself mints —
|
|
125
160
|
* `tmct:needs`, stamped by the lexicon — out of a public-facing sentence.
|
|
126
161
|
*
|
|
162
|
+
* `subject` is optional and used ONLY by the minted "mgx:<lemma>" verb fold
|
|
163
|
+
* (and the do-support it derives under negation): a plural subject reads the
|
|
164
|
+
* lemma's bare form ("scientists report"), anything else reads its
|
|
165
|
+
* third-person-singular fold ("an earthquake strikes"). A CURATED phrase is
|
|
166
|
+
* returned exactly as written regardless of subject — the table is fixed
|
|
167
|
+
* English on purpose, no morphology applied to it here or anywhere else.
|
|
168
|
+
* Callers with no subject to offer (most of chat.mjs's call sites, which
|
|
169
|
+
* render a bare predicate with no sentence around it) get today's singular
|
|
170
|
+
* default, unchanged.
|
|
171
|
+
*
|
|
127
172
|
* Self-contained on purpose: the news page runs this exact function in the
|
|
128
|
-
* browser (phraseRendererSource below stringifies it and
|
|
129
|
-
* it reaches for nothing outside this module.
|
|
173
|
+
* browser (phraseRendererSource below stringifies it and everything it calls
|
|
174
|
+
* into), so it reaches for nothing outside this module.
|
|
130
175
|
*/
|
|
131
|
-
export function predicatePhrase(predicate) {
|
|
176
|
+
export function predicatePhrase(predicate, subject) {
|
|
132
177
|
if (FACT_PREDICATE_PHRASES[predicate]) return FACT_PREDICATE_PHRASES[predicate];
|
|
133
178
|
const p = String(predicate ?? "");
|
|
134
179
|
// NEGATIVE polarity renders as its own positive phrase, negated — ONE branch
|
|
@@ -144,12 +189,13 @@ export function predicatePhrase(predicate) {
|
|
|
144
189
|
// for the same reason: a modal, a copula and a plain verb take different
|
|
145
190
|
// negations, and nothing else does.
|
|
146
191
|
if (p.startsWith("mgxneg:")) {
|
|
147
|
-
const phrase = predicatePhrase(`mgx:${p.slice("mgxneg:".length)}
|
|
192
|
+
const phrase = predicatePhrase(`mgx:${p.slice("mgxneg:".length)}`, subject);
|
|
148
193
|
if (phrase === "can") return "cannot";
|
|
149
194
|
if (phrase === "can be") return "cannot be";
|
|
150
195
|
if (phrase === "is" || phrase.startsWith("is ")) return `is not${phrase.slice(2)}`;
|
|
151
196
|
const [head, ...tail] = phrase.split(" ");
|
|
152
|
-
|
|
197
|
+
const doSupport = isSubjectPlural(subject) ? "do not" : "does not";
|
|
198
|
+
return [doSupport, baseVerbSurface(head), ...tail].join(" ");
|
|
153
199
|
}
|
|
154
200
|
// a comparative renders as its copula surface: mgx:smaller-than ->
|
|
155
201
|
// "is smaller than" (never a 3sg fold — "smallers" isn't a word)
|
|
@@ -164,15 +210,23 @@ export function predicatePhrase(predicate) {
|
|
|
164
210
|
const same = /^mgx:same-([a-z]+)-as$/i.exec(p);
|
|
165
211
|
if (same) return `has the same ${same[1].toLowerCase()} as`;
|
|
166
212
|
// a folded preposition renders back naturally: mgx:rest-on -> "rests on"
|
|
213
|
+
// (subject-verb agreement lives here alone: mgx:<lemma> is minted from the
|
|
214
|
+
// verb's OWN base form, so a plural subject reads it bare — "scientists
|
|
215
|
+
// report" — and anything else takes the 3sg fold — "an earthquake strikes")
|
|
167
216
|
const minted = /^mgx:([a-z]+)(?:-([a-z]+))?$/i.exec(p);
|
|
168
|
-
if (minted)
|
|
217
|
+
if (minted) {
|
|
218
|
+
const verb = isSubjectPlural(subject) ? minted[1] : thirdPersonSingularSurface(minted[1]);
|
|
219
|
+
return `${verb}${minted[2] ? ` ${minted[2]}` : ""}`;
|
|
220
|
+
}
|
|
169
221
|
const colon = p.indexOf(":");
|
|
170
222
|
return colon === -1 ? p : p.slice(colon + 1);
|
|
171
223
|
}
|
|
172
224
|
|
|
173
|
-
/** "a heart has a valve" from one { subject, predicate, object } fact row
|
|
225
|
+
/** "a heart has a valve" from one { subject, predicate, object } fact row —
|
|
226
|
+
* "scientists report a finding" for a plural row.subject, off the same
|
|
227
|
+
* agreement rule in predicatePhrase above. */
|
|
174
228
|
export function factSentence(row) {
|
|
175
|
-
return `${row.subject} ${predicatePhrase(row.predicate)} ${row.object}`;
|
|
229
|
+
return `${row.subject} ${predicatePhrase(row.predicate, row.subject)} ${row.object}`;
|
|
176
230
|
}
|
|
177
231
|
|
|
178
232
|
/**
|
|
@@ -224,6 +278,9 @@ export function phraseRendererSource() {
|
|
|
224
278
|
`const TEACH_PARTICIPLE_SRC = ${JSON.stringify(TEACH_PARTICIPLE_SRC)};`,
|
|
225
279
|
`const thirdPersonSingularSurface = ${thirdPersonSingularSurface};`,
|
|
226
280
|
`const baseVerbSurface = ${baseVerbSurface};`,
|
|
281
|
+
`const IRREGULAR_PLURAL_NOUNS = new Set(${JSON.stringify([...IRREGULAR_PLURAL_NOUNS])});`,
|
|
282
|
+
`const SINGULAR_NOUNS_ENDING_S = new Set(${JSON.stringify([...SINGULAR_NOUNS_ENDING_S])});`,
|
|
283
|
+
`const isSubjectPlural = ${isSubjectPlural};`,
|
|
227
284
|
`const predicatePhrase = ${predicatePhrase};`,
|
|
228
285
|
`const factSentence = ${factSentence};`,
|
|
229
286
|
].join("\n ");
|
package/src/domain/news-feed.mjs
CHANGED
|
@@ -71,6 +71,51 @@ export function newsWindowRows(rows, { now, windowMs }) {
|
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Item identity: what makes two fetched snapshots the same newsworthy item.
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
// Everything a source can respell between two readings of one article —
|
|
79
|
+
// punctuation, capitalisation, entity escapes already stripped upstream, run
|
|
80
|
+
// of spaces — folds away, so the key answers to the item's words alone.
|
|
81
|
+
const CONTENT_KEY_NOISE_RE = /[^a-z0-9]+/g;
|
|
82
|
+
|
|
83
|
+
function itemContentText(snapshot) {
|
|
84
|
+
return `${snapshot?.title ?? ""} ${snapshot?.summary ?? ""}`
|
|
85
|
+
.toLowerCase()
|
|
86
|
+
.replace(CONTENT_KEY_NOISE_RE, " ")
|
|
87
|
+
.trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The content key one snapshot answers to, or "" when it carries no words:
|
|
91
|
+
* its source, its own publication stamp and its normalized text. The key a
|
|
92
|
+
* source with no stable id of its own de-dupes on, and the second chance at
|
|
93
|
+
* recognising an article a source re-issued under a fresh id. The publication
|
|
94
|
+
* stamp stays in so two genuinely different events that happen to share a
|
|
95
|
+
* headline — two quakes of the same size near the same town — keep separate
|
|
96
|
+
* keys. Pure. */
|
|
97
|
+
export function newsItemContentKey(snapshot) {
|
|
98
|
+
const text = itemContentText(snapshot);
|
|
99
|
+
if (!text) return "";
|
|
100
|
+
const sourceId = String(snapshot?.sourceId ?? "");
|
|
101
|
+
const publishedAt = String(snapshot?.publishedAt ?? "");
|
|
102
|
+
return `news-text:${sha256HexPrefix(`${sourceId}\0${publishedAt}\0${text}`, 8)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Every key a fetched snapshot is the same item under: the id the fetcher
|
|
106
|
+
* minted from the source's own identifier (Hacker News story id, USGS event
|
|
107
|
+
* id, an RSS guid, a Wikinews page id, a Wikimedia article title) and its
|
|
108
|
+
* content key. Two snapshots sharing any key name one item. Pure — a function
|
|
109
|
+
* of the snapshot's own fields, never of when or in what order it arrived. */
|
|
110
|
+
export function newsItemKeys(snapshot) {
|
|
111
|
+
const keys = [];
|
|
112
|
+
const id = String(snapshot?.id ?? "");
|
|
113
|
+
if (id) keys.push(id);
|
|
114
|
+
const contentKey = newsItemContentKey(snapshot);
|
|
115
|
+
if (contentKey) keys.push(contentKey);
|
|
116
|
+
return keys;
|
|
117
|
+
}
|
|
118
|
+
|
|
74
119
|
// ---------------------------------------------------------------------------
|
|
75
120
|
// The newsworthiness gate (PLAN_NEWS_FEED.md section 17). A card reports what
|
|
76
121
|
// a contemporary source said inside the window; everything the graph looked
|
|
@@ -580,7 +625,7 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
|
|
|
580
625
|
.map((r) => r.object)
|
|
581
626
|
.sort();
|
|
582
627
|
if (!objects.length) continue;
|
|
583
|
-
sentences.push(`${hub} ${predicatePhrase(predicate)} ${joinObjects(objects)}`);
|
|
628
|
+
sentences.push(`${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`);
|
|
584
629
|
}
|
|
585
630
|
|
|
586
631
|
// A hub that only ever appears as an OBJECT — the place a quake struck, the
|
|
@@ -31,8 +31,42 @@ const FUNCTION_WORD_TERMS = new Set([
|
|
|
31
31
|
"usually", "indeed", "however", "therefore", "thus", "hence", "meanwhile",
|
|
32
32
|
]);
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// A bare measurement-unit abbreviation names a quantity, not a subject —
|
|
35
|
+
// "10 km SSW of Ridgecrest" leaks "km" as a standalone term. Closed set of
|
|
36
|
+
// the abbreviations USGS/wire-service distance, weight and speed reports
|
|
37
|
+
// actually use standalone.
|
|
38
|
+
const MEASUREMENT_UNIT_TERMS = new Set([
|
|
39
|
+
"km", "kms", "m", "mi", "mis", "ft", "kg", "kgs", "mph", "kmh", "kph",
|
|
40
|
+
"cm", "mm", "nm", "yd", "yds", "lb", "lbs", "oz",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// A compass abbreviation locates something relative to a place; it never
|
|
44
|
+
// names the place itself — "10 km SSW of Ridgecrest" leaks "ssw" the same
|
|
45
|
+
// way it leaks "km". Closed set: the 16 standard compass-rose points.
|
|
46
|
+
const COMPASS_ABBREVIATION_TERMS = new Set([
|
|
47
|
+
"n", "s", "e", "w",
|
|
48
|
+
"ne", "nw", "se", "sw",
|
|
49
|
+
"nne", "ene", "ese", "sse", "ssw", "wsw", "wnw", "nnw",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
// A bare foreign article/preposition arrives only as a fragment of a title
|
|
53
|
+
// that never got tokenized as a whole name ("Tour de France Femmes" leaking
|
|
54
|
+
// "de"). Closed set, standalone only — this never matches "de" glued inside
|
|
55
|
+
// a multi-word term like "tour de france", since bumpTerms and
|
|
56
|
+
// ledgerFromPayload both key on the full normalized term, not its words.
|
|
57
|
+
const FOREIGN_PARTICLE_TERMS = new Set([
|
|
58
|
+
"de", "la", "le", "du", "der", "von", "van", "di", "el",
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
const NOISE_TERM_SETS = [
|
|
62
|
+
FUNCTION_WORD_TERMS,
|
|
63
|
+
MEASUREMENT_UNIT_TERMS,
|
|
64
|
+
COMPASS_ABBREVIATION_TERMS,
|
|
65
|
+
FOREIGN_PARTICLE_TERMS,
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
function isNoiseTerm(term) {
|
|
69
|
+
return NOISE_TERM_SETS.some((set) => set.has(term));
|
|
36
70
|
}
|
|
37
71
|
|
|
38
72
|
/** Ledger entry field order fixed once here so `ledgerPayload` serializes
|
|
@@ -62,7 +96,7 @@ export function createTermLedger() {
|
|
|
62
96
|
export function bumpTerms(ledger, termCounts, itemId, now, vocabGroundedByTerm = new Map()) {
|
|
63
97
|
for (const [rawTerm, occurrences] of termCounts) {
|
|
64
98
|
const term = normFactTerm(rawTerm);
|
|
65
|
-
if (!term ||
|
|
99
|
+
if (!term || isNoiseTerm(term)) continue;
|
|
66
100
|
let entry = ledger.terms.get(term);
|
|
67
101
|
if (!entry) {
|
|
68
102
|
const vocabGrounded = vocabGroundedByTerm.has(rawTerm)
|
|
@@ -139,7 +173,7 @@ export function ledgerPayload(ledger) {
|
|
|
139
173
|
export function ledgerFromPayload(payload) {
|
|
140
174
|
const ledger = createTermLedger();
|
|
141
175
|
for (const entry of payload?.terms ?? []) {
|
|
142
|
-
if (
|
|
176
|
+
if (isNoiseTerm(entry.term)) continue;
|
|
143
177
|
ledger.terms.set(entry.term, { ...entry, itemIds: [...(entry.itemIds ?? [])] });
|
|
144
178
|
}
|
|
145
179
|
return ledger;
|
package/src/services/news.mjs
CHANGED
|
@@ -17,11 +17,11 @@
|
|
|
17
17
|
// (the same invalidation convention chat.mjs's own caches use).
|
|
18
18
|
// lexicon a loaded lexicon (loadLexicon() when absent).
|
|
19
19
|
// config resolveNewsConfig()'s shape.
|
|
20
|
-
// state the news-store shape (news-store.mjs): { items,
|
|
21
|
-
// (ledgerPayload form), health, requestLog, metrics,
|
|
22
|
-
// lastEnrichAt } — always JSON-plain; a live term
|
|
23
|
-
// built from state.ledger via ledgerFromPayload only
|
|
24
|
-
// span of one call, then folded back with ledgerPayload.
|
|
20
|
+
// state the news-store shape (news-store.mjs): { items, seenItemKeys,
|
|
21
|
+
// ledger (ledgerPayload form), health, requestLog, metrics,
|
|
22
|
+
// lastPollAt, lastEnrichAt } — always JSON-plain; a live term
|
|
23
|
+
// ledger is built from state.ledger via ledgerFromPayload only
|
|
24
|
+
// for the span of one call, then folded back with ledgerPayload.
|
|
25
25
|
// providers { newsFetchers: Map<sourceId, { id, fetchItems }>,
|
|
26
26
|
// getResearchProvider({ source }), preflightNewsUrl?(url) } —
|
|
27
27
|
// every fetcher and provider this session may call, already
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
import { normFactTerm, normFactPredicate, factIdFor } from "../domain/hash.mjs";
|
|
45
45
|
import {
|
|
46
46
|
newsWindowRows, renderNewsParagraph, buildNewsItems, evictNewsFacts,
|
|
47
|
-
conceptTerms, isQuantityTerm,
|
|
47
|
+
conceptTerms, isQuantityTerm, newsItemKeys,
|
|
48
48
|
} from "../domain/news-feed.mjs";
|
|
49
49
|
import {
|
|
50
50
|
createTermLedger, bumpTerms, rankedTerms, markTerm, groundedSweep, ledgerPayload, ledgerFromPayload,
|
|
@@ -473,38 +473,90 @@ function recordSuccess(health, nowVal, status) {
|
|
|
473
473
|
health.autoDisabled = false;
|
|
474
474
|
}
|
|
475
475
|
|
|
476
|
-
/** Merges `incoming` snapshots into `existing` by id (an already-seen id is
|
|
477
|
-
* never re-added or re-ingested), then enforces `cap` by dropping the
|
|
478
|
-
* oldest-by-fetchedAt entries. Returns the merged list and the genuinely
|
|
479
|
-
* new snapshots the caller still needs to ingest. */
|
|
480
|
-
function mergeSnapshotsById(existing, incoming, cap) {
|
|
481
|
-
const byIdMap = new Map((existing || []).map((s) => [s.id, s]));
|
|
482
|
-
const added = [];
|
|
483
|
-
for (const snap of incoming || []) {
|
|
484
|
-
if (byIdMap.has(snap.id)) continue;
|
|
485
|
-
byIdMap.set(snap.id, snap);
|
|
486
|
-
added.push(snap);
|
|
487
|
-
}
|
|
488
|
-
let items = [...byIdMap.values()];
|
|
489
|
-
if (items.length > cap) {
|
|
490
|
-
items = items
|
|
491
|
-
.slice()
|
|
492
|
-
.sort((a, b) => (toMs(a.fetchedAt) - toMs(b.fetchedAt)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
493
|
-
.slice(items.length - cap);
|
|
494
|
-
}
|
|
495
|
-
return { items, added };
|
|
496
|
-
}
|
|
497
|
-
|
|
498
476
|
const fetchedAtMs = (snapshot) => {
|
|
499
477
|
const ms = toMs(snapshot?.fetchedAt);
|
|
500
478
|
return Number.isFinite(ms) ? ms : 0;
|
|
501
479
|
};
|
|
502
480
|
|
|
503
|
-
/** True once a snapshot has been through a grounding round.
|
|
504
|
-
*
|
|
505
|
-
*
|
|
481
|
+
/** True once a snapshot has been through a grounding round. A snapshot is
|
|
482
|
+
* filed the moment it arrives, so "known" and "grounded" are two different
|
|
483
|
+
* states and only this one means the facts landed. */
|
|
506
484
|
const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
|
|
507
485
|
|
|
486
|
+
const byIdAscending = (a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
487
|
+
const byFetchedAtThenId = (a, b) => (fetchedAtMs(a) - fetchedAtMs(b)) || byIdAscending(a, b);
|
|
488
|
+
// The item cap's drop order: a snapshot already read is the first to go, since
|
|
489
|
+
// its facts are in the graph and its keys are remembered, while one still
|
|
490
|
+
// waiting to be read would lose its facts for good.
|
|
491
|
+
const alreadyReadFirst = (a, b) => (isGroundedSnapshot(b) - isGroundedSnapshot(a)) || byFetchedAtThenId(a, b);
|
|
492
|
+
|
|
493
|
+
// How many item keys the de-dupe memory carries. Each grounded item files two
|
|
494
|
+
// (its source id and its content key), so this remembers roughly a thousand
|
|
495
|
+
// articles — many times the item window itself, which is the point: the window
|
|
496
|
+
// forgets an article as soon as newer ones crowd it out, and without this the
|
|
497
|
+
// next poll would read that same article as brand new.
|
|
498
|
+
const SEEN_ITEM_KEY_CAP = 2000;
|
|
499
|
+
|
|
500
|
+
const seenEntries = (seen) => (Array.isArray(seen) ? seen : []);
|
|
501
|
+
|
|
502
|
+
/** Every key `snapshots` and `seen` between them name, newest first and capped
|
|
503
|
+
* — an entry's `at` is the snapshot's own fetchedAt, never a fresh clock
|
|
504
|
+
* reading. Ordered off the entries themselves, so the same items produce the
|
|
505
|
+
* same memory whatever order they arrived in. */
|
|
506
|
+
function rememberItemKeys(seen, snapshots) {
|
|
507
|
+
const atByKey = new Map();
|
|
508
|
+
const remember = (key, at) => {
|
|
509
|
+
if (!key) return;
|
|
510
|
+
const previous = atByKey.get(key);
|
|
511
|
+
atByKey.set(key, previous === undefined ? at : Math.max(previous, at));
|
|
512
|
+
};
|
|
513
|
+
for (const entry of seenEntries(seen)) {
|
|
514
|
+
const at = Number(entry?.at);
|
|
515
|
+
remember(String(entry?.key ?? ""), Number.isFinite(at) ? at : 0);
|
|
516
|
+
}
|
|
517
|
+
for (const snap of snapshots || []) {
|
|
518
|
+
for (const key of newsItemKeys(snap)) remember(key, fetchedAtMs(snap));
|
|
519
|
+
}
|
|
520
|
+
return [...atByKey.entries()]
|
|
521
|
+
.map(([key, at]) => ({ key, at }))
|
|
522
|
+
.sort((a, b) => (b.at - a.at) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
523
|
+
.slice(0, SEEN_ITEM_KEY_CAP);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/** Merges `incoming` snapshots into `existing` by item identity: a snapshot
|
|
527
|
+
* whose id or content key already sits in the window, or in the `seen` memory
|
|
528
|
+
* of what has been grounded, is neither re-added nor re-ingested. Two
|
|
529
|
+
* incoming snapshots naming one item collapse to the lower id, so the same
|
|
530
|
+
* fetch read in two orders admits the same snapshot. The merged list sorts by
|
|
531
|
+
* fetchedAt then id, so the window is a function of which items are in it
|
|
532
|
+
* rather than of when each arrived, and `cap` then drops the snapshots that
|
|
533
|
+
* have already been read before any that still have facts to contribute.
|
|
534
|
+
* Returns the merged list and the genuinely new snapshots the caller still
|
|
535
|
+
* needs to ingest. */
|
|
536
|
+
function mergeSnapshots(existing, incoming, { cap, seen } = {}) {
|
|
537
|
+
const known = new Set();
|
|
538
|
+
for (const snap of existing || []) for (const key of newsItemKeys(snap)) known.add(key);
|
|
539
|
+
for (const entry of seenEntries(seen)) if (entry?.key) known.add(String(entry.key));
|
|
540
|
+
|
|
541
|
+
const claimed = new Set();
|
|
542
|
+
const admitted = new Set();
|
|
543
|
+
for (const snap of [...(incoming || [])].sort(byIdAscending)) {
|
|
544
|
+
const keys = newsItemKeys(snap);
|
|
545
|
+
if (!keys.length) continue;
|
|
546
|
+
if (keys.some((key) => known.has(key) || claimed.has(key))) continue;
|
|
547
|
+
for (const key of keys) claimed.add(key);
|
|
548
|
+
admitted.add(snap);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const added = (incoming || []).filter((snap) => admitted.has(snap));
|
|
552
|
+
const items = [...(existing || []), ...added].sort(byFetchedAtThenId);
|
|
553
|
+
if (items.length <= cap) return { items, added };
|
|
554
|
+
const dropped = new Set(
|
|
555
|
+
items.slice().sort(alreadyReadFirst).slice(0, items.length - cap).map((snap) => snap.id),
|
|
556
|
+
);
|
|
557
|
+
return { items: items.filter((snap) => !dropped.has(snap.id)), added };
|
|
558
|
+
}
|
|
559
|
+
|
|
508
560
|
/** One source's fetched-but-not-yet-grounded snapshots, oldest first. A cycle
|
|
509
561
|
* that ran out of time leaves its backlog here, so the next cycle works
|
|
510
562
|
* through that before anything newer and the same article is never ingested
|
|
@@ -513,7 +565,7 @@ const isGroundedSnapshot = (snapshot) => (snapshot?.processedRounds || 0) > 0;
|
|
|
513
565
|
function pendingSnapshotsFor(items, sourceId) {
|
|
514
566
|
return (items || [])
|
|
515
567
|
.filter((snap) => snap?.sourceId === sourceId && !isGroundedSnapshot(snap))
|
|
516
|
-
.sort(
|
|
568
|
+
.sort(byFetchedAtThenId);
|
|
517
569
|
}
|
|
518
570
|
|
|
519
571
|
function emptyCycleAccumulator(at) {
|
|
@@ -575,7 +627,9 @@ export async function pollNewsSources(ctx) {
|
|
|
575
627
|
recordSuccess(health, nowVal, "not-modified");
|
|
576
628
|
} else {
|
|
577
629
|
recordSuccess(health, nowVal, "ok");
|
|
578
|
-
const merged =
|
|
630
|
+
const merged = mergeSnapshots(state.items, result.items, {
|
|
631
|
+
cap: config.itemCap, seen: state.seenItemKeys,
|
|
632
|
+
});
|
|
579
633
|
state.items = merged.items;
|
|
580
634
|
added = merged.added;
|
|
581
635
|
newItemsTotal += added.length;
|
|
@@ -614,6 +668,15 @@ export async function pollNewsSources(ctx) {
|
|
|
614
668
|
if (aborted) break;
|
|
615
669
|
}
|
|
616
670
|
|
|
671
|
+
// Only a GROUNDED snapshot is remembered. One the item cap dropped before it
|
|
672
|
+
// was ever read still has its facts to contribute, so the next poll is meant
|
|
673
|
+
// to pick it up again; one whose facts already landed must never be read a
|
|
674
|
+
// second time, however long ago the window forgot it.
|
|
675
|
+
state.seenItemKeys = rememberItemKeys(
|
|
676
|
+
state.seenItemKeys,
|
|
677
|
+
(state.items || []).filter(isGroundedSnapshot),
|
|
678
|
+
);
|
|
679
|
+
|
|
617
680
|
const memory = await store.loadMemory(memoryDir);
|
|
618
681
|
const rows = store.readFactRows(memory);
|
|
619
682
|
const evictIds = evictNewsFacts(rows, { cap: config.newsFactCap });
|
|
@@ -998,7 +1061,7 @@ export function cycleMetrics(before, after, { source } = {}) {
|
|
|
998
1061
|
|
|
999
1062
|
export function createNewsState() {
|
|
1000
1063
|
return {
|
|
1001
|
-
items: [], ledger: ledgerPayload(createTermLedger()), health: [],
|
|
1002
|
-
lastPollAt: "", lastEnrichAt: "",
|
|
1064
|
+
items: [], seenItemKeys: [], ledger: ledgerPayload(createTermLedger()), health: [],
|
|
1065
|
+
requestLog: [], metrics: [], lastPollAt: "", lastEnrichAt: "",
|
|
1003
1066
|
};
|
|
1004
1067
|
}
|