@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "6.0.14",
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",
@@ -298,6 +298,30 @@ async function fetchWikimediaFeed(record, gate, { now }) {
298
298
  return { items, bytes: jsonByteLength(body) };
299
299
  }
300
300
 
301
+ // A Hacker News item is a headline and nothing else — the API carries no
302
+ // summary field to fetch — and a headline is rarely a sentence, so nothing in
303
+ // the item grounds on its own. The fixed sentence below states what the site
304
+ // itself did with the story, and it names the headline IN QUOTES: quoted, the
305
+ // headline's own words can never be re-read as a claim of their own, so "Let's
306
+ // take apart your phone" stays a story Hacker News discusses instead of
307
+ // becoming a fact about Hacker News taking a phone apart.
308
+ //
309
+ // "Hackernews" is one word here on purpose. A two-word proper name in subject
310
+ // position reads as noun + verb ("Hacker" doing "News"), and the fact that
311
+ // falls out of that is nonsense.
312
+ const HACKER_NEWS_TITLE_PREFIX_RE = /^(?:show|ask|tell)\s+hn:\s*/i;
313
+ // Past this many words the stored term is a clause, not a name, and the
314
+ // recognizer turns it down — so the sentence is not built at all rather than
315
+ // stored half-read.
316
+ const HACKER_NEWS_HEADLINE_MAX_WORDS = 6;
317
+
318
+ function hackerNewsSummary(title) {
319
+ const headline = String(title || "").replace(HACKER_NEWS_TITLE_PREFIX_RE, "").trim();
320
+ if (!headline || headline.includes(":")) return "";
321
+ if (headline.split(/\s+/).length > HACKER_NEWS_HEADLINE_MAX_WORDS) return "";
322
+ return `Hackernews discusses "${headline}".`;
323
+ }
324
+
301
325
  /** topstories.json, then item/<id>.json for the first ten ids, through the
302
326
  * same gate — each item fetch takes its own slot, so the ten round trips
303
327
  * are genuinely paced by the gate's minIntervalMs rather than firing back
@@ -311,11 +335,12 @@ async function fetchHackerNews(record, gate, { now }) {
311
335
  const item = await pacedFetchJson(gate, `${record.url}/item/${id}.json`);
312
336
  if (!item) continue;
313
337
  bytes += jsonByteLength(item);
338
+ const title = stripMarkup(item.title || "");
314
339
  raw.push({
315
340
  guid: String(item.id ?? id),
316
- title: stripMarkup(item.title || ""),
341
+ title,
317
342
  url: item.url || `https://news.ycombinator.com/item?id=${item.id ?? id}`,
318
- summary: "",
343
+ summary: hackerNewsSummary(title),
319
344
  publishedAt: Number.isFinite(item.time) ? new Date(item.time * 1000).toISOString() : "",
320
345
  });
321
346
  }
@@ -323,6 +348,32 @@ async function fetchHackerNews(record, gate, { now }) {
323
348
  return { items, bytes };
324
349
  }
325
350
 
351
+ // A USGS place names a point by its distance and bearing from a settlement
352
+ // ("25 km ENE of Wana, Pakistan"), or by an offshore bearing ("off the west
353
+ // coast of Vancouver Island, Canada"), or outright ("Western Australia"). The
354
+ // distance and the bearing are a measurement, not a name, so they come off
355
+ // before the place reaches the sentence below: a number-led term can never
356
+ // head a feed card, and "near" already covers the few kilometres dropped.
357
+ const USGS_DISTANCE_PREFIX_RE = /^\s*\d+(?:\.\d+)?\s*km\s+[NSEW]{1,3}\s+of\s+/i;
358
+ const USGS_OFFSHORE_PREFIX_RE = /^\s*off\s+(?:the\s+)?[a-z\s]*?coast\s+of\s+/i;
359
+
360
+ function usgsPlaceName(place) {
361
+ return String(place || "")
362
+ .replace(USGS_DISTANCE_PREFIX_RE, "")
363
+ .replace(USGS_OFFSHORE_PREFIX_RE, "")
364
+ .trim();
365
+ }
366
+
367
+ // One fixed sentence per quake: a bare-noun subject, the verb the event did,
368
+ // and the named place. The magnitude stays where USGS itself put it, in the
369
+ // item's title — a stored fact's subject here is the CLASS "earthquake", so
370
+ // writing the number into this sentence would say something about earthquakes
371
+ // in general rather than about this one.
372
+ function usgsSummary(place) {
373
+ const name = usgsPlaceName(place);
374
+ return name ? `An earthquake struck near ${name}.` : "";
375
+ }
376
+
326
377
  async function fetchUsgs(record, gate, { now }) {
327
378
  const body = await pacedFetchJson(gate, record.url);
328
379
  if (body === null) return null;
@@ -332,7 +383,7 @@ async function fetchUsgs(record, gate, { now }) {
332
383
  guid: String(f?.id ?? f?.properties?.detail ?? f?.properties?.url ?? ""),
333
384
  title: stripMarkup(f?.properties?.title || ""),
334
385
  url: f?.properties?.url || "",
335
- summary: stripMarkup(`magnitude ${f?.properties?.mag ?? "unknown"} earthquake near ${f?.properties?.place || "an unreported location"}`),
386
+ summary: stripMarkup(usgsSummary(f?.properties?.place)),
336
387
  publishedAt: Number.isFinite(f?.properties?.time) ? new Date(f.properties.time).toISOString() : "",
337
388
  }));
338
389
  const items = normalizeFeedItems(record.id, raw, { now });
@@ -347,7 +398,11 @@ function wikinewsArticleOrigin(apiUrl) {
347
398
  * recentchanges list — the smallest query that names what is new without a
348
399
  * category to maintain by hand. */
349
400
  async function fetchWikinews(record, gate, { now }) {
350
- const url = `${record.url}?action=query&list=recentchanges&rcnamespace=0&rcnewonly=1&rclimit=20&format=json&formatversion=2&origin=*`;
401
+ // `rctype=new` is the action API's own name for "page creations only". An
402
+ // earlier `rcnewonly` is not a parameter the API has: it answers with an
403
+ // "Unrecognized parameter" warning and lists every edit, so a re-edited old
404
+ // article read as a new story.
405
+ const url = `${record.url}?action=query&list=recentchanges&rcnamespace=0&rctype=new&rclimit=20&format=json&formatversion=2&origin=*`;
351
406
  const body = await pacedFetchJson(gate, url);
352
407
  if (body === null) return null;
353
408
  if (isNotModified(body)) return { items: [], bytes: 0, notModified: true };
@@ -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
@@ -240,6 +285,19 @@ const ENTITY_FRAGMENT_LEAD_WORDS = new Set([
240
285
  // term; "back" alone is a fine noun.
241
286
  const ENTITY_PARTICLE_LEAD_WORDS = new Set(["back", "up", "down", "out", "off", "away", "along", "around"]);
242
287
 
288
+ // A pronoun points back at whatever the last clause named, so a multi-word
289
+ // term opening with one is a clause the split lost the subject of. A term
290
+ // ending in a bare auxiliary is the front half of one. Both mirror
291
+ // extract-facts.mjs's own lexical rules, for the same reason the sets above do.
292
+ const ENTITY_PRONOUN_LEAD_WORDS = new Set([
293
+ "i", "he", "she", "it", "we", "they", "you", "me", "him", "them", "us",
294
+ "his", "her", "its", "their", "our", "your", "my",
295
+ ]);
296
+ const ENTITY_CLITIC_SUFFIX_RE = /['’](?:s|re|ve|ll|d|m)$/;
297
+ const ENTITY_TRAILING_AUXILIARY_WORDS = new Set([
298
+ "is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
299
+ ]);
300
+
243
301
  /** Does `term` read as a thing's name rather than a clause fragment? Bounds
244
302
  * the word count and rejects a leading conjunction, auxiliary or
245
303
  * preposition (test E's condition 3, PLAN_NEWSWORTHINESS.md section 2), plus
@@ -251,7 +309,10 @@ function looksLikeEntityTerm(term) {
251
309
  if (words.length > ENTITY_TERM_MAX_WORDS) return false;
252
310
  const first = words[0].toLowerCase().replace(/^[^a-z0-9]+/, "");
253
311
  if (!first || ENTITY_FRAGMENT_LEAD_WORDS.has(first)) return false;
254
- if (words.length > 1 && ENTITY_PARTICLE_LEAD_WORDS.has(first)) return false;
312
+ if (words.length === 1) return true;
313
+ if (ENTITY_PARTICLE_LEAD_WORDS.has(first)) return false;
314
+ if (ENTITY_PRONOUN_LEAD_WORDS.has(first.replace(ENTITY_CLITIC_SUFFIX_RE, ""))) return false;
315
+ if (ENTITY_TRAILING_AUXILIARY_WORDS.has(words[words.length - 1].toLowerCase())) return false;
255
316
  return true;
256
317
  }
257
318
 
@@ -425,21 +486,29 @@ export function buildTermAdjacency(rows) {
425
486
  }
426
487
 
427
488
  /** Breadth-first over subject/object adjacency from `hub`, exactly `hops`
428
- * levels deep, then capped by content-addressed id so the cap never
429
- * depends on `rows`' own order, only on which rows the hop-bounded walk
430
- * actually reaches. */
431
- export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adjacency = null } = {}) {
489
+ * levels deep, then capped: a `priorityIds` row first, then the nearer hop,
490
+ * then content-addressed id. The cap never depends on `rows`' own order, only
491
+ * on which rows the hop-bounded walk actually reaches and how far out each
492
+ * one sits.
493
+ *
494
+ * `priorityIds` is what keeps a card about a term the graph already knows
495
+ * thousands of things about from being built out of an arbitrary slice of
496
+ * them: a hub like "france" reaches far more rows than the cap, and the one
497
+ * report that made it news would otherwise be the row that fell out. */
498
+ export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null } = {}) {
432
499
  const adj = adjacency ?? buildTermAdjacency(rows);
433
500
  const hubTerm = normFactTerm(hub);
434
501
  const visited = new Set([hubTerm]);
435
502
  let frontier = [hubTerm];
436
503
  const collected = new Map();
504
+ const hopOf = new Map();
437
505
  for (let hop = 0; hop < hops; hop += 1) {
438
506
  const nextFrontier = new Set();
439
507
  for (const term of [...frontier].sort()) {
440
508
  for (const idx of adj.byTerm.get(term) ?? []) {
441
509
  const row = rows[idx];
442
510
  collected.set(row.id, row);
511
+ if (!hopOf.has(row.id)) hopOf.set(row.id, hop);
443
512
  const [s, o] = adj.terms[idx];
444
513
  if (!visited.has(s)) nextFrontier.add(s);
445
514
  if (!visited.has(o)) nextFrontier.add(o);
@@ -448,7 +517,12 @@ export function subgraphAround(rows, hub, { hops = NEWS_HUB_HOPS, cap = 60, adja
448
517
  for (const term of nextFrontier) visited.add(term);
449
518
  frontier = [...nextFrontier].sort();
450
519
  }
451
- return [...collected.values()].sort(byId).slice(0, cap);
520
+ const isPriority = (id) => (priorityIds instanceof Set ? priorityIds.has(id) : Boolean(priorityIds?.includes?.(id)));
521
+ return [...collected.values()]
522
+ .sort((a, b) => (isPriority(b.id) - isPriority(a.id))
523
+ || (hopOf.get(a.id) - hopOf.get(b.id))
524
+ || byId(a, b))
525
+ .slice(0, cap);
452
526
  }
453
527
 
454
528
  /** The strongest prior kind among `rows`, for the item's trust chip — read
@@ -487,6 +561,28 @@ function joinWithAnd(items) {
487
561
 
488
562
  const IDENTITY_PREDICATES = new Set(["rdf:type", "rdfs:subClassOf"]);
489
563
  const SENTENCE_CAP = 5;
564
+ // How many objects one sentence names before it counts the rest. A live source
565
+ // reports the same relation over and over inside one window — every quake of
566
+ // the day strikes near somewhere — and an unbounded list turns a card into a
567
+ // wall of text.
568
+ const OBJECTS_PER_SENTENCE = 6;
569
+
570
+ function joinObjects(objects) {
571
+ if (objects.length <= OBJECTS_PER_SENTENCE) return joinWithAnd(objects);
572
+ const shown = objects.slice(0, OBJECTS_PER_SENTENCE);
573
+ return `${shown.join(", ")} and ${objects.length - OBJECTS_PER_SENTENCE} more`;
574
+ }
575
+
576
+ /** The predicates `rows` carry, curated-table order first and then whatever
577
+ * is left, sorted. A relation minted from a source's own verb ("mgx:hit",
578
+ * "mgx:strike-near") has no curated entry, and reading the table alone left
579
+ * every card built from live headlines with an empty paragraph. */
580
+ function predicatesInRenderOrder(rows) {
581
+ const present = new Set(rows.map((r) => r.predicate));
582
+ const curated = Object.keys(FACT_PREDICATE_PHRASES).filter((predicate) => present.has(predicate));
583
+ const rest = [...present].filter((predicate) => !Object.hasOwn(FACT_PREDICATE_PHRASES, predicate)).sort();
584
+ return [...curated, ...rest];
585
+ }
490
586
 
491
587
  /** The fixed five-sentence paraphrase template (PLAN_NEWS_FEED.md section
492
588
  * 8.3): identity first, then the hub's own relations grouped by predicate in
@@ -519,17 +615,30 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
519
615
  .sort();
520
616
  if (identityObjects.length) {
521
617
  const withArticles = identityObjects.map((object) => `${articleFor(object)} ${object}`);
522
- sentences.push(`${hub} is ${joinWithAnd(withArticles)}`);
618
+ sentences.push(`${hub} is ${joinObjects(withArticles)}`);
523
619
  }
524
620
 
525
- for (const predicate of Object.keys(FACT_PREDICATE_PHRASES)) {
621
+ for (const predicate of predicatesInRenderOrder(reportedHubRows)) {
526
622
  if (IDENTITY_PREDICATES.has(predicate) || sentences.length >= SENTENCE_CAP) continue;
527
623
  const objects = reportedHubRows
528
624
  .filter((r) => r.predicate === predicate)
529
625
  .map((r) => r.object)
530
626
  .sort();
531
627
  if (!objects.length) continue;
532
- sentences.push(`${hub} ${predicatePhrase(predicate)} ${joinWithAnd(objects)}`);
628
+ sentences.push(`${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`);
629
+ }
630
+
631
+ // A hub that only ever appears as an OBJECT — the place a quake struck, the
632
+ // story a site discussed — has no subject-side row to build a sentence from,
633
+ // and its card came out blank. What was reported about it still says
634
+ // something, so those rows render whole, subject and all.
635
+ if (!sentences.length) {
636
+ const aboutHub = subgraphRows
637
+ .filter((r) => normFactTerm(r.object) === hubTerm && normFactTerm(r.subject) !== hubTerm && isReported(r.id))
638
+ .sort(byId)
639
+ .slice(0, OBJECTS_PER_SENTENCE)
640
+ .map((r) => factSentence(r));
641
+ if (aboutHub.length) sentences.push(aboutHub.join("; "));
533
642
  }
534
643
 
535
644
  if (sentences.length < SENTENCE_CAP && secondHopRows.length) {
@@ -564,7 +673,7 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
564
673
  if (readsAsEntityTerm) hubOptions.readsAsEntityTerm = readsAsEntityTerm;
565
674
  const hubs = newsworthyHubs(rows, reported, hubOptions);
566
675
  const items = hubs.map(({ term, changed }) => {
567
- const subgraphRows = subgraphAround(rows, term, { adjacency });
676
+ const subgraphRows = subgraphAround(rows, term, { adjacency, priorityIds: reportedIds });
568
677
  const factIds = subgraphRows.map((r) => r.id).sort();
569
678
  const { background } = splitCardRows(subgraphRows, reportedIds);
570
679
  return {
@@ -8,6 +8,67 @@ import { normFactTerm } from "./hash.mjs";
8
8
 
9
9
  const ITEM_IDS_CAP = 12;
10
10
 
11
+ // A preposition, conjunction or degree/frequency adverb scaffolds a
12
+ // sentence; it never names the thing the sentence is about, so no
13
+ // occurrence count makes one a useful ledger term. Closed set, checked
14
+ // against the already-normalized (lowercased) term. bumpTerms uses this to
15
+ // keep a function word from ever being admitted; ledgerFromPayload uses it
16
+ // to drop one that reached a persisted payload before this filter existed.
17
+ const FUNCTION_WORD_TERMS = new Set([
18
+ // prepositions
19
+ "about", "above", "across", "after", "against", "along", "among", "around",
20
+ "at", "before", "behind", "below", "beneath", "beside", "between", "beyond",
21
+ "by", "despite", "down", "during", "except", "for", "from", "in", "into",
22
+ "near", "of", "off", "on", "onto", "out", "over", "since", "through",
23
+ "throughout", "to", "toward", "towards", "under", "underneath", "until",
24
+ "up", "upon", "with", "within", "without",
25
+ // conjunctions
26
+ "and", "or", "nor", "but", "so", "yet", "although", "because", "if",
27
+ "though", "unless", "when", "whenever", "whereas", "while", "than",
28
+ // degree and frequency adverbs
29
+ "very", "quite", "rather", "too", "just", "only", "even", "also", "still",
30
+ "already", "almost", "always", "never", "ever", "often", "sometimes",
31
+ "usually", "indeed", "however", "therefore", "thus", "hence", "meanwhile",
32
+ ]);
33
+
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));
70
+ }
71
+
11
72
  /** Ledger entry field order fixed once here so `ledgerPayload` serializes
12
73
  * byte-identically across peers regardless of insertion order elsewhere. */
13
74
  function newEntry(term, vocabGrounded, now) {
@@ -35,7 +96,7 @@ export function createTermLedger() {
35
96
  export function bumpTerms(ledger, termCounts, itemId, now, vocabGroundedByTerm = new Map()) {
36
97
  for (const [rawTerm, occurrences] of termCounts) {
37
98
  const term = normFactTerm(rawTerm);
38
- if (!term) continue;
99
+ if (!term || isNoiseTerm(term)) continue;
39
100
  let entry = ledger.terms.get(term);
40
101
  if (!entry) {
41
102
  const vocabGrounded = vocabGroundedByTerm.has(rawTerm)
@@ -112,6 +173,7 @@ export function ledgerPayload(ledger) {
112
173
  export function ledgerFromPayload(payload) {
113
174
  const ledger = createTermLedger();
114
175
  for (const entry of payload?.terms ?? []) {
176
+ if (isNoiseTerm(entry.term)) continue;
115
177
  ledger.terms.set(entry.term, { ...entry, itemIds: [...(entry.itemIds ?? [])] });
116
178
  }
117
179
  return ledger;
@@ -673,6 +673,32 @@ const FRAGMENT_LEAD_TAGS = new Set(["VERB", "AUX", "ADP", "CCONJ", "SCONJ", "PAR
673
673
  // which names nothing. Read lexically so a checkout with no wink model catches
674
674
  // it too, and only for a multi-word term — "back" alone is a fine noun.
675
675
  const PARTICLE_LEAD_WORDS = new Set(["back", "up", "down", "out", "off", "away", "along", "around"]);
676
+ // A pronoun names nothing on its own — it points back at whatever the last
677
+ // clause named — so a multi-word term opening with one is a clause the split
678
+ // lost the subject of ("he's also destroyed the city's soul"), never a name. A
679
+ // one-word term is exempt for the same reason the particle rule exempts one:
680
+ // "us" is also how a headline writes the United States.
681
+ const PRONOUN_LEAD_WORDS = new Set([
682
+ "i", "he", "she", "it", "we", "they", "you", "me", "him", "them", "us",
683
+ "his", "her", "its", "their", "our", "your", "my",
684
+ ]);
685
+ const CLITIC_SUFFIX_RE = /['’](?:s|re|ve|ll|d|m)$/;
686
+ // A term ending in a bare auxiliary is the front half of a clause the split cut
687
+ // ("rooms were"), never the whole of a name.
688
+ const TRAILING_AUXILIARY_WORDS = new Set([
689
+ "is", "are", "was", "were", "be", "been", "being", "am", "has", "have", "had",
690
+ ]);
691
+ // A compass word opening a place name is a modifier, not a clause lead —
692
+ // "north korea", "south sandwich islands". A tagger reading the LOWERCASED
693
+ // term has no capital left to tell the place from the direction and tags
694
+ // "north"/"south" as an adverb, so the POS rule below would turn every one of
695
+ // them down. Followed by "of" the word really is heading a prepositional
696
+ // phrase ("north of the border"), and that stays declined.
697
+ const COMPASS_LEAD_WORDS = new Set([
698
+ "north", "south", "east", "west",
699
+ "northeast", "northwest", "southeast", "southwest",
700
+ "northern", "southern", "eastern", "western",
701
+ ]);
676
702
 
677
703
  /** Does `term` read as a thing's name rather than a clause fragment? Bounds
678
704
  * the word count and rejects a leading conjunction, auxiliary, preposition,
@@ -689,6 +715,9 @@ export function readsAsEntityTerm(term, nlp) {
689
715
  if (FRAGMENT_LEAD_WORDS.has(first)) return false;
690
716
  if (words.length === 1) return true;
691
717
  if (PARTICLE_LEAD_WORDS.has(first)) return false;
718
+ if (PRONOUN_LEAD_WORDS.has(first.replace(CLITIC_SUFFIX_RE, ""))) return false;
719
+ if (TRAILING_AUXILIARY_WORDS.has(words[words.length - 1].toLowerCase())) return false;
720
+ if (COMPASS_LEAD_WORDS.has(first) && words[1].toLowerCase() !== "of") return true;
692
721
  const engine = nlp === undefined ? winkInstance() : nlp;
693
722
  if (!engine) return true;
694
723
  try {