@polycode-projects/the-mechanical-code-talker 6.0.17 → 6.0.18

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.17",
3
+ "version": "6.0.18",
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.",
@@ -150,16 +150,24 @@ const hasNonEmptyArray = (value) => Array.isArray(value) && value.length > 0;
150
150
  // after it.
151
151
  const provenanceHead = (provenance) => String(provenance || "").trim().split(/\s+/)[0] || "";
152
152
 
153
+ /** True when the graph reasoned this row out for itself rather than reading it
154
+ * somewhere — an entailment head, an environment, or a justification chain.
155
+ * A card never speaks from one: the subClassOf closure gives a common noun
156
+ * every parent class of every sense it has ("earthquake is a kind of
157
+ * cognition, a tentacle, a christians"), which is sound as inference and
158
+ * useless as a sentence about today's quake. */
159
+ export function isDerivedRow(row) {
160
+ return provenanceHead(row?.provenance).startsWith("entailed:")
161
+ || hasNonEmptyArray(row?.environments) || hasNonEmptyArray(row?.justification);
162
+ }
163
+
153
164
  /** "derived" | "background" | "reported" for one row, pure over the row plus
154
165
  * `now` (PLAN_NEWS_FEED.md section 17.3, step one). Rules apply in order,
155
166
  * first hit wins: a syllogised row is derived; an identity, universal or
156
167
  * non-news-provenance row is background; a news/news-fixture row with no
157
168
  * readable or in-window stamp is background; everything else is reported. */
158
169
  export function classifyNewsRow(row, { now, windowMs }) {
159
- if (provenanceHead(row.provenance).startsWith("entailed:")
160
- || hasNonEmptyArray(row.environments) || hasNonEmptyArray(row.justification)) {
161
- return "derived";
162
- }
170
+ if (isDerivedRow(row)) return "derived";
163
171
  if (GATE_IDENTITY_PREDICATES.has(row.predicate)) return "background";
164
172
  if (UNIVERSAL_QUANTIFIERS.has(String(row.quantifier || "").toLowerCase())) return "background";
165
173
  if (!REPORT_PROVENANCE_RE.test(String(row.provenance || ""))) return "background";
@@ -485,6 +493,26 @@ export function buildTermAdjacency(rows) {
485
493
  return { byTerm, terms };
486
494
  }
487
495
 
496
+ // A source names a place by settlement and region at once — "mina, nevada",
497
+ // "pedro bay, alaska", "san juan, puerto rico". The region is the trailing
498
+ // part, and it is the half the graph plausibly already holds facts about,
499
+ // while the joined term is new. So the walk seeds from the region as well as
500
+ // the whole name. The leading part stays out: "mina" the town and "mina" the
501
+ // myna bird are one string to a graph keyed on words, and the settlement half
502
+ // is exactly where that collision lands. Comma-separated only — splitting on
503
+ // spaces would turn "public investments fund" into three terms that name
504
+ // nothing.
505
+ export function hubSeedTerms(hub) {
506
+ const whole = normFactTerm(hub);
507
+ const seeds = [whole];
508
+ if (!whole.includes(",")) return seeds;
509
+ const region = normFactTerm(whole.split(",").pop());
510
+ if (!region || region === whole) return seeds;
511
+ if (STOP_SET.has(region) || isQuantityTerm(region) || !looksLikeEntityTerm(region)) return seeds;
512
+ seeds.push(region);
513
+ return seeds;
514
+ }
515
+
488
516
  /** Breadth-first over subject/object adjacency from `hub`, exactly `hops`
489
517
  * levels deep, then capped: a `priorityIds` row first, then the nearer hop,
490
518
  * then content-addressed id. The cap never depends on `rows`' own order, only
@@ -494,12 +522,20 @@ export function buildTermAdjacency(rows) {
494
522
  * `priorityIds` is what keeps a card about a term the graph already knows
495
523
  * thousands of things about from being built out of an arbitrary slice of
496
524
  * 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 } = {}) {
525
+ * report that made it news would otherwise be the row that fell out.
526
+ *
527
+ * `seedTerms` starts the walk from more than the hub itself (hubSeedTerms).
528
+ * Everything downstream that asks "is this the hub" — the report sentences,
529
+ * the sources, the neighbourhood — still reads the hub term alone, so a seed
530
+ * widens only what the card can draw background from. */
531
+ export function subgraphAround(rows, hub, {
532
+ hops = NEWS_HUB_HOPS, cap = 60, adjacency = null, priorityIds = null, seedTerms = null,
533
+ } = {}) {
499
534
  const adj = adjacency ?? buildTermAdjacency(rows);
500
535
  const hubTerm = normFactTerm(hub);
501
- const visited = new Set([hubTerm]);
502
- let frontier = [hubTerm];
536
+ const seeds = (seedTerms?.length ? seedTerms : [hubTerm]).map((t) => normFactTerm(t)).filter(Boolean);
537
+ const visited = new Set(seeds);
538
+ let frontier = [...new Set(seeds)].sort();
503
539
  const collected = new Map();
504
540
  const hopOf = new Map();
505
541
  for (let hop = 0; hop < hops; hop += 1) {
@@ -538,20 +574,37 @@ function tierOf(rows) {
538
574
  return kinds[0] || "";
539
575
  }
540
576
 
541
- function collectSources(subgraphRows, sourcesByFactId) {
577
+ // How much of an item's own summary a card carries. A wire description runs
578
+ // about a line; an encyclopaedia extract runs several paragraphs, and a feed
579
+ // of fifty cards has a byte budget to keep (MAX_FEED_DOCUMENT_BYTES). Cut at
580
+ // the last word boundary inside the bound so the quote ends on a word.
581
+ const SOURCE_SUMMARY_MAX_CHARS = 400;
582
+
583
+ function clampSummary(summary) {
584
+ const text = String(summary || "").trim();
585
+ if (text.length <= SOURCE_SUMMARY_MAX_CHARS) return text;
586
+ const cut = text.slice(0, SOURCE_SUMMARY_MAX_CHARS);
587
+ const lastSpace = cut.lastIndexOf(" ");
588
+ return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
589
+ }
590
+
591
+ function collectSources(citedRows, sourcesByFactId) {
542
592
  const get = (id) => (sourcesByFactId instanceof Map ? sourcesByFactId.get(id) : sourcesByFactId?.[id]);
543
593
  const seen = new Set();
544
594
  const sources = [];
545
- for (const row of subgraphRows) {
595
+ for (const row of citedRows) {
546
596
  const src = get(row.id);
547
597
  if (!src) continue;
548
598
  const key = src.url || src.title || "";
549
599
  if (!key || seen.has(key)) continue;
550
600
  seen.add(key);
551
601
  const entry = { title: src.title || "", url: src.url || "", name: src.name || "" };
552
- // Carried through only when the source snapshot actually has one — a
553
- // card for an undated snapshot shows no date rather than a blank field.
602
+ // Both of these ride along only when the snapshot actually has one — a
603
+ // card for an undated or summary-less snapshot shows nothing there rather
604
+ // than a blank field.
554
605
  if (src.publishedAt) entry.publishedAt = src.publishedAt;
606
+ const summary = clampSummary(src.summary);
607
+ if (summary) entry.summary = summary;
555
608
  sources.push(entry);
556
609
  }
557
610
  return sources;
@@ -563,14 +616,227 @@ function joinWithAnd(items) {
563
616
  return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
564
617
  }
565
618
 
619
+ // ---------------------------------------------------------------------------
620
+ // What one card reports, and whose neighbourhood it sits in.
621
+ // ---------------------------------------------------------------------------
622
+
623
+ /** An id membership test over a Set, an array, or null — null meaning "every
624
+ * row counts", which is what a caller with no reported-row set of its own
625
+ * (the background paragraph, a direct render call) needs. */
626
+ function idMembership(ids) {
627
+ if (ids === null || ids === undefined) return () => true;
628
+ if (ids instanceof Set) return (id) => ids.has(id);
629
+ return (id) => ids.includes(id);
630
+ }
631
+
632
+ const rowObservedRank = (row) => {
633
+ const t = rowObservedMs(row);
634
+ return Number.isFinite(t) ? t : 0;
635
+ };
636
+
637
+ /** The reported rows of `subgraphRows` that touch `hub` directly — what this
638
+ * card actually reports, and so what it may attribute a source to. A row
639
+ * further out in the two-hop walk was reached through a term the hub merely
640
+ * shares; it belongs to some other card's report. */
641
+ export function hubReportRows(hub, subgraphRows, { reportedIds = null } = {}) {
642
+ const hubTerm = normFactTerm(hub);
643
+ const isReported = idMembership(reportedIds);
644
+ return subgraphRows.filter((row) => isReported(row.id)
645
+ && (normFactTerm(row.subject) === hubTerm || normFactTerm(row.object) === hubTerm));
646
+ }
647
+
648
+ /** Every term sitting one edge from `hub` inside this card's own sub-graph. */
649
+ function hubLinkTerms(subgraphRows, hubTerm) {
650
+ const terms = new Set();
651
+ for (const row of subgraphRows) {
652
+ const s = normFactTerm(row.subject);
653
+ const o = normFactTerm(row.object);
654
+ if (s === hubTerm && o) terms.add(o);
655
+ else if (o === hubTerm && s) terms.add(s);
656
+ }
657
+ terms.delete(hubTerm);
658
+ return terms;
659
+ }
660
+
661
+ const NEIGHBOUR_ROW_LIMIT = 3;
662
+
663
+ /** This hub's own neighbourhood: the reported rows one further edge out,
664
+ * reached only through a link term specific enough to name a neighbourhood.
665
+ * A link term the clause cannot name in full — one reaching more rows than
666
+ * the sentence prints — is a category node, not a neighbour: "earthquake"
667
+ * sits between all 44 quakes of a day, so every quake card walked through it
668
+ * and printed the same arbitrary three. Naming a slice of a category is what
669
+ * made sibling cards identical, so a term over the clause's own capacity
670
+ * contributes nothing and a card with no specific link prints no "Around it"
671
+ * at all.
672
+ *
673
+ * Survivors rank by a predicate the hub's own report also used, then by
674
+ * observation time, then by id — this hub's choice, and a pure function of
675
+ * the fact set either way. */
676
+ export function neighbourRows(hub, subgraphRows, { reportedIds = null, limit = NEIGHBOUR_ROW_LIMIT } = {}) {
677
+ const hubTerm = normFactTerm(hub);
678
+ const isReported = idMembership(reportedIds);
679
+ const linkTerms = hubLinkTerms(subgraphRows, hubTerm);
680
+
681
+ const reachedByLinkTerm = new Map();
682
+ for (const row of subgraphRows) {
683
+ const s = normFactTerm(row.subject);
684
+ const o = normFactTerm(row.object);
685
+ if (s === hubTerm || o === hubTerm || !isReported(row.id)) continue;
686
+ for (const term of new Set([s, o])) {
687
+ if (!term || !linkTerms.has(term)) continue;
688
+ let reached = reachedByLinkTerm.get(term);
689
+ if (!reached) reachedByLinkTerm.set(term, (reached = []));
690
+ reached.push(row);
691
+ }
692
+ }
693
+
694
+ const candidates = new Map();
695
+ for (const [, reached] of reachedByLinkTerm) {
696
+ if (reached.length > limit) continue;
697
+ for (const row of reached) candidates.set(row.id, row);
698
+ }
699
+
700
+ const hubPredicates = new Set(hubReportRows(hub, subgraphRows, { reportedIds }).map((r) => r.predicate));
701
+ return [...candidates.values()]
702
+ .sort((a, b) => (Number(hubPredicates.has(b.predicate)) - Number(hubPredicates.has(a.predicate)))
703
+ || (rowObservedRank(b) - rowObservedRank(a))
704
+ || byId(a, b))
705
+ .slice(0, limit);
706
+ }
707
+
566
708
  const IDENTITY_PREDICATES = new Set(["rdf:type", "rdfs:subClassOf"]);
567
- const SENTENCE_CAP = 5;
709
+ const SENTENCE_CAP = 6;
710
+ // The four blocks a card's paragraph is built from, in the order they read:
711
+ // what was reported, then what the thing is, then what the graph already knew
712
+ // about it, then its neighbourhood. Their caps add up past SENTENCE_CAP on
713
+ // purpose — the paragraph fills from the front, so a news-rich card spends its
714
+ // budget on the news and a quiet one spends it on background.
715
+ const REPORT_SENTENCE_CAP = 4;
716
+ const IDENTITY_SENTENCE_CAP = 1;
717
+ const KNOWN_FACT_SENTENCE_CAP = 2;
568
718
  // How many objects one sentence names before it counts the rest. A live source
569
719
  // reports the same relation over and over inside one window — every quake of
570
720
  // the day strikes near somewhere — and an unbounded list turns a card into a
571
721
  // wall of text.
572
722
  const OBJECTS_PER_SENTENCE = 6;
573
723
 
724
+ // A term the graph says is more than this many things is read across senses:
725
+ // "earthquake" is a natural event, a cognition, a social station and nine more,
726
+ // so no single class line about it is trustworthy on a card about one quake.
727
+ // Same discipline as the neighbourhood's own category-node test — a node too
728
+ // wide for one clause to name in full says nothing — applied to senses instead
729
+ // of edges.
730
+ const IDENTITY_MAX_CLASSES = 2;
731
+ // A background line's far side, when this many terms in the sub-graph already
732
+ // fall under it, names a category rather than anything about this card:
733
+ // "france is related to place" is true of most of the graph. The hub's own
734
+ // identity clause is exempt — a crowded class is still this thing's own kind,
735
+ // and "france is a country" is the most useful line a card can carry.
736
+ const CATEGORY_FAN_MAX = 3;
737
+ // How many background rows the "what the graph already knew" disclosure names.
738
+ // The paragraph itself shows the first KNOWN_FACT_SENTENCE_CAP groups of these.
739
+ const KNOWN_FACT_ROW_LIMIT = 6;
740
+
741
+ /** Three fan-out readings of the identity rows inside one card's sub-graph.
742
+ * `senseFan` counts every class a term is said to BE, the entailment closure
743
+ * included — high means the graph reads the term across senses, which is what
744
+ * makes a common noun a poor subject for a card about one event.
745
+ * `sourcedSenseFan` counts only the classes something actually stated, which
746
+ * is what a printed "X is a Y" clause may draw on. `categoryFan` counts the
747
+ * distinct terms said to fall UNDER a term — high means a category node, the
748
+ * same reading the neighbourhood's own link-term test makes. All three are
749
+ * pure counts over the rows handed in, so a card's own sub-graph decides and
750
+ * no global blocklist is involved. */
751
+ function identityFans(subgraphRows) {
752
+ const senseFan = new Map();
753
+ const sourcedSenseFan = new Map();
754
+ const membersByClass = new Map();
755
+ for (const row of subgraphRows) {
756
+ if (!IDENTITY_PREDICATES.has(row.predicate)) continue;
757
+ const s = normFactTerm(row.subject);
758
+ const o = normFactTerm(row.object);
759
+ if (!s || !o) continue;
760
+ senseFan.set(s, (senseFan.get(s) || 0) + 1);
761
+ if (!isDerivedRow(row)) sourcedSenseFan.set(s, (sourcedSenseFan.get(s) || 0) + 1);
762
+ let members = membersByClass.get(o);
763
+ if (!members) membersByClass.set(o, (members = new Set()));
764
+ members.add(s);
765
+ }
766
+ const categoryFan = new Map();
767
+ for (const [term, members] of membersByClass) categoryFan.set(term, members.size);
768
+ return { senseFan, sourcedSenseFan, categoryFan };
769
+ }
770
+
771
+ /** What this card is ABOUT: its hub, the region of a hub the source spelled
772
+ * "settlement, region", and the other terms its own report sentences name —
773
+ * minus any of those that reads across senses. A quake card's report
774
+ * names "earthquake" and a place; the class term is where the graph's
775
+ * knowledge is thinnest and its senses widest, so background drawn through it
776
+ * is background about earthquakes in general, never about this quake. The hub
777
+ * itself is always in, whatever its sense count — the card is about it. */
778
+ function cardSubjectTerms(hub, subgraphRows, { reportedIds = null, senseFan }) {
779
+ const hubTerm = normFactTerm(hub);
780
+ const isReported = idMembership(reportedIds);
781
+ const terms = new Set(hubSeedTerms(hubTerm));
782
+ for (const row of hubReportRows(hubTerm, subgraphRows, { reportedIds })) {
783
+ if (!isReported(row.id)) continue;
784
+ for (const raw of [row.subject, row.object]) {
785
+ const term = normFactTerm(raw);
786
+ if (!term || terms.has(term) || STOP_SET.has(term)) continue;
787
+ if ((senseFan.get(term) || 0) > IDENTITY_MAX_CLASSES) continue;
788
+ terms.add(term);
789
+ }
790
+ }
791
+ return terms;
792
+ }
793
+
794
+ /** The background rows worth telling a reader about, ranked: a row touching
795
+ * one of this card's own subject terms, whose other side is not a category
796
+ * node, and — for an identity row — whose subject is not read across senses.
797
+ * Ranks the hub's own rows first, then by how specific the other side is,
798
+ * then by content-addressed id, so the same fact set always yields the same
799
+ * lines in the same order. */
800
+ export function knownFactRows(hub, subgraphRows, { reportedIds = null, limit = KNOWN_FACT_ROW_LIMIT } = {}) {
801
+ const hubTerm = normFactTerm(hub);
802
+ const isReported = idMembership(reportedIds);
803
+ const { senseFan, sourcedSenseFan, categoryFan } = identityFans(subgraphRows);
804
+ const subjects = cardSubjectTerms(hub, subgraphRows, { reportedIds, senseFan });
805
+ const neighbourIds = new Set(neighbourRows(hub, subgraphRows, { reportedIds }).map((r) => r.id));
806
+
807
+ const scored = [];
808
+ for (const row of subgraphRows) {
809
+ if (isReported(row.id) || neighbourIds.has(row.id) || isDerivedRow(row)) continue;
810
+ const s = normFactTerm(row.subject);
811
+ const o = normFactTerm(row.object);
812
+ if (!s || !o || s === o) continue;
813
+ const anchorIsSubject = subjects.has(s);
814
+ if (!anchorIsSubject && !subjects.has(o)) continue;
815
+ const anchor = anchorIsSubject ? s : o;
816
+ const other = anchorIsSubject ? o : s;
817
+ if (IDENTITY_PREDICATES.has(row.predicate)) {
818
+ // What the hub itself IS belongs to the identity clause and is said once.
819
+ if (s === hubTerm) continue;
820
+ if ((sourcedSenseFan.get(s) || 0) > IDENTITY_MAX_CLASSES) continue;
821
+ }
822
+ if ((categoryFan.get(other) || 0) > CATEGORY_FAN_MAX) continue;
823
+ scored.push({
824
+ row,
825
+ anchorIsHub: anchor === hubTerm,
826
+ otherCategoryFan: categoryFan.get(other) || 0,
827
+ otherSenseFan: senseFan.get(other) || 0,
828
+ });
829
+ }
830
+
831
+ return scored
832
+ .sort((a, b) => (Number(b.anchorIsHub) - Number(a.anchorIsHub))
833
+ || (a.otherCategoryFan - b.otherCategoryFan)
834
+ || (a.otherSenseFan - b.otherSenseFan)
835
+ || byId(a.row, b.row))
836
+ .slice(0, limit)
837
+ .map((entry) => entry.row);
838
+ }
839
+
574
840
  function joinObjects(objects) {
575
841
  if (objects.length <= OBJECTS_PER_SENTENCE) return joinWithAnd(objects);
576
842
  const shown = objects.slice(0, OBJECTS_PER_SENTENCE);
@@ -588,75 +854,111 @@ function predicatesInRenderOrder(rows) {
588
854
  return [...curated, ...rest];
589
855
  }
590
856
 
591
- /** The fixed five-sentence paraphrase template (PLAN_NEWS_FEED.md section
592
- * 8.3): identity first, then the hub's own relations grouped by predicate in
593
- * FACT_PREDICATE_PHRASES table order, then one closing sentence naming up to
594
- * three second-hop facts. Every sentence shown is a grounded fact, never a
595
- * paraphrase of prose the grammar could not read.
596
- *
597
- * `reportedIds` (PLAN_NEWS_FEED.md section 17.4), when given, restricts the
598
- * relation sentences and the closing "Around it" sentence to rows in that
599
- * set the identity sentence keeps drawing from every row it's handed,
600
- * since "what is this thing" is the first question a reader has regardless
601
- * of who reported it. Defaults to null, meaning every row renders, so every
602
- * existing caller and pin is unaffected. */
603
- export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } = {}) {
857
+ /** One sentence per (subject, predicate) group over `rows`, in the order the
858
+ * rows arrive the same shape the hub's own relation sentences take, so a
859
+ * background line reads like the rest of the paragraph rather than a dump. */
860
+ function groupedFactSentences(rows) {
861
+ const groups = new Map();
862
+ for (const row of rows) {
863
+ const key = `${row.subject}${row.predicate}`;
864
+ let group = groups.get(key);
865
+ if (!group) groups.set(key, (group = { subject: row.subject, predicate: row.predicate, objects: [] }));
866
+ group.objects.push(row.object);
867
+ }
868
+ return [...groups.values()].map(({ subject, predicate, objects }) => {
869
+ const sorted = [...objects].sort();
870
+ const rendered = IDENTITY_PREDICATES.has(predicate)
871
+ ? sorted.map((object) => `${articleFor(object)} ${object}`)
872
+ : sorted;
873
+ return `${subject} ${predicatePhrase(predicate, subject)} ${joinObjects(rendered)}`;
874
+ });
875
+ }
876
+
877
+ /** The sentences a card's paragraph is made of, as four ordered blocks: the
878
+ * report (what a source said inside the window), the identity clause, the
879
+ * related facts the graph already held, and the neighbourhood. Callers that
880
+ * render only one block — the "what the graph already knew" disclosure —
881
+ * read the block they want instead of re-deriving it. */
882
+ function paragraphBlocks(hub, subgraphRows, { reportedIds = null } = {}) {
604
883
  const hubTerm = normFactTerm(hub);
605
- const isReported = reportedIds === null
606
- ? () => true
607
- : (id) => (reportedIds instanceof Set ? reportedIds.has(id) : reportedIds.includes(id));
884
+ const isReported = idMembership(reportedIds);
608
885
  const hubRows = subgraphRows.filter((r) => normFactTerm(r.subject) === hubTerm);
609
886
  const reportedHubRows = hubRows.filter((r) => isReported(r.id));
610
- const secondHopRows = subgraphRows.filter(
611
- (r) => normFactTerm(r.subject) !== hubTerm && normFactTerm(r.object) !== hubTerm && isReported(r.id),
612
- );
613
-
614
- const sentences = [];
615
-
616
- const identityObjects = hubRows
617
- .filter((r) => IDENTITY_PREDICATES.has(r.predicate))
618
- .map((r) => r.object)
619
- .sort();
620
- if (identityObjects.length) {
621
- const withArticles = identityObjects.map((object) => `${articleFor(object)} ${object}`);
622
- sentences.push(`${hub} is ${joinObjects(withArticles)}`);
623
- }
624
-
887
+ const report = [];
625
888
  for (const predicate of predicatesInRenderOrder(reportedHubRows)) {
626
- if (IDENTITY_PREDICATES.has(predicate) || sentences.length >= SENTENCE_CAP) continue;
889
+ if (IDENTITY_PREDICATES.has(predicate) || report.length >= REPORT_SENTENCE_CAP) continue;
627
890
  const objects = reportedHubRows
628
891
  .filter((r) => r.predicate === predicate)
629
892
  .map((r) => r.object)
630
893
  .sort();
631
894
  if (!objects.length) continue;
632
- sentences.push(`${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`);
895
+ report.push(`${hub} ${predicatePhrase(predicate, hub)} ${joinObjects(objects)}`);
633
896
  }
634
897
 
635
898
  // A hub that only ever appears as an OBJECT — the place a quake struck, the
636
899
  // story a site discussed — has no subject-side row to build a sentence from,
637
900
  // and its card came out blank. What was reported about it still says
638
901
  // something, so those rows render whole, subject and all.
639
- if (!sentences.length) {
902
+ if (!report.length) {
640
903
  const aboutHub = subgraphRows
641
904
  .filter((r) => normFactTerm(r.object) === hubTerm && normFactTerm(r.subject) !== hubTerm && isReported(r.id))
642
905
  .sort(byId)
643
906
  .slice(0, OBJECTS_PER_SENTENCE)
644
907
  .map((r) => factSentence(r));
645
- if (aboutHub.length) sentences.push(aboutHub.join("; "));
908
+ if (aboutHub.length) report.push(aboutHub.join("; "));
646
909
  }
647
910
 
648
- if (sentences.length < SENTENCE_CAP && secondHopRows.length) {
649
- const around = secondHopRows
650
- .slice()
651
- .sort(byId)
652
- .slice(0, 3)
653
- .map((r) => factSentence(r))
654
- .join("; ");
655
- sentences.push(`Around it: ${around}`);
911
+ // The identity clause follows the news, never leads it, and only when
912
+ // something actually stated the hub's kind in one sense. The entailment
913
+ // closure names thirteen classes for "france" — a list nobody asked for,
914
+ // most of it the wrong sense — so it opens no card.
915
+ const identity = [];
916
+ const identityObjects = hubRows
917
+ .filter((r) => IDENTITY_PREDICATES.has(r.predicate) && !isDerivedRow(r))
918
+ .map((r) => r.object)
919
+ .sort();
920
+ const identityIsSingleSense = identityObjects.length > 0 && identityObjects.length <= IDENTITY_MAX_CLASSES;
921
+ if (identityIsSingleSense) {
922
+ identity.push(`${hub} is ${joinObjects(identityObjects.map((object) => `${articleFor(object)} ${object}`))}`);
656
923
  }
657
924
 
658
- const capped = sentences.slice(0, SENTENCE_CAP);
659
- return capped.length ? `${capped.join(". ")}.` : "";
925
+ const known = groupedFactSentences(knownFactRows(hub, subgraphRows, { reportedIds }));
926
+
927
+ const neighbours = neighbourRows(hub, subgraphRows, { reportedIds });
928
+ const around = neighbours.length ? [`Around it: ${neighbours.map((r) => factSentence(r)).join("; ")}`] : [];
929
+
930
+ return { report, identity, known, around };
931
+ }
932
+
933
+ /** A card's paragraph: what a source reported, then what the thing is, then
934
+ * the related facts the graph already held about it, then its own
935
+ * neighbourhood (neighbourRows). Every sentence shown is a grounded fact,
936
+ * never a paraphrase of prose the grammar could not read, and the news always
937
+ * leads.
938
+ *
939
+ * `reportedIds` (PLAN_NEWS_FEED.md section 17.4), when given, splits the rows
940
+ * into what was reported (the lead sentences) and what the graph already held
941
+ * (the background ones). Defaults to null, meaning every row counts as
942
+ * reported. */
943
+ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } = {}) {
944
+ const { report, identity, known, around } = paragraphBlocks(hub, subgraphRows, { reportedIds });
945
+ const sentences = [
946
+ ...report,
947
+ ...identity.slice(0, IDENTITY_SENTENCE_CAP),
948
+ ...known.slice(0, KNOWN_FACT_SENTENCE_CAP),
949
+ ...around,
950
+ ].slice(0, SENTENCE_CAP);
951
+ return sentences.length ? `${sentences.join(". ")}.` : "";
952
+ }
953
+
954
+ /** The "what the graph already knew" disclosure: the same related facts the
955
+ * paragraph leads with, at the disclosure's own fuller depth, and nothing the
956
+ * card already reported. Empty when the graph held nothing about this card's
957
+ * own subjects — a card with no background says so rather than filling the
958
+ * space with whatever the two-hop walk happened to reach. */
959
+ export function renderKnownFactsParagraph(hub, subgraphRows, { reportedIds = null } = {}) {
960
+ const sentences = groupedFactSentences(knownFactRows(hub, subgraphRows, { reportedIds }));
961
+ return sentences.length ? `${sentences.join(". ")}.` : "";
660
962
  }
661
963
 
662
964
  /** newsworthyHubs -> one item per hub (PLAN_NEWS_FEED.md section 6.6),
@@ -666,9 +968,16 @@ export function renderNewsParagraph(hub, subgraphRows, { reportedIds = null } =
666
968
  * The gate (PLAN_NEWS_FEED.md section 17): `reportedRows` replaces `newsWindowRows`
667
969
  * and `newsworthyHubs` replaces `scoreHubs` as this function's own inputs —
668
970
  * both keep their prior behaviour for every other caller. Each item's
669
- * two-hop sub-graph then splits into its own `reported`/`background` rows,
670
- * so a card's paragraph draws from what was reported and its collapsed
671
- * `backgroundParagraph` draws from what the graph already knew. */
971
+ * two-hop sub-graph then splits into its own `reported`/`background` rows:
972
+ * the report leads the paragraph, the related background follows it, and
973
+ * `backgroundParagraph` names that background at its own fuller depth for the
974
+ * card's disclosure.
975
+ *
976
+ * A card attributes a source to the rows it reports (hubReportRows) and to
977
+ * nothing else. The two-hop walk reaches every row a shared class node
978
+ * touches, so attributing the whole sub-graph gave one quake's card all 44 of
979
+ * the day's quake headlines. An "Around it" neighbour is context the card
980
+ * borrows, and it carries its own citation on its own card. */
672
981
  export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId = new Map(), readsAsEntityTerm } = {}) {
673
982
  const reported = reportedRows(rows, { now, windowMs });
674
983
  const reportedIds = new Set(reported.map((r) => r.id));
@@ -678,7 +987,9 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
678
987
  if (readsAsEntityTerm) hubOptions.readsAsEntityTerm = readsAsEntityTerm;
679
988
  const hubs = newsworthyHubs(rows, reported, hubOptions);
680
989
  const items = hubs.map(({ term, changed }) => {
681
- const subgraphRows = subgraphAround(rows, term, { adjacency, priorityIds: reportedIds });
990
+ const subgraphRows = subgraphAround(rows, term, {
991
+ adjacency, priorityIds: reportedIds, seedTerms: hubSeedTerms(term),
992
+ });
682
993
  const factIds = subgraphRows.map((r) => r.id).sort();
683
994
  const { background } = splitCardRows(subgraphRows, reportedIds);
684
995
  return {
@@ -689,9 +1000,9 @@ export function buildNewsItems(rows, { now, windowMs, limit = 6, sourcesByFactId
689
1000
  builtAt: now,
690
1001
  paragraph: renderNewsParagraph(term, subgraphRows, { reportedIds }),
691
1002
  tier: tierOf(subgraphRows),
692
- sources: collectSources(subgraphRows, sourcesByFactId),
1003
+ sources: collectSources(hubReportRows(term, subgraphRows, { reportedIds }), sourcesByFactId),
693
1004
  background: background.map((r) => r.id).sort(),
694
- backgroundParagraph: renderNewsParagraph(term, background),
1005
+ backgroundParagraph: renderKnownFactsParagraph(term, subgraphRows, { reportedIds }),
695
1006
  };
696
1007
  });
697
1008
  return items.sort((a, b) => (toMs(b.builtAt) - toMs(a.builtAt)) || byId(a, b));
@@ -150,6 +150,12 @@ ${THEME_TOKENS_CSS}
150
150
  .item .tier { font-family: ${MONO_STACK}; font-size: .64rem; padding: .05rem .5rem; border-radius: 99px; border: 1px solid var(--line); margin-left: .5rem; }
151
151
  .item .newtag { font-family: ${MONO_STACK}; font-size: .64rem; color: var(--taught); margin-left: .5rem; }
152
152
  .item .paragraph { margin: .4rem 0; }
153
+ .item .report { margin: .5rem 0; padding: .35rem 0 .35rem .7rem; border-left: 2px solid var(--line); }
154
+ .item .report + .report { margin-top: .35rem; }
155
+ .item .reportheadline { display: block; font-weight: 600; font-size: .88rem; }
156
+ .item .reportsummary { margin: .2rem 0 0; font-size: .84rem; color: var(--ink); }
157
+ .item .reportcite { display: block; margin-top: .2rem; font-family: ${MONO_STACK}; font-size: .64rem; color: var(--muted); font-style: normal; }
158
+ .item .reportmore { font-size: .74rem; color: var(--muted); }
153
159
  .item .sources-links { font-size: .74rem; color: var(--muted); }
154
160
  .item .sourcedate { font-family: ${MONO_STACK}; }
155
161
  .item details.facts summary { font-family: ${MONO_STACK}; font-size: .68rem; color: var(--corpus); cursor: pointer; }
@@ -498,6 +504,30 @@ ${NEWS_STYLE}
498
504
  return earliest;
499
505
  }
500
506
 
507
+ // How many backing reports a card sets out in full before it counts the
508
+ // rest. After the per-card attribution cut a card cites one or two items;
509
+ // the shared-subject card ("earthquake") still cites dozens, and quoting
510
+ // every one of them buries its own paragraph.
511
+ var REPORTS_SHOWN_PER_CARD = 3;
512
+
513
+ // The report a card was built from, in the source's own framing: its
514
+ // headline, its description, and who filed it when. Set off from the
515
+ // graph's own sentences so a reader can always tell which is which.
516
+ function reportBlockHtml(sources) {
517
+ const shown = (sources || []).slice(0, REPORTS_SHOWN_PER_CARD);
518
+ var html = shown.map(function (s) {
519
+ const headline = s.title ? '<span class="reportheadline">' + esc(s.title) + "</span>" : "";
520
+ const summary = s.summary ? '<p class="reportsummary">' + esc(s.summary) + "</p>" : "";
521
+ if (!headline && !summary) return "";
522
+ const cite = [s.name, s.publishedAt ? String(s.publishedAt).slice(0, 10) : ""].filter(Boolean).join(", ");
523
+ return '<blockquote class="report">' + headline + summary
524
+ + (cite ? '<cite class="reportcite">' + esc(cite) + "</cite>" : "") + "</blockquote>";
525
+ }).join("");
526
+ const more = (sources || []).length - shown.length;
527
+ if (more > 0) html += '<p class="reportmore">&hellip;and ' + more + ' more report' + (more === 1 ? "" : "s") + "</p>";
528
+ return html;
529
+ }
530
+
501
531
  function cardHtml(item) {
502
532
  const factLines = item.factLines || [];
503
533
  const factsHtml = factLines.map(function (line) { return '<div class="factrow">' + esc(line) + '</div>'; }).join("");
@@ -513,6 +543,7 @@ ${NEWS_STYLE}
513
543
  return '<div class="item" data-item-id="' + esc(item.id) + '">'
514
544
  + '<span class="hub">' + esc(item.hub) + '</span><span class="tier">' + esc(item.tier || "unranked") + '</span>' + newTag
515
545
  + '<p class="paragraph">' + esc(item.paragraph) + '</p>'
546
+ + reportBlockHtml(item.sources)
516
547
  + background
517
548
  + (sourcesText ? '<p class="sources-links">sources: ' + sourcesText + dateText + '</p>' : "")
518
549
  + '<details class="facts"><summary>' + (item.factCount || 0) + ' fact' + (item.factCount === 1 ? "" : "s") + '</summary>' + factsHtml + moreHtml + '</details>'
@@ -287,7 +287,15 @@ function buildSourcesByFactId(items) {
287
287
  const recordsById = new Map(newsSourceRecords().map((r) => [r.id, r]));
288
288
  for (const snap of items || []) {
289
289
  const record = recordsById.get(snap.sourceId);
290
- const src = { title: snap.title || "", url: snap.url || "", name: record?.name || snap.sourceId || "" };
290
+ const src = {
291
+ title: snap.title || "",
292
+ url: snap.url || "",
293
+ name: record?.name || snap.sourceId || "",
294
+ // The item's own description, carried so a card can show the report it
295
+ // was built from beside the sentences the graph read out of it. Bounded
296
+ // downstream (collectSources), never rewritten here.
297
+ summary: snap.summary || "",
298
+ };
291
299
  // A snapshot with no publication timestamp (a source whose own feed never
292
300
  // carries one) leaves the key off entirely — never an invented or blank
293
301
  // date, and never the fetch's own clock standing in for it.
@@ -926,7 +934,13 @@ function renderFeedText(feed, focus) {
926
934
  const sourceNames = it.sources.map((s) => s.title || s.url).filter(Boolean);
927
935
  const sourcesText = sourceNames.length ? ` sources: ${sourceNames.join(", ")}` : "";
928
936
  const backgroundText = it.backgroundParagraph ? ` what the graph already knew: ${it.backgroundParagraph}` : "";
929
- return `${i + 1}. ${it.paragraph} (${it.tier || "unranked"})${sourcesText}${seedTag}${backgroundText}`;
937
+ // The report the card was built from, quoted, so a reader can check the
938
+ // graph's own sentences against what the source actually filed.
939
+ const filed = it.sources
940
+ .map((s) => [s.title, s.summary].filter(Boolean).join(" — "))
941
+ .filter(Boolean);
942
+ const filedText = filed.length ? ` as filed: "${filed[0]}"` : "";
943
+ return `${i + 1}. ${it.paragraph} (${it.tier || "unranked"})${filedText}${sourcesText}${seedTag}${backgroundText}`;
930
944
  })
931
945
  .join("\n");
932
946
  }