@polycode-projects/the-mechanical-code-talker 3.0.5 → 3.0.7

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.
Files changed (68) hide show
  1. package/README.md +41 -16
  2. package/bin/tmct.mjs +1 -1
  3. package/corpus/child/README.md +2 -2
  4. package/corpus/child/index.json.gz +0 -0
  5. package/corpus/child/manifest.json +74 -74
  6. package/corpus/child/shards/child-00.jsonl.gz +0 -0
  7. package/corpus/child/shards/child-01.jsonl.gz +0 -0
  8. package/corpus/child/shards/child-02.jsonl.gz +0 -0
  9. package/corpus/child/shards/child-03.jsonl.gz +0 -0
  10. package/corpus/child/shards/child-04.jsonl.gz +0 -0
  11. package/corpus/child/shards/child-05.jsonl.gz +0 -0
  12. package/corpus/child/shards/child-06.jsonl.gz +0 -0
  13. package/corpus/child/shards/child-07.jsonl.gz +0 -0
  14. package/corpus/child/shards/child-08.jsonl.gz +0 -0
  15. package/corpus/child/shards/child-09.jsonl.gz +0 -0
  16. package/corpus/child/shards/child-0a.jsonl.gz +0 -0
  17. package/corpus/child/shards/child-0b.jsonl.gz +0 -0
  18. package/corpus/child/shards/child-0c.jsonl.gz +0 -0
  19. package/corpus/child/shards/child-0d.jsonl.gz +0 -0
  20. package/corpus/child/shards/child-0e.jsonl.gz +0 -0
  21. package/corpus/child/shards/child-0f.jsonl.gz +0 -0
  22. package/corpus/child/shards/child-10.jsonl.gz +0 -0
  23. package/corpus/child/shards/child-11.jsonl.gz +0 -0
  24. package/corpus/child/shards/child-12.jsonl.gz +0 -0
  25. package/corpus/child/shards/child-13.jsonl.gz +0 -0
  26. package/corpus/child/shards/child-14.jsonl.gz +0 -0
  27. package/corpus/child/shards/child-15.jsonl.gz +0 -0
  28. package/corpus/child/shards/child-16.jsonl.gz +0 -0
  29. package/corpus/child/shards/child-17.jsonl.gz +0 -0
  30. package/corpus/child/shards/child-18.jsonl.gz +0 -0
  31. package/corpus/child/shards/child-19.jsonl.gz +0 -0
  32. package/corpus/child/shards/child-1a.jsonl.gz +0 -0
  33. package/corpus/child/shards/child-1b.jsonl.gz +0 -0
  34. package/corpus/child/shards/child-1c.jsonl.gz +0 -0
  35. package/corpus/child/shards/child-1d.jsonl.gz +0 -0
  36. package/corpus/child/shards/child-1e.jsonl.gz +0 -0
  37. package/corpus/child/shards/child-1f.jsonl.gz +0 -0
  38. package/corpus/conceptnet/quality-filter.mjs +28 -4
  39. package/corpus/tier2/generate.mjs +4 -8
  40. package/corpus/tier2/human-large.jsonl +0 -2
  41. package/corpus/tier2/human-medium.jsonl +0 -2
  42. package/corpus/tier2/manifest.json +6 -6
  43. package/data/templates/constructions/digest-sentence-structures.toml +146 -1
  44. package/package.json +18 -15
  45. package/src/adapters/corpus/digest-bank.mjs +15 -3
  46. package/src/domain/answer-variants.json +1 -1
  47. package/src/domain/ask-vocab.mjs +4 -0
  48. package/src/domain/ask.mjs +101 -12
  49. package/src/domain/codegraph.mjs +25 -8
  50. package/src/domain/digest/compose.mjs +142 -10
  51. package/src/domain/digest/config.json +5 -0
  52. package/src/domain/digest/structures.mjs +39 -14
  53. package/src/domain/interpret/normalize.mjs +2 -2
  54. package/src/domain/reference-pack.mjs +12 -3
  55. package/src/domain/router/call-validator.mjs +1 -1
  56. package/src/domain/router/drive.mjs +2 -2
  57. package/src/domain/router/set-algebra.mjs +1 -1
  58. package/src/services/adventure.mjs +34 -19
  59. package/src/services/chat-page-viz.mjs +16 -5
  60. package/src/services/chat.mjs +290 -88
  61. package/src/services/extract-facts.mjs +46 -4
  62. package/src/services/index.mjs +6 -3
  63. package/src/services/ledger-viz.mjs +1 -1
  64. package/src/services/research.mjs +80 -3
  65. package/src/surfaces/web/chat-browser-entry.mjs +23 -3
  66. package/src/surfaces/web/digest-client.mjs +8 -3
  67. package/src/surfaces/web/graph-ask-browser-entry.mjs +41 -0
  68. package/src/surfaces/web/memory-ask-browser.bundle.js +113 -113
@@ -15,7 +15,7 @@
15
15
  // ask.mjs's own `touches`/`cochange` verbs answer one-hop structural edges
16
16
  // (mgx:touchedByCommit / mgx:changeCoupledWith).
17
17
 
18
- import { relationKind, impactClosure, moduleCountOf, normPath, HISTORY_CAP } from "./codegraph.mjs";
18
+ import { relationKind, impactClosure, moduleCountOf, normPath, packageCounts, modulesOf, HISTORY_CAP } from "./codegraph.mjs";
19
19
  import { isTestPath } from "./module-paths.mjs";
20
20
  import {
21
21
  RELATIONS,
@@ -84,6 +84,7 @@ const PLURAL_FORMS = {
84
84
  Attribute: ["attribute", "attributes"], GlobalVariable: ["variable", "variables"],
85
85
  Commit: ["commit", "commits"],
86
86
  Change: ["change", "changes"],
87
+ Package: ["package", "packages"],
87
88
  Fact: ["fact", "facts"], Utterance: ["utterance", "utterances"],
88
89
  Session: ["session", "sessions"], Source: ["source", "sources"], Rule: ["rule", "rules"],
89
90
  };
@@ -92,6 +93,23 @@ function nounFor(entityType, n) {
92
93
  return n === 1 ? s : p;
93
94
  }
94
95
 
96
+ /** A discourse `set` referent for a class-homogeneous result list — the typed
97
+ * content a listing/filter answer establishes, so a later "which of those …"
98
+ * binds it (see evalCommitFilter for the shape the session layer registers).
99
+ * Returns an empty array when nothing typed can be registered: no member
100
+ * class, or an empty result. `extra` carries a lane's own sibling flags (the
101
+ * anaphora lane's `bound: true`). */
102
+ function setReferentsFor(cls, matches, lane, extra = {}) {
103
+ if (!cls || !matches.length) return [];
104
+ return [{
105
+ kind: "set", class: cls,
106
+ label: `${matches.length} ${nounFor(cls, matches.length)}`,
107
+ ids: matches.map((m) => m.id),
108
+ attrs: { count: matches.length },
109
+ lane, ...extra,
110
+ }];
111
+ }
112
+
95
113
  /** A class enum rendered as prose words ("GlobalVariable" -> "global
96
114
  * variable"), lowercase to match nounFor's own convention. For the render
97
115
  * sites that must name the enum itself rather than a curated PLURAL_FORMS
@@ -834,7 +852,7 @@ function parseAggregate(w, lc, nlp) {
834
852
 
835
853
  const LIST_SKIP = new Set(["the", "a", "an", "all", "me", "us"]);
836
854
  const LIST_TRIGGERS_SORTED = [...LIST_TRIGGERS].sort((a, b) => b.split(" ").length - a.split(" ").length);
837
- const LISTABLE_KINDS = "functions, classes, methods, modules, attributes, variables, or commits";
855
+ const LISTABLE_KINDS = "functions, classes, methods, modules, packages, attributes, variables, or commits";
838
856
  // A leading "in"/"inside"/"under" right after the entity noun is an
839
857
  // unambiguous location-scope tail, never a reverse-clause predicate object.
840
858
  const SCOPE_PREPOSITIONS = new Set(["in", "inside", "under"]);
@@ -1621,11 +1639,25 @@ function computeFind(graph, entityType, term) {
1621
1639
  return { narrow: [], broad: sortFindHits([...broadHits.values()]) };
1622
1640
  }
1623
1641
 
1642
+ /** The graph's packages as list/count-shaped individuals. Packages are derived
1643
+ * from module labels rather than stored as nodes (see codegraph.mjs's
1644
+ * packageCounts), so these carry a `pkg:` id of their own and resolve to
1645
+ * nothing in graph.byId — a follow-up that tries to traverse one gets an
1646
+ * empty result, never a wrong one. Ordered by module count, the same order
1647
+ * the architecture map prints, so the two surfaces agree. */
1648
+ function packageIndividuals(graph) {
1649
+ return [...packageCounts(modulesOf(graph)).entries()]
1650
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
1651
+ .map(([dir]) => ({ id: `pkg:${dir}`, label: dir, class: "Package" }));
1652
+ }
1653
+
1624
1654
  /** Compile a set-producing AST into an array of individuals. */
1625
1655
  function evalSet(graph, ast, opts) {
1626
1656
  switch (ast.node) {
1627
1657
  case "clause": return traverse(graph, ast.clause, opts).matches || [];
1628
- case "allOfClass": return graph.individuals.filter((i) => i.class === ast.entityType);
1658
+ case "allOfClass":
1659
+ if (ast.entityType === "Package") return packageIndividuals(graph);
1660
+ return graph.individuals.filter((i) => i.class === ast.entityType);
1629
1661
  // Predicate-find as a set atom: the narrow-then-broaden cascade's result,
1630
1662
  // transparently flattened ("related, not exact" is a render concern).
1631
1663
  case "find": {
@@ -1806,7 +1838,13 @@ function evalAnaphora(graph, ast, opts) {
1806
1838
  const emptyClass = f && f.type === "entity" ? f.entityType : sameClass(baseItems);
1807
1839
  const common = items.length ? sameClass(items) : emptyClass;
1808
1840
  if (ast.mode === "count") return { compositeKind: "count", count: items.length, entityType: common, matches: [] };
1809
- return { compositeKind: "set", matches: items, entityType: common };
1841
+ // A narrowed, class-homogeneous result registers as a NEW set referent so
1842
+ // the narrowing survives a later count and binds a further "which of those".
1843
+ // It rides `bound: true` (a follow-up derived FROM the standing set), so the
1844
+ // session layer's register() does not evict the set on the class change a
1845
+ // narrowing produces.
1846
+ const referents = setReferentsFor(common, items, "anaphora", { bound: true });
1847
+ return { compositeKind: "set", matches: items, entityType: common, referents };
1810
1848
  }
1811
1849
 
1812
1850
  // Structural kinds counted for "most-connected" (total degree). Symbol-grain and
@@ -1923,7 +1961,17 @@ function evalSuperlative(graph, ast) {
1923
1961
  if (!scored.length) return { compositeKind: "superlative", entityType: ast.entityType, matches: [] };
1924
1962
  const best = scored[0].score;
1925
1963
  const winners = scored.filter((s) => s.score === best).map((s) => s.ind);
1926
- return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners };
1964
+ // What the ranking established, as discourse referents (see evalCommitFilter
1965
+ // for the shape the session layer registers). A lone winner is one entity a
1966
+ // later "it"/"this"/"that" can bind; a metric tie (two winners on the same
1967
+ // score) is a set instead, since no singular form should silently pick one.
1968
+ // The score itself is always a measure referent, so "is that bigger than X"
1969
+ // binds the number the ranking produced rather than re-deriving it.
1970
+ const referents = winners.length >= 2
1971
+ ? setReferentsFor(ast.entityType, winners, "superlative")
1972
+ : [{ kind: "entity", class: ast.entityType, label: winners[0].label, ids: [winners[0].id], attrs: {}, lane: "superlative" }];
1973
+ referents.push({ kind: "measure", label: `${best} ${ast.metricNoun}`, ids: [], attrs: { metric: best }, lane: "superlative" });
1974
+ return { compositeKind: "superlative", entityType: ast.entityType, metricNoun: ast.metricNoun, extreme: ast.extreme, score: best, matches: winners, referents };
1927
1975
  }
1928
1976
 
1929
1977
  /** Top-level "<kind> of/in <owner>" membership eval, covering both a bare
@@ -1938,6 +1986,9 @@ function evalMembershipComposite(graph, ast, opts) {
1938
1986
  const filterFn = qualNode
1939
1987
  ? (ind) => qualNode.filters.every((f) => qualHolds(graph, ind, QUALIFIERS[f]))
1940
1988
  : null;
1989
+ // A class-typed membership result registers as a discourse set referent (see
1990
+ // evalCommitFilter) so a later "which of those …" binds it.
1991
+ const membershipReferents = (list) => setReferentsFor(entityType, list, "membership");
1941
1992
  const owner = resolveMembershipOwner(graph, memNode.term, opts && opts.contextId);
1942
1993
  if (owner.kind === "dir") {
1943
1994
  let objs;
@@ -1947,14 +1998,16 @@ function evalMembershipComposite(graph, ast, opts) {
1947
1998
  objs = uniqueById(MEMBERSHIP_KINDS.flatMap((k) => forwardOverSet(graph, k, ids))).filter((o) => o.class === entityType);
1948
1999
  }
1949
2000
  if (filterFn) objs = objs.filter(filterFn);
1950
- return { compositeKind: "set", matches: objs, entityType };
2001
+ return { compositeKind: "set", matches: objs, entityType, referents: membershipReferents(objs) };
1951
2002
  }
1952
2003
  if (owner.kind === "miss") return { compositeKind: "set", matches: [], entityType };
1953
2004
  const { own, inherited, viaLabel } = computeMembership(graph, owner.id, owner.entityClass, entityType, filterFn);
1954
2005
  const inheritedNotOwn = !own.length && inherited.length > 0;
2006
+ const finalMatches = inheritedNotOwn ? inherited : own;
1955
2007
  return {
1956
- compositeKind: "membership", entityType, matches: inheritedNotOwn ? inherited : own,
2008
+ compositeKind: "membership", entityType, matches: finalMatches,
1957
2009
  inheritedNotOwn, viaLabel, ownerLabel: owner.label,
2010
+ referents: membershipReferents(finalMatches),
1958
2011
  };
1959
2012
  }
1960
2013
 
@@ -2019,7 +2072,11 @@ function evalQualCheck(graph, ast, opts) {
2019
2072
  }
2020
2073
  const rawHolds = qualHolds(graph, r.match, QUALIFIERS[qualifier]);
2021
2074
  const holds = negated ? !rawHolds : rawHolds;
2022
- return { compositeKind: "qualCheck", subject: r.match, qualifier, negated, holds, matches: [r.match] };
2075
+ // The subject the check resolved is a discourse referent either way — the
2076
+ // answer being "no" does not make the entity any less bindable by a
2077
+ // follow-up (see evalCommitFilter for the registration shape).
2078
+ const referents = [{ kind: "entity", class: r.match.class, label: r.match.label, ids: [r.match.id], attrs: {}, lane: "qualCheck" }];
2079
+ return { compositeKind: "qualCheck", subject: r.match, qualifier, negated, holds, matches: [r.match], referents };
2023
2080
  }
2024
2081
 
2025
2082
  /** Universal-over-a-set: the object is grounded first (an unknown one is an
@@ -2102,7 +2159,14 @@ function evalComposite(graph, ast, opts = {}) {
2102
2159
  && !(Array.isArray(opts.prev) && opts.prev.length)) {
2103
2160
  return { compositeMiss: true, reason: "no-prev", matches: [] };
2104
2161
  }
2105
- return { compositeKind: "set", matches: evalSet(graph, ast, opts), entityType: ast.entityType || null };
2162
+ const matches = evalSet(graph, ast, opts);
2163
+ const entityType = ast.entityType || null;
2164
+ // A resolved, class-typed listing/filter set registers as a discourse
2165
+ // referent (see evalCommitFilter) so a later "which of those …" binds it. A
2166
+ // qualifier listing ("which modules are tested") registers the same way, just
2167
+ // under its own lane name.
2168
+ const referents = setReferentsFor(entityType, matches, ast.node === "qualifier" ? "qualifierListing" : "compositeSet");
2169
+ return { compositeKind: "set", matches, entityType, referents };
2106
2170
  }
2107
2171
 
2108
2172
  // ---- compositional render: templated, same "honest miss vs cited hit"
@@ -2200,7 +2264,7 @@ function renderComposite(parsed, result, graph) {
2200
2264
  }
2201
2265
  // Kinds that don't live in a module (or have no module-scope parse at
2202
2266
  // all, like memory-graph classes) get no narrow-by-module hint.
2203
- const scopeable = !["Module", "Commit", "Fact", "Utterance", "Session", "Source", "Rule"].includes(result.entityType);
2267
+ const scopeable = !["Module", "Package", "Commit", "Fact", "Utterance", "Session", "Source", "Rule"].includes(result.entityType);
2204
2268
  const hint = (!result.scoped && scopeable && result.matches.length > OVERFLOW_CAP)
2205
2269
  ? ` — narrow with "${nounFor(result.entityType, 2)} in <module>"`
2206
2270
  : "";
@@ -3111,8 +3175,15 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3111
3175
  }
3112
3176
  commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
3113
3177
  }
3178
+ // The dated commit this answer named is a discourse `event` referent, so a
3179
+ // later "was that before X was touched" binds it (see evalCommitFilter).
3180
+ // Only when a dated commit resolved — an undated one is the render's honest
3181
+ // miss, and a referent with no date could not feed the comparison lane.
3182
+ const referents = commits.length && dateOf(commits[0])
3183
+ ? [{ kind: "event", class: "Commit", label: commits[0].label, ids: [commits[0].id], attrs: { date: dateOf(commits[0]).slice(0, 10) }, lane: "when" }]
3184
+ : [];
3114
3185
  return {
3115
- matches: commits, objMatch, candidates, ambiguous, matchedVia, whenShape: true,
3186
+ matches: commits, objMatch, candidates, ambiguous, matchedVia, whenShape: true, referents,
3116
3187
  traversal: `touches+touchesSymbol edges where object = ${objMatch.label}, newest commit date first`,
3117
3188
  };
3118
3189
  }
@@ -3135,8 +3206,14 @@ export function traverse(graph, parsed, { contextId = null, prev = null, pinnedO
3135
3206
  if (c && c.class === "Commit") commits.push(c);
3136
3207
  }
3137
3208
  commits.sort((a, b) => dateOf(b).localeCompare(dateOf(a)));
3209
+ // The dated commit behind the "who last touched X" answer is the same
3210
+ // `event` referent the when-shape registers, so either phrasing feeds a
3211
+ // later temporal comparison. Registered only when the commit carries a date.
3212
+ const referents = commits.length && dateOf(commits[0])
3213
+ ? [{ kind: "event", class: "Commit", label: commits[0].label, ids: [commits[0].id], attrs: { date: dateOf(commits[0]).slice(0, 10) }, lane: "whoLast" }]
3214
+ : [];
3138
3215
  return {
3139
- matches: commits, objMatch, candidates, ambiguous, matchedVia, whoLastShape: true,
3216
+ matches: commits, objMatch, candidates, ambiguous, matchedVia, whoLastShape: true, referents,
3140
3217
  traversal: `touches+touchesSymbol edges where object = ${objMatch.label}, newest commit's author`,
3141
3218
  };
3142
3219
  }
@@ -4409,6 +4486,18 @@ export function ask(graph, query, { contextId = null, nlp = undefined, prev = nu
4409
4486
  content = `${content}\n(answering for ${result.objMatch.label} — ${others.length} other match${others.length === 1 ? "" : "es"}: ${list})`;
4410
4487
  }
4411
4488
  }
4489
+ // A plain relation LISTING ("which modules import X", "which functions call
4490
+ // X") resolves through the simple traverse path, not evalComposite, so it
4491
+ // registers its class-typed result here — the same set referent the
4492
+ // composite listing lanes emit, so a later "which of those …" binds it. Only
4493
+ // a real, non-empty, class-homogeneous answer no lane already registered
4494
+ // (the composite lanes set result.referents themselves).
4495
+ if (!Array.isArray(result.referents) && !rendered.miss && !rendered.ambiguous
4496
+ && (parsed?.shape === "reverse" || parsed?.shape === "forward")
4497
+ && parsed.entityType && Array.isArray(result.matches) && result.matches.length
4498
+ && result.matches.every((m) => m && m.class === parsed.entityType)) {
4499
+ result = { ...result, referents: setReferentsFor(parsed.entityType, result.matches, "relationListing") };
4500
+ }
4412
4501
  return {
4413
4502
  content,
4414
4503
  tmct_ask: {
@@ -1277,19 +1277,36 @@ export function renderSubclasses(graph, ind) {
1277
1277
  const ARCH_PKG_CAP = 25;
1278
1278
  const ARCH_HUB_CAP = 15;
1279
1279
 
1280
+ /** The directories that group a set of modules, each mapped to how many
1281
+ * modules it holds. A package is not a node class — no individual is ever
1282
+ * stored with class "Package" — so every surface that reports packages
1283
+ * (the architecture map, ask()'s package list and count) derives them from
1284
+ * module labels, and this is the one place that derivation lives. A module
1285
+ * at the repo root has no directory, so it groups under "(root)". */
1286
+ export function packageCounts(modules) {
1287
+ const counts = new Map();
1288
+ for (const m of modules) {
1289
+ const dir = m.label.includes("/") ? m.label.slice(0, m.label.lastIndexOf("/")) : "(root)";
1290
+ counts.set(dir, (counts.get(dir) || 0) + 1);
1291
+ }
1292
+ return counts;
1293
+ }
1294
+
1295
+ /** Every module in the graph, optionally scoped to a path prefix. */
1296
+ export function modulesOf(graph, prefix = "") {
1297
+ const norm = normPath(prefix);
1298
+ return graph.individuals.filter(
1299
+ (i) => (i.class || "") === "Module" && (!norm || normPath(i.label).startsWith(norm)),
1300
+ );
1301
+ }
1302
+
1280
1303
  /** Package/module tree + the most-imported (hub) modules — replaces reading the dir
1281
1304
  * tree and many files to learn the shape. Optional `pkg` prefix scopes it. */
1282
1305
  export function renderArchitecture(graph, { pkg = "" } = {}) {
1283
1306
  const norm = normPath(pkg);
1284
- const modules = graph.individuals.filter(
1285
- (i) => (i.class || "") === "Module" && (!norm || normPath(i.label).startsWith(norm)),
1286
- );
1307
+ const modules = modulesOf(graph, pkg);
1287
1308
  if (!modules.length) return norm ? `no modules under "${pkg}".` : "no modules in the graph.";
1288
- const pkgCount = new Map();
1289
- for (const m of modules) {
1290
- const dir = m.label.includes("/") ? m.label.slice(0, m.label.lastIndexOf("/")) : "(root)";
1291
- pkgCount.set(dir, (pkgCount.get(dir) || 0) + 1);
1292
- }
1309
+ const pkgCount = packageCounts(modules);
1293
1310
  const inDeg = new Map();
1294
1311
  for (const e of edgesOfKind(graph, "imports")) inDeg.set(e.object, (inDeg.get(e.object) || 0) + 1);
1295
1312
  const modSet = new Set(modules.map((m) => m.id));
@@ -8,6 +8,7 @@
8
8
  import { renderStructure } from "./structures.mjs";
9
9
  import { FAMILY_PRIORITY } from "./select.mjs";
10
10
  import { articleFor, capitalizeFirst } from "./words.mjs";
11
+ import DEFAULT_CONFIG from "./config.json" with { type: "json" };
11
12
 
12
13
  const DESCRIPTION_FAMILIES = FAMILY_PRIORITY.filter((f) => f !== "isa" && f !== "other");
13
14
 
@@ -22,6 +23,137 @@ function rowsByFamily(selected) {
22
23
  return by;
23
24
  }
24
25
 
26
+ /** Split `rows` into runs of at most `size`, keeping order. Never returns an
27
+ * empty run; a size below 1 is treated as 1. */
28
+ export function chunk(rows, size) {
29
+ const n = Math.max(1, Math.trunc(size) || 1);
30
+ const out = [];
31
+ for (let i = 0; i < (rows || []).length; i += n) out.push(rows.slice(i, i + n));
32
+ return out;
33
+ }
34
+
35
+ /** Bucket isa rows by their object's immediate ancestor (`chains[object][1]`),
36
+ * so facts that share a parent class can be named in one coherent sentence.
37
+ * A parent with two or more rows is a real group; a parent with only one row,
38
+ * and any row whose object has no recorded ancestry, fold into one shared
39
+ * leftover pool. Groups come first in the order their earliest member ranked;
40
+ * the leftover pool, in rank order, comes last. Returns
41
+ * `[{ parent, rows }, …]` where `parent` is null for the leftover pool. */
42
+ export function groupIsaByParent(rows, chains) {
43
+ const parentOf = (row) => (chains?.[row.object] || [])[1] || null;
44
+ const counts = new Map();
45
+ for (const row of rows || []) {
46
+ const p = parentOf(row);
47
+ if (p) counts.set(p, (counts.get(p) || 0) + 1);
48
+ }
49
+ const groupRows = new Map();
50
+ const order = [];
51
+ const leftover = [];
52
+ for (const row of rows || []) {
53
+ const p = parentOf(row);
54
+ if (p && counts.get(p) >= 2) {
55
+ if (!groupRows.has(p)) { groupRows.set(p, []); order.push(p); }
56
+ groupRows.get(p).push(row);
57
+ } else {
58
+ leftover.push(row);
59
+ }
60
+ }
61
+ const groups = order.map((parent) => ({ parent, rows: groupRows.get(parent) }));
62
+ if (leftover.length) groups.push({ parent: null, rows: leftover });
63
+ return groups;
64
+ }
65
+
66
+ /** The highest-level ontology roots the spoken isa objects reach: for each row,
67
+ * the last (most general) node its ancestry chain walked. Roots rank by how
68
+ * many chains reach them (ties broken alphabetically for determinism); the top
69
+ * `count` are kept. Returns `{ roots, rows }` — `roots` names the concepts a
70
+ * closer sentence renders, `rows` are the real backing isa facts so the closer
71
+ * keeps its provenance without ever synthesising a row. */
72
+ export function closerRootsFor(chains, usedRows, count) {
73
+ const rootCount = new Map();
74
+ const rootRows = new Map();
75
+ for (const row of usedRows || []) {
76
+ const chain = chains?.[row.object];
77
+ if (!Array.isArray(chain) || chain.length < 2) continue;
78
+ const root = chain[chain.length - 1];
79
+ if (!root || root === row.object) continue;
80
+ rootCount.set(root, (rootCount.get(root) || 0) + 1);
81
+ if (!rootRows.has(root)) rootRows.set(root, []);
82
+ rootRows.get(root).push(row);
83
+ }
84
+ const roots = [...rootCount.keys()]
85
+ .sort((a, b) => (rootCount.get(b) - rootCount.get(a)) || a.localeCompare(b))
86
+ .slice(0, Math.max(0, count));
87
+ const rows = [];
88
+ const seen = new Set();
89
+ for (const root of roots) for (const r of rootRows.get(root) || []) {
90
+ if (!seen.has(r.id)) { seen.add(r.id); rows.push(r); }
91
+ }
92
+ return { roots, rows };
93
+ }
94
+
95
+ /** Compose the isa (definition) sentences. Gated purely on how many isa facts
96
+ * survived selection: none says nothing; one renders the single or chained
97
+ * form; a handful (up to the split threshold) merges into one clause. Past the
98
+ * threshold the run-on breaks apart — an opener names the top concepts, body
99
+ * sentences group the rest by shared parent, and a closer names the roots they
100
+ * all sit under. Returns tagged sentence objects, or an empty list. */
101
+ function composeIsaSection(isaRows, chains, term, table, config) {
102
+ const out = [];
103
+ if (!isaRows.length) return out;
104
+
105
+ if (isaRows.length === 1) {
106
+ const chain = chains[isaRows[0].object];
107
+ const form = chain && chain.length > 1 ? "chained" : "single";
108
+ const s = renderStructure(table, "isa", isaRows, { term, form, chain });
109
+ if (s) out.push({ ...s, role: "names-term", paragraph: "definition" });
110
+ return out;
111
+ }
112
+
113
+ if (isaRows.length <= config.groupSplitThreshold) {
114
+ const s = renderStructure(table, "isa", isaRows, { term, form: "several" });
115
+ if (s) out.push({ ...s, role: "names-term", paragraph: "definition" });
116
+ return out;
117
+ }
118
+
119
+ const openerRows = isaRows.slice(0, config.openerConceptCount);
120
+ const restRows = isaRows.slice(config.openerConceptCount);
121
+ const opener = renderStructure(table, "isa", openerRows, {
122
+ term, form: openerRows.length > 1 ? "several" : "single", variantSeed: term,
123
+ });
124
+ if (opener) out.push({ ...opener, role: "names-term", paragraph: "definition" });
125
+
126
+ let units = [];
127
+ for (const g of groupIsaByParent(restRows, chains)) {
128
+ for (const c of chunk(g.rows, config.maxObjectsPerBodySentence)) units.push({ parent: g.parent, rows: c });
129
+ }
130
+ // Cap the sentence count. Overflow folds into the last sentence rather than
131
+ // drop a selected fact — the merged sentence loses its single-parent coherence,
132
+ // so it renders as a plain "several" clause.
133
+ if (units.length > config.maxBodySentences) {
134
+ const kept = units.slice(0, config.maxBodySentences);
135
+ const last = kept[kept.length - 1];
136
+ for (const o of units.slice(config.maxBodySentences)) last.rows = last.rows.concat(o.rows);
137
+ last.parent = null;
138
+ units = kept;
139
+ }
140
+ for (const u of units) {
141
+ const s = u.parent
142
+ ? renderStructure(table, "isa", u.rows, { term, form: "group", parent: u.parent, variantSeed: u.parent })
143
+ : renderStructure(table, "isa", u.rows, { term, form: "several", variantSeed: term });
144
+ if (s) out.push({ ...s, role: "describes-isa", paragraph: "definition" });
145
+ }
146
+
147
+ const { roots, rows: closerRows } = closerRootsFor(chains, isaRows, config.closerRootCount);
148
+ if (roots.length) {
149
+ const s = renderStructure(table, "isa", closerRows.length ? closerRows : isaRows, {
150
+ term, form: "closer", objectsOverride: roots, variantSeed: term,
151
+ });
152
+ if (s) out.push({ ...s, role: "closes-isa", paragraph: "definition" });
153
+ }
154
+ return out;
155
+ }
156
+
25
157
  /** The first sentence must introduce the term. When no isa fact led (so the
26
158
  * first sentence opens with a bare "It"), rewrite that pronoun into the term's
27
159
  * own noun phrase so the pronoun has an antecedent. */
@@ -59,8 +191,11 @@ function paragraphsFrom(sentences, cap) {
59
191
  *
60
192
  * opts:
61
193
  * - chains: { object -> [object, parent, …] } ancestry chains, so a lone isa
62
- * fact can render as a chain ("a mammal, and so an animal") when one exists.
194
+ * fact can render as a chain ("a mammal, and so an animal") when one exists,
195
+ * and a many-fact term can split into opener, grouped body and closer.
63
196
  * - maxSentencesPerParagraph: the sentence cap (default 3).
197
+ * - config: digest tuning overrides (the group split threshold and the
198
+ * per-sentence / per-digest caps), defaulting to config.json.
64
199
  *
65
200
  * Returns { term, sentences, paragraphs, provenanceRows }. Every sentence in
66
201
  * `sentences` carries { text, rows, family, role, paragraph }; `paragraphs`
@@ -71,22 +206,19 @@ export function composeTermDigest(selection, table, opts = {}) {
71
206
  const term = selection?.term || "";
72
207
  const cap = Number.isInteger(opts.maxSentencesPerParagraph) ? opts.maxSentencesPerParagraph : 3;
73
208
  const chains = opts.chains || {};
209
+ const config = { ...DEFAULT_CONFIG, ...(opts.config || {}) };
74
210
  const byFamily = rowsByFamily(selection?.selected || []);
75
211
  const sentences = [];
76
212
 
77
- const isaRows = byFamily.get("isa") || [];
78
- if (isaRows.length) {
79
- const chain = isaRows.length === 1 ? chains[isaRows[0].object] : null;
80
- const form = chain && chain.length > 1 ? "chained" : (isaRows.length > 1 ? "several" : "single");
81
- const s = renderStructure(table, "isa", isaRows, { term, form, chain });
82
- if (s) sentences.push({ ...s, role: "names-term", paragraph: "definition" });
83
- }
213
+ for (const s of composeIsaSection(byFamily.get("isa") || [], chains, term, table, config)) sentences.push(s);
84
214
 
85
215
  for (const family of DESCRIPTION_FAMILIES) {
86
216
  const rows = byFamily.get(family);
87
217
  if (!rows || !rows.length) continue;
88
- const s = renderStructure(table, family, rows, { term });
89
- if (s) sentences.push({ ...s, role: "describes", paragraph: "description" });
218
+ for (const c of chunk(rows, config.maxObjectsPerBodySentence)) {
219
+ const s = renderStructure(table, family, c, { term });
220
+ if (s) sentences.push({ ...s, role: "describes", paragraph: "description" });
221
+ }
90
222
  }
91
223
 
92
224
  ensureFirstNamesTerm(sentences, term);
@@ -4,6 +4,11 @@
4
4
  "entailedDepthPenalty": 0.2,
5
5
  "minoritySensePenalty": 0.5,
6
6
  "minScore": 0.05,
7
+ "groupSplitThreshold": 4,
8
+ "openerConceptCount": 2,
9
+ "maxObjectsPerBodySentence": 4,
10
+ "maxBodySentences": 4,
11
+ "closerRootCount": 2,
7
12
  "budget": {
8
13
  "chatReply": 5,
9
14
  "researchPanel": 10,
@@ -6,9 +6,10 @@
6
6
  // sibling word helpers.
7
7
 
8
8
  import { articleFor, pluralOf, capitalizeFirst, series } from "./words.mjs";
9
+ import { fnv1a32 } from "../hash.mjs";
9
10
 
10
11
  const VALID_FAMILIES = new Set(["isa", "location", "partOf", "capableOf", "usedFor"]);
11
- const VALID_FORMS = new Set(["single", "several", "chained"]);
12
+ const VALID_FORMS = new Set(["single", "several", "chained", "group", "closer"]);
12
13
 
13
14
  const keyFor = (family, form) => `${family}:${form}`;
14
15
 
@@ -16,8 +17,10 @@ const keyFor = (family, form) => `${family}:${form}`;
16
17
  * Validate and index the raw [[structure]] rows into a Map keyed by
17
18
  * `family:form`. Closed-vocabulary discipline, same as the construction banks:
18
19
  * an unknown family or form, or a missing template, drops the row rather than
19
- * coercing it. First occurrence of a (family, form) wins; a later duplicate is
20
- * ignored.
20
+ * coercing it. Every valid row for a (family, form) is collected into that
21
+ * key's `templates` list, so the renderer can pick one deterministically from
22
+ * the pool; an exact-string duplicate is folded, but a second distinct wording
23
+ * is kept as another variant.
21
24
  */
22
25
  export function buildStructureTable(structures) {
23
26
  const table = new Map();
@@ -26,8 +29,9 @@ export function buildStructureTable(structures) {
26
29
  if (typeof s.form !== "string" || !VALID_FORMS.has(s.form)) continue;
27
30
  if (typeof s.template !== "string" || !s.template.trim()) continue;
28
31
  const key = keyFor(s.family, s.form);
29
- if (table.has(key)) continue;
30
- table.set(key, { family: s.family, form: s.form, template: s.template });
32
+ if (!table.has(key)) table.set(key, { family: s.family, form: s.form, templates: [] });
33
+ const entry = table.get(key);
34
+ if (!entry.templates.includes(s.template)) entry.templates.push(s.template);
31
35
  }
32
36
  return table;
33
37
  }
@@ -44,12 +48,15 @@ function renderChain(chain) {
44
48
  return `${head}, and so ${series(rest.map(withArticle))}`;
45
49
  }
46
50
 
47
- /** The slot values for one render, derived from the term, its objects and an
48
- * optional ancestry chain. A placeholder with no derived value renders empty. */
49
- function slotsFor(term, objects, chain) {
51
+ /** The slot values for one render, derived from the term, its objects, an
52
+ * optional ancestry chain and an optional parent class (the shared immediate
53
+ * ancestor a `group` sentence hangs off). A placeholder with no derived value
54
+ * renders empty. */
55
+ function slotsFor(term, objects, chain, parent) {
50
56
  const t = String(term || "").trim();
51
57
  const objs = (objects || []).map((o) => String(o || "").trim()).filter(Boolean);
52
58
  const first = objs[0] || "";
59
+ const p = String(parent || "").trim();
53
60
  return {
54
61
  TERM: t,
55
62
  TERM_CAP: capitalizeFirst(t),
@@ -59,6 +66,8 @@ function slotsFor(term, objects, chain) {
59
66
  TERMS_CAP: capitalizeFirst(pluralOf(t)),
60
67
  PRONOUN: "it",
61
68
  PRONOUN_CAP: "It",
69
+ PARENT: p,
70
+ A_PARENT: p ? withArticle(p) : "",
62
71
  OBJECT: first,
63
72
  A_OBJECT: first ? withArticle(first) : "",
64
73
  OBJECTS_A: series(objs.map(withArticle)),
@@ -82,9 +91,22 @@ function fill(template, slots) {
82
91
  * Render one clause for `facts` (all of one family) using the structure the
83
92
  * table holds for (family, form). `form` defaults by fact count — one fact is
84
93
  * "single", more is "several" — unless the caller names a form (e.g. "chained"
85
- * with an ancestry chain). Returns { text, rows, family, form } so the sentence
86
- * traces to the exact fact rows behind it, or null when no structure matches or
87
- * there are no facts.
94
+ * with an ancestry chain, or "group"/"closer" for the multi-sentence isa body).
95
+ *
96
+ * When a key holds more than one wording, the one that renders is picked by a
97
+ * deterministic FNV-1a hash of `term|family|form|variantSeed` — the same
98
+ * paraphrase-pool tool answer-variants.mjs uses — so the choice is stable across
99
+ * runs but varies by term (and by `variantSeed`, e.g. the shared parent, so two
100
+ * group sentences under one term can read differently). A single-wording key
101
+ * always resolves to that one wording.
102
+ *
103
+ * `opts.objectsOverride` renders words other than the raw fact objects (a closer
104
+ * sentence names root concepts, not the facts) while the returned `rows` stays
105
+ * the real backing facts, so provenance survives. `opts.parent` fills the
106
+ * `{PARENT}`/`{A_PARENT}` slots a group sentence hangs off.
107
+ *
108
+ * Returns { text, rows, family, form } so the sentence traces to the exact fact
109
+ * rows behind it, or null when no structure matches or there are no facts.
88
110
  */
89
111
  export function renderStructure(table, family, facts, opts = {}) {
90
112
  const rows = (facts || []).filter(Boolean);
@@ -92,9 +114,12 @@ export function renderStructure(table, family, facts, opts = {}) {
92
114
  const term = opts.term ?? rows[0].subject ?? "";
93
115
  const form = opts.form || (rows.length > 1 ? "several" : "single");
94
116
  const entry = table instanceof Map ? table.get(keyFor(family, form)) : null;
95
- if (!entry) return null;
96
- const objects = rows.map((r) => r.object);
97
- const text = fill(entry.template, slotsFor(term, objects, opts.chain));
117
+ const templates = entry ? entry.templates : null;
118
+ if (!templates || !templates.length) return null;
119
+ const variantSeed = opts.variantSeed ?? "";
120
+ const idx = fnv1a32(`${term}|${family}|${form}|${variantSeed}`) % templates.length;
121
+ const objects = opts.objectsOverride || rows.map((r) => r.object);
122
+ const text = fill(templates[idx], slotsFor(term, objects, opts.chain, opts.parent));
98
123
  if (!text) return null;
99
124
  return { text, rows, family, form };
100
125
  }
@@ -110,10 +110,10 @@ const INTERROGATIVE_LEAD_RE = /^(?:which|what|who|whose|where|when|why|how)\b/i;
110
110
  * untouched so that working path keeps it. Two shapes: a plural kind noun in
111
111
  * tail position, or a bare (det +) singular kind noun and nothing else. */
112
112
  const LISTING_TAIL_KINDS = new Set([
113
- "modules", "files", "functions", "methods", "classes", "attributes", "fields",
113
+ "modules", "files", "packages", "functions", "methods", "classes", "attributes", "fields",
114
114
  "properties", "variables", "globals", "commits", "changes", "tests", "members",
115
115
  ]);
116
- const BARE_KIND_RE = /^(?:all\s+|the\s+)?(?:module|file|function|method|class|attribute|field|property|variable|global|commit|change|test|member)\??$/i;
116
+ const BARE_KIND_RE = /^(?:all\s+|the\s+)?(?:module|file|package|function|method|class|attribute|field|property|variable|global|commit|change|test|member)\??$/i;
117
117
  const isListingRemainder = (rest) => {
118
118
  if (BARE_KIND_RE.test(rest)) return true;
119
119
  const words = rest.replace(/\?+\s*$/, "").trim().split(/\s+/);
@@ -12,6 +12,10 @@ import { RELATION_TERM } from "./concept.mjs";
12
12
  export const REFERENCE_PACK_NAME = "simplewiki";
13
13
  export const REFERENCE_SHARD_COUNT = 64;
14
14
 
15
+ /** The grain cue every encyclopedia-grounded answer carries: this is a word's
16
+ * general meaning, not something read out of the graph the session is about. */
17
+ export const GENERAL_VOCABULARY_CUE = "General vocabulary, not from this codebase.";
18
+
15
19
  /** The shard a term's article row lives in: FNV-1a first byte mod 64, as the
16
20
  * file basename "ref-00" … "ref-3f". Part of the pack's on-disk contract —
17
21
  * the build script shards with THIS function, so the reader never scans. */
@@ -43,10 +47,14 @@ export function isReferenceArticleRow(row) {
43
47
  }
44
48
 
45
49
  /** The cited answer for a clean miss the pack could ground: the article's
46
- * summary with its title, licence and revision-pinned URL always visible. */
50
+ * summary with its title, licence and revision-pinned URL always visible,
51
+ * then the grain cue. Without the cue a reader has to recognize the source
52
+ * name to tell this apart from a fact read out of their own graph — both
53
+ * answer the same "what is X" question in the same voice. */
47
54
  export function renderReferenceAnswer(term, article) {
48
55
  return `${term} — ${article.summary} (source: reference article "${article.title}", `
49
- + `Simple English Wikipedia, CC BY-SA 4.0 — ${article.url}?oldid=${article.revid})`;
56
+ + `Simple English Wikipedia, CC BY-SA 4.0 — ${article.url}?oldid=${article.revid})`
57
+ + ` ${GENERAL_VOCABULARY_CUE}`;
50
58
  }
51
59
 
52
60
  /** The provenance tag a fact stored from a pack article carries —
@@ -88,7 +96,8 @@ export function liveProvenanceTag(article) {
88
96
  * the revision-pinned URL always visible. */
89
97
  export function renderLiveReferenceAnswer(term, article) {
90
98
  return `${term} — ${article.summary} (source: live Wikipedia article "${article.title}", `
91
- + `English Wikipedia, CC BY-SA 4.0 — ${article.url}?oldid=${article.revid})`;
99
+ + `English Wikipedia, CC BY-SA 4.0 — ${article.url}?oldid=${article.revid})`
100
+ + ` ${GENERAL_VOCABULARY_CUE}`;
92
101
  }
93
102
 
94
103
  /** The pure half of the LIVE clean-miss gate — same fold, shape check and
@@ -1,6 +1,6 @@
1
1
  // src/domain/router/call-validator.mjs — pure registry validators shared by the
2
2
  // product router (resolver / goal-reasoner) + the bench grader
3
- // (agentbench/grade.mjs re-exports these). Depends ONLY on registry.mjs — no
3
+ // (test-benchmarks/agentbench/grade.mjs re-exports these). Depends ONLY on registry.mjs — no
4
4
  // bench code — so the product←bench dependency stays inverted: the bench
5
5
  // imports the product, never the other way round. No I/O, no Date.now, no LLM.
6
6
 
@@ -243,8 +243,8 @@ export async function runTaughtPlan(request, tools, ctx) {
243
243
  /** The full drive: resolver/planner first; a refusal there falls through to
244
244
  * the taught world-goal lane (runTaughtPlan, above), and only a request that
245
245
  * is not a world goal escalates to the closed-world goal-reasoner. Mirrors
246
- * agentbench's driver-resolver.mjs + driver-goal.mjs composition, with no
247
- * agentbench/ dependency (agentbench/ is dev-only, never shipped). Returns a
246
+ * test-benchmarks/agentbench's driver-resolver.mjs + driver-goal.mjs composition, with no
247
+ * test-benchmarks/agentbench/ dependency (test-benchmarks/agentbench/ is dev-only, never shipped). Returns a
248
248
  * loopResult:
249
249
  * `{ calls, refused, terminated, proof, why, driver, composed?, observed?, candidateResults? }`.
250
250
  *