@polycode-projects/the-mechanical-code-talker 6.0.14 → 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.
@@ -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
  }