@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.15",
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 its two helpers), so
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
- return ["does not", baseVerbSurface(head), ...tail].join(" ");
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) return `${thirdPersonSingularSurface(minted[1])}${minted[2] ? ` ${minted[2]}` : ""}`;
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 ");
@@ -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
- function isFunctionWordTerm(term) {
35
- return FUNCTION_WORD_TERMS.has(term);
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 || isFunctionWordTerm(term)) continue;
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 (isFunctionWordTerm(entry.term)) continue;
176
+ if (isNoiseTerm(entry.term)) continue;
143
177
  ledger.terms.set(entry.term, { ...entry, itemIds: [...(entry.itemIds ?? [])] });
144
178
  }
145
179
  return ledger;
@@ -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, ledger
21
- // (ledgerPayload form), health, requestLog, metrics, lastPollAt,
22
- // lastEnrichAt } — always JSON-plain; a live term ledger is
23
- // built from state.ledger via ledgerFromPayload only for the
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. `mergeSnapshotsById`
504
- * files a snapshot the moment it arrives, so "known" and "grounded" are two
505
- * different states and only this one means the facts landed. */
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((a, b) => (fetchedAtMs(a) - fetchedAtMs(b)) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
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 = mergeSnapshotsById(state.items, result.items, config.itemCap);
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: [], requestLog: [], metrics: [],
1002
- lastPollAt: "", lastEnrichAt: "",
1064
+ items: [], seenItemKeys: [], ledger: ledgerPayload(createTermLedger()), health: [],
1065
+ requestLog: [], metrics: [], lastPollAt: "", lastEnrichAt: "",
1003
1066
  };
1004
1067
  }