@polycode-projects/the-mechanical-code-talker 3.0.0 → 3.0.2
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/README.md +11 -0
- package/bin/tmct.mjs +98 -0
- package/data/templates/constructions/digest-sentence-structures.toml +97 -0
- package/package.json +7 -1
- package/src/adapters/corpus/digest-bank.mjs +62 -0
- package/src/adapters/toml-config.mjs +13 -0
- package/src/domain/ask.mjs +53 -4
- package/src/domain/cli-verbs.mjs +11 -0
- package/src/domain/codeplan/graph-delta.mjs +239 -0
- package/src/domain/codeplan/graph-predicates.mjs +0 -0
- package/src/domain/codeplan/operators.mjs +232 -0
- package/src/domain/codeplan/planner.mjs +87 -0
- package/src/domain/digest/article.mjs +106 -0
- package/src/domain/digest/compose.mjs +103 -0
- package/src/domain/digest/config.json +13 -0
- package/src/domain/digest/index.mjs +38 -0
- package/src/domain/digest/select.mjs +233 -0
- package/src/domain/digest/store-stats.mjs +77 -0
- package/src/domain/digest/structures.mjs +100 -0
- package/src/domain/digest/words.mjs +40 -0
- package/src/domain/discourse.mjs +0 -0
- package/src/services/chat-session.mjs +13 -1
- package/src/services/chat.mjs +191 -12
- package/src/services/ledger-viz.mjs +14 -3
- package/src/services/sessions.mjs +3 -1
- package/src/surfaces/web/memory-ask-browser.bundle.js +121 -116
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// digest/compose.mjs — stage 3 of the digest layer: order the selected facts
|
|
2
|
+
// into sentences and group them into paragraphs. Pure and deterministic; the
|
|
3
|
+
// prose quality lives here — the lead sentence names the term, every sentence
|
|
4
|
+
// after it refers back with a pronoun, and a paragraph never runs past its
|
|
5
|
+
// sentence cap. Each sentence keeps the fact rows behind it, so provenance
|
|
6
|
+
// survives composition into the article stage.
|
|
7
|
+
|
|
8
|
+
import { renderStructure } from "./structures.mjs";
|
|
9
|
+
import { FAMILY_PRIORITY } from "./select.mjs";
|
|
10
|
+
import { articleFor, capitalizeFirst } from "./words.mjs";
|
|
11
|
+
|
|
12
|
+
const DESCRIPTION_FAMILIES = FAMILY_PRIORITY.filter((f) => f !== "isa" && f !== "other");
|
|
13
|
+
|
|
14
|
+
/** Group the selector's `selected` items by family, preserving each item's
|
|
15
|
+
* ranked order, and return `{ family -> rows[] }` over the fact rows. */
|
|
16
|
+
function rowsByFamily(selected) {
|
|
17
|
+
const by = new Map();
|
|
18
|
+
for (const item of selected || []) {
|
|
19
|
+
if (!by.has(item.family)) by.set(item.family, []);
|
|
20
|
+
by.get(item.family).push(item.row);
|
|
21
|
+
}
|
|
22
|
+
return by;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The first sentence must introduce the term. When no isa fact led (so the
|
|
26
|
+
* first sentence opens with a bare "It"), rewrite that pronoun into the term's
|
|
27
|
+
* own noun phrase so the pronoun has an antecedent. */
|
|
28
|
+
function ensureFirstNamesTerm(sentences, term) {
|
|
29
|
+
if (!sentences.length) return;
|
|
30
|
+
const first = sentences[0];
|
|
31
|
+
if (/\bnames-term\b/.test(first.role)) return;
|
|
32
|
+
const phrase = capitalizeFirst(`${articleFor(term)} ${term}`);
|
|
33
|
+
first.text = first.text.replace(/^It\b/, phrase);
|
|
34
|
+
first.role = "names-term";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Split a flat sentence list into paragraphs of at most `cap` sentences,
|
|
38
|
+
* respecting the caller's paragraph boundaries (a sentence's `paragraph` tag). */
|
|
39
|
+
function paragraphsFrom(sentences, cap) {
|
|
40
|
+
const groups = [];
|
|
41
|
+
let current = null;
|
|
42
|
+
let currentTag = null;
|
|
43
|
+
for (const s of sentences) {
|
|
44
|
+
if (!current || s.paragraph !== currentTag || current.length >= cap) {
|
|
45
|
+
current = [];
|
|
46
|
+
currentTag = s.paragraph;
|
|
47
|
+
groups.push(current);
|
|
48
|
+
}
|
|
49
|
+
current.push(s);
|
|
50
|
+
}
|
|
51
|
+
return groups.map((sentences) => ({
|
|
52
|
+
sentences,
|
|
53
|
+
text: sentences.map((s) => s.text).join(" "),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compose the term digest from a selector result and a structure table.
|
|
59
|
+
*
|
|
60
|
+
* opts:
|
|
61
|
+
* - 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.
|
|
63
|
+
* - maxSentencesPerParagraph: the sentence cap (default 3).
|
|
64
|
+
*
|
|
65
|
+
* Returns { term, sentences, paragraphs, provenanceRows }. Every sentence in
|
|
66
|
+
* `sentences` carries { text, rows, family, role, paragraph }; `paragraphs`
|
|
67
|
+
* joins them for rendering; `provenanceRows` is every fact row the digest used,
|
|
68
|
+
* deduped by id.
|
|
69
|
+
*/
|
|
70
|
+
export function composeTermDigest(selection, table, opts = {}) {
|
|
71
|
+
const term = selection?.term || "";
|
|
72
|
+
const cap = Number.isInteger(opts.maxSentencesPerParagraph) ? opts.maxSentencesPerParagraph : 3;
|
|
73
|
+
const chains = opts.chains || {};
|
|
74
|
+
const byFamily = rowsByFamily(selection?.selected || []);
|
|
75
|
+
const sentences = [];
|
|
76
|
+
|
|
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
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const family of DESCRIPTION_FAMILIES) {
|
|
86
|
+
const rows = byFamily.get(family);
|
|
87
|
+
if (!rows || !rows.length) continue;
|
|
88
|
+
const s = renderStructure(table, family, rows, { term });
|
|
89
|
+
if (s) sentences.push({ ...s, role: "describes", paragraph: "description" });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
ensureFirstNamesTerm(sentences, term);
|
|
93
|
+
|
|
94
|
+
const provenanceById = new Map();
|
|
95
|
+
for (const s of sentences) for (const r of s.rows) if (!provenanceById.has(r.id)) provenanceById.set(r.id, r);
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
term,
|
|
99
|
+
sentences,
|
|
100
|
+
paragraphs: paragraphsFrom(sentences, cap),
|
|
101
|
+
provenanceRows: [...provenanceById.values()],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"uninformativeClassMaxShare": 0.35,
|
|
3
|
+
"uninformativeClassMinSubjects": 12,
|
|
4
|
+
"entailedDepthPenalty": 0.2,
|
|
5
|
+
"minoritySensePenalty": 0.5,
|
|
6
|
+
"minScore": 0.05,
|
|
7
|
+
"budget": {
|
|
8
|
+
"chatReply": 5,
|
|
9
|
+
"researchPanel": 10,
|
|
10
|
+
"cliDigest": 12,
|
|
11
|
+
"ledgerTerm": 8
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// digest/index.mjs — the digest layer's public seam: run the pure pipeline
|
|
2
|
+
// (select -> structures -> compose -> article) end to end. Stage 5 (the chat
|
|
3
|
+
// term answer, the research panel, `tmct digest`) becomes thin wiring over
|
|
4
|
+
// this: build the fact rows and the store statistics from the graph it already
|
|
5
|
+
// holds, hand them in, and render the returned article shape.
|
|
6
|
+
//
|
|
7
|
+
// Everything here is pure and deterministic. The structure table arrives
|
|
8
|
+
// pre-parsed (the same injection posture the construction banks use), so this
|
|
9
|
+
// module — and the whole layer — imports nothing outside src/domain.
|
|
10
|
+
|
|
11
|
+
import { selectFacts, FAMILY_OF, FAMILY_PRIORITY } from "./select.mjs";
|
|
12
|
+
import { buildStructureTable, renderStructure } from "./structures.mjs";
|
|
13
|
+
import { composeTermDigest } from "./compose.mjs";
|
|
14
|
+
import { termArticle, researchRunArticle, sessionDigestArticle } from "./article.mjs";
|
|
15
|
+
|
|
16
|
+
export { selectFacts, FAMILY_OF, FAMILY_PRIORITY };
|
|
17
|
+
export { buildStructureTable, renderStructure };
|
|
18
|
+
export { composeTermDigest };
|
|
19
|
+
export { termArticle, researchRunArticle, sessionDigestArticle };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Digest one term end to end.
|
|
23
|
+
*
|
|
24
|
+
* @param term the normalized term the digest is about.
|
|
25
|
+
* @param rows its candidate fact rows (readFactRows() rows with subject===term).
|
|
26
|
+
* @param store the store-relative statistics selectFacts() needs
|
|
27
|
+
* ({ classSubjectCounts, totalSubjects, subClassEdges, disjointEdges }).
|
|
28
|
+
* @param table a structure table (buildStructureTable() over the parsed bank).
|
|
29
|
+
* @param opts { budget, config, chains, maxSentencesPerParagraph } — threaded
|
|
30
|
+
* to the stages that use them.
|
|
31
|
+
* @returns the term-article shape, carrying the narrative, its sources, and the
|
|
32
|
+
* full fact list behind the "show the facts" escape.
|
|
33
|
+
*/
|
|
34
|
+
export function digestTerm(term, rows, store = {}, table = new Map(), opts = {}) {
|
|
35
|
+
const selection = selectFacts(term, rows, store, opts);
|
|
36
|
+
const composed = composeTermDigest(selection, table, opts);
|
|
37
|
+
return termArticle(selection, composed, opts);
|
|
38
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// digest/select.mjs — stage 1 of the digest layer: score the candidate fact
|
|
2
|
+
// rows for one term and cut them to a budget. Pure and deterministic over the
|
|
3
|
+
// rows and a store-relative statistics bundle the caller supplies; imports only
|
|
4
|
+
// its sibling domain modules (the trust priors and the sense-split machinery),
|
|
5
|
+
// never the filesystem or the store adapter.
|
|
6
|
+
//
|
|
7
|
+
// The scoring signals, all already present on a readFactRows() row or cheaply
|
|
8
|
+
// derivable from a store scan:
|
|
9
|
+
// - provenance tier: a researched or taught fact outranks an entailed one,
|
|
10
|
+
// read from the row's source-type priors (memory/trust.mjs).
|
|
11
|
+
// - chain depth: a stated fact outranks one the closure derived (a row with a
|
|
12
|
+
// non-empty premise environment is entailed, and demoted).
|
|
13
|
+
// - informativeness: a class nearly every subject in the store shares carries
|
|
14
|
+
// almost no information about THIS term. A store-relative frequency share
|
|
15
|
+
// over a committed threshold CUTS the row — this is what removes "a kind of
|
|
16
|
+
// entity" without a hand-maintained stoplist.
|
|
17
|
+
// - relation coverage: the cut prefers breadth — one good isa, one partOf and
|
|
18
|
+
// one capableOf beat four isa chains — by filling families round-robin.
|
|
19
|
+
// - sense agreement: an isa object whose sense cluster disagrees with the
|
|
20
|
+
// term's dominant sense is demoted (the "medium"/"software" branch the
|
|
21
|
+
// specimen shows), scored through sense-split.mjs's own clustering.
|
|
22
|
+
//
|
|
23
|
+
// Nothing is destroyed. Every row the budget or a cut removes is returned in
|
|
24
|
+
// `cut` with a reason, so "show the facts" downstream reaches the full list and
|
|
25
|
+
// the ranking stays auditable.
|
|
26
|
+
|
|
27
|
+
import { SOURCE_PRIOR } from "../memory/trust.mjs";
|
|
28
|
+
import { clusterSenses } from "../sense-split.mjs";
|
|
29
|
+
import DEFAULT_CONFIG from "./config.json" with { type: "json" };
|
|
30
|
+
|
|
31
|
+
// A relation predicate -> the sentence-family key the digest groups it under.
|
|
32
|
+
// Deliberately small: the families stage 2 authors structures for today. A
|
|
33
|
+
// predicate with no entry lands in "other" — still selectable, still carried
|
|
34
|
+
// into the detail, but never leading the narrative.
|
|
35
|
+
// Each family is VERB-COHERENT: the predicates inside it share one sentence
|
|
36
|
+
// frame, so stage 2 can author one structure per family without the object
|
|
37
|
+
// preposition guessing which verb it belongs to. A predicate with no entry
|
|
38
|
+
// lands in "other" — still selectable, still carried into the detail, but never
|
|
39
|
+
// leading the narrative.
|
|
40
|
+
export const FAMILY_OF = Object.freeze({
|
|
41
|
+
"rdfs:subClassOf": "isa",
|
|
42
|
+
"rdf:type": "isa",
|
|
43
|
+
"mgx:atLocation": "location",
|
|
44
|
+
"mgx:locatedNear": "location",
|
|
45
|
+
"mgx:partOf": "partOf",
|
|
46
|
+
"mgx:capableOf": "capableOf",
|
|
47
|
+
"mgx:usedFor": "usedFor",
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// The order families lead the digest in: the definition (isa) first, then where
|
|
51
|
+
// it sits, what it belongs to, what it can do, what it is used for, then
|
|
52
|
+
// anything else. The round-robin cut pulls from families in this order, so a tie
|
|
53
|
+
// in score never makes the ordering non-deterministic.
|
|
54
|
+
export const FAMILY_PRIORITY = Object.freeze(["isa", "location", "partOf", "capableOf", "usedFor", "other"]);
|
|
55
|
+
|
|
56
|
+
const familyOf = (predicate) => FAMILY_OF[predicate] || "other";
|
|
57
|
+
const clamp01 = (n) => Math.max(0, Math.min(1, n));
|
|
58
|
+
|
|
59
|
+
/** The provenance component of a row's score: the strongest source-type prior
|
|
60
|
+
* the row carries (a taught fact's 0.95 beats an entailed fact's 0.3), falling
|
|
61
|
+
* back to the row's materialised trust, then to the entailed floor. */
|
|
62
|
+
function provenanceScore(row) {
|
|
63
|
+
const priors = (row.sourceTypes || []).map((t) => SOURCE_PRIOR[t] ?? 0);
|
|
64
|
+
if (priors.length) return Math.max(...priors);
|
|
65
|
+
if (typeof row.trust === "number" && row.trust > 0) return row.trust;
|
|
66
|
+
return SOURCE_PRIOR.entailed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A row the closure derived rather than a source stated: it carries a premise
|
|
70
|
+
* environment, or an entailed source type. */
|
|
71
|
+
function isEntailed(row) {
|
|
72
|
+
if (Array.isArray(row.environments) && row.environments.length) return true;
|
|
73
|
+
return (row.sourceTypes || []).includes("entailed");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The store-relative share of subjects that carry this class as an isa target.
|
|
77
|
+
* Above the committed threshold (in a store large enough for the share to
|
|
78
|
+
* mean anything) the class is uninformative and its row is cut. */
|
|
79
|
+
function classShare(object, store) {
|
|
80
|
+
const total = Number(store?.totalSubjects) || 0;
|
|
81
|
+
if (total <= 0) return 0;
|
|
82
|
+
const count = Number(store?.classSubjectCounts?.[object]) || 0;
|
|
83
|
+
return count / total;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Select the digest-worthy facts for `term` from its candidate `rows`.
|
|
88
|
+
*
|
|
89
|
+
* `store` supplies the store-relative signals the scoring needs, all derivable
|
|
90
|
+
* from one pass over the graph the caller already holds:
|
|
91
|
+
* - classSubjectCounts: { class -> number of distinct subjects that are isa it }
|
|
92
|
+
* - totalSubjects: number of distinct isa subjects in the store
|
|
93
|
+
* - subClassEdges: [[child, parent], …] for the sense clustering
|
|
94
|
+
* - disjointEdges: [[a, b], …] stored owl:disjointWith pairs (optional)
|
|
95
|
+
*
|
|
96
|
+
* `opts`: { budget (default config.budget.chatReply), config (threshold data) }.
|
|
97
|
+
*
|
|
98
|
+
* Returns { term, selected, cut, senses }:
|
|
99
|
+
* - selected: up to `budget` items { row, family, score, reasons }, ranked,
|
|
100
|
+
* breadth-first across families. Each carries its fact row, so every
|
|
101
|
+
* downstream sentence traces back to stored provenance.
|
|
102
|
+
* - cut: every other row { row, reason } — nothing is discarded silently.
|
|
103
|
+
* - senses: the sense-split verdict over the term's isa objects.
|
|
104
|
+
*/
|
|
105
|
+
export function selectFacts(term, rows, store = {}, opts = {}) {
|
|
106
|
+
const config = { ...DEFAULT_CONFIG, ...(opts.config || {}) };
|
|
107
|
+
const budget = Number.isInteger(opts.budget) ? opts.budget : config.budget.chatReply;
|
|
108
|
+
const candidates = (rows || []).filter((r) => r && r.object && r.object !== term);
|
|
109
|
+
const cut = [];
|
|
110
|
+
|
|
111
|
+
// The uninformative-class cut runs FIRST, before sense clustering. A class
|
|
112
|
+
// nearly every subject shares carries no information about this term — and,
|
|
113
|
+
// just as important, a bare uninformative class with no recorded ancestry
|
|
114
|
+
// would act as a union bridge in the clustering below, collapsing genuinely
|
|
115
|
+
// distinct senses into one. Removing it first keeps the sense signal clean.
|
|
116
|
+
const bigEnough = (Number(store?.totalSubjects) || 0) >= config.uninformativeClassMinSubjects;
|
|
117
|
+
const informative = [];
|
|
118
|
+
for (const row of candidates) {
|
|
119
|
+
if (familyOf(row.predicate) === "isa") {
|
|
120
|
+
const share = classShare(row.object, store);
|
|
121
|
+
if (bigEnough && share > config.uninformativeClassMaxShare) {
|
|
122
|
+
cut.push({ row, family: "isa", reason: "uninformative-class", share });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
informative.push(row);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Sense clustering over the SURVIVING isa objects — the dominant sense is the
|
|
130
|
+
// cluster whose objects carry the most provenance weight; objects outside it
|
|
131
|
+
// are the demoted branch (the specimen's "medium"/"software" fan-out).
|
|
132
|
+
const isaObjects = informative.filter((r) => familyOf(r.predicate) === "isa").map((r) => r.object);
|
|
133
|
+
const senses = clusterSenses(isaObjects, {
|
|
134
|
+
subClassEdges: store.subClassEdges || [],
|
|
135
|
+
disjointEdges: store.disjointEdges || [],
|
|
136
|
+
});
|
|
137
|
+
const clusterWeight = new Map(); // label -> summed provenance of its objects
|
|
138
|
+
const labelOfObject = new Map(); // object -> its cluster label
|
|
139
|
+
for (const cluster of senses.clusters) {
|
|
140
|
+
let weight = 0;
|
|
141
|
+
for (const obj of cluster.objects) {
|
|
142
|
+
labelOfObject.set(obj, cluster.label);
|
|
143
|
+
const row = informative.find((r) => r.object === obj && familyOf(r.predicate) === "isa");
|
|
144
|
+
weight += row ? provenanceScore(row) : 0;
|
|
145
|
+
}
|
|
146
|
+
clusterWeight.set(cluster.label, weight);
|
|
147
|
+
}
|
|
148
|
+
let dominantLabel = null;
|
|
149
|
+
let bestWeight = -1;
|
|
150
|
+
for (const [label, weight] of [...clusterWeight].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
151
|
+
if (weight > bestWeight) { bestWeight = weight; dominantLabel = label; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const scored = [];
|
|
155
|
+
for (const row of informative) {
|
|
156
|
+
const family = familyOf(row.predicate);
|
|
157
|
+
let score = provenanceScore(row);
|
|
158
|
+
const reasons = { provenance: score };
|
|
159
|
+
if (isEntailed(row)) { score -= config.entailedDepthPenalty; reasons.entailed = true; }
|
|
160
|
+
if (family === "isa") {
|
|
161
|
+
const share = classShare(row.object, store);
|
|
162
|
+
// A bounded weight in [0.6, 1.0]: a rarer class says more about the term
|
|
163
|
+
// and ranks higher, but a common one is only demoted, never zeroed — the
|
|
164
|
+
// hard removal of a truly uninformative class is the cut above, not this.
|
|
165
|
+
const informativeness = 0.6 + 0.4 * (1 - share);
|
|
166
|
+
score *= informativeness;
|
|
167
|
+
reasons.informativeness = informativeness;
|
|
168
|
+
const label = labelOfObject.get(row.object);
|
|
169
|
+
if (dominantLabel && label && label !== dominantLabel) {
|
|
170
|
+
score -= config.minoritySensePenalty;
|
|
171
|
+
reasons.minoritySense = label;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
score = clamp01(score);
|
|
175
|
+
scored.push({ row, family, score, reasons });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// A row scored below the floor (a demoted minority sense, a weak entailed
|
|
179
|
+
// chain) is cut, not held for backfill — the budget stays unfilled rather than
|
|
180
|
+
// padding the narrative with a fact that barely earned a place. Nothing is
|
|
181
|
+
// lost: it is still returned in `cut`, still reachable through the escape.
|
|
182
|
+
const eligible = [];
|
|
183
|
+
for (const item of scored) {
|
|
184
|
+
if (item.score < config.minScore) cut.push({ row: item.row, family: item.family, reason: "below-floor", score: item.score });
|
|
185
|
+
else eligible.push(item);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Breadth-first cut to budget: each family sorted by score (tie broken by
|
|
189
|
+
// object label for determinism), then pulled round-robin in family priority
|
|
190
|
+
// order so the digest covers relations before it repeats one.
|
|
191
|
+
const byFamily = new Map();
|
|
192
|
+
for (const item of eligible) {
|
|
193
|
+
if (!byFamily.has(item.family)) byFamily.set(item.family, []);
|
|
194
|
+
byFamily.get(item.family).push(item);
|
|
195
|
+
}
|
|
196
|
+
for (const list of byFamily.values()) {
|
|
197
|
+
list.sort((a, b) => b.score - a.score || a.row.object.localeCompare(b.row.object));
|
|
198
|
+
}
|
|
199
|
+
const orderedFamilies = FAMILY_PRIORITY.filter((f) => byFamily.has(f))
|
|
200
|
+
.concat([...byFamily.keys()].filter((f) => !FAMILY_PRIORITY.includes(f)).sort());
|
|
201
|
+
const selected = [];
|
|
202
|
+
let drained = false;
|
|
203
|
+
while (selected.length < budget && !drained) {
|
|
204
|
+
drained = true;
|
|
205
|
+
for (const family of orderedFamilies) {
|
|
206
|
+
const list = byFamily.get(family);
|
|
207
|
+
if (list && list.length) {
|
|
208
|
+
selected.push(list.shift());
|
|
209
|
+
drained = false;
|
|
210
|
+
if (selected.length >= budget) break;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
// Whatever the round-robin left over is ranked detail, not noise — returned
|
|
215
|
+
// as budget-cut, still carrying its score, so the escape reaches it in order.
|
|
216
|
+
for (const family of orderedFamilies) {
|
|
217
|
+
for (const item of byFamily.get(family) || []) {
|
|
218
|
+
cut.push({ row: item.row, family: item.family, reason: "over-budget", score: item.score });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
selected.sort((a, b) => {
|
|
222
|
+
const fa = FAMILY_PRIORITY.indexOf(a.family);
|
|
223
|
+
const fb = FAMILY_PRIORITY.indexOf(b.family);
|
|
224
|
+
return (fa - fb) || (b.score - a.score) || a.row.object.localeCompare(b.row.object);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
term,
|
|
229
|
+
selected,
|
|
230
|
+
cut,
|
|
231
|
+
senses: { split: senses.split, clusters: senses.clusters, dominantLabel },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// digest/store-stats.mjs — the store-relative statistics the selector needs,
|
|
2
|
+
// derived in one pass over the fact rows a caller already holds. Pure and
|
|
3
|
+
// deterministic; imports only its sibling sense-split helpers, so it links into
|
|
4
|
+
// a browser bundle the same way the rest of the digest layer does (the pages
|
|
5
|
+
// digest client-side from an embedded structure table).
|
|
6
|
+
//
|
|
7
|
+
// selectFacts() scores an isa row partly by how many subjects across the store
|
|
8
|
+
// share its object as a class: a class nearly everything is cut as
|
|
9
|
+
// uninformative. That count, the total isa-subject population, and the
|
|
10
|
+
// subClassOf/disjointWith edges the sense clustering walks are all one scan of
|
|
11
|
+
// the rows away — this module is that scan, kept out of the pure selector so
|
|
12
|
+
// the selector stays a function of its inputs.
|
|
13
|
+
|
|
14
|
+
import { subClassParents, ancestryChain } from "../sense-split.mjs";
|
|
15
|
+
|
|
16
|
+
// The predicates the digest groups under the isa family (kept in step with
|
|
17
|
+
// select.mjs's FAMILY_OF): a subclass edge and a type edge both name a class
|
|
18
|
+
// the subject belongs to.
|
|
19
|
+
const ISA_PREDICATES = new Set(["rdfs:subClassOf", "rdf:type"]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The store-relative statistics selectFacts() reads, from one pass over `rows`:
|
|
23
|
+
* - classSubjectCounts: { class -> distinct subjects that are isa it }
|
|
24
|
+
* - totalSubjects: distinct subjects that carry any isa fact
|
|
25
|
+
* - subClassEdges: [[child, parent], …] for the sense clustering
|
|
26
|
+
* - disjointEdges: [[a, b], …] stored owl:disjointWith pairs
|
|
27
|
+
*/
|
|
28
|
+
export function digestStoreStats(rows) {
|
|
29
|
+
const classSubjects = new Map(); // class -> Set(subject)
|
|
30
|
+
const isaSubjects = new Set();
|
|
31
|
+
const subClassEdges = [];
|
|
32
|
+
const disjointEdges = [];
|
|
33
|
+
for (const r of rows || []) {
|
|
34
|
+
if (!r || !r.predicate) continue;
|
|
35
|
+
if (ISA_PREDICATES.has(r.predicate)) {
|
|
36
|
+
isaSubjects.add(r.subject);
|
|
37
|
+
if (!classSubjects.has(r.object)) classSubjects.set(r.object, new Set());
|
|
38
|
+
classSubjects.get(r.object).add(r.subject);
|
|
39
|
+
subClassEdges.push([r.subject, r.object]);
|
|
40
|
+
} else if (r.predicate === "owl:disjointWith") {
|
|
41
|
+
disjointEdges.push([r.subject, r.object]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const classSubjectCounts = {};
|
|
45
|
+
for (const [cls, subs] of classSubjects) classSubjectCounts[cls] = subs.size;
|
|
46
|
+
return { classSubjectCounts, totalSubjects: isaSubjects.size, subClassEdges, disjointEdges };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Ancestry chains for a set of isa objects, keyed object -> [object, parent, …],
|
|
51
|
+
* so a lone isa fact can render as "a mammal, and so an animal". Built from the
|
|
52
|
+
* store's subClassOf edges; an object with no recorded parent is omitted.
|
|
53
|
+
*/
|
|
54
|
+
export function chainsForObjects(subClassEdges, objects, cap = 6) {
|
|
55
|
+
const parents = subClassParents(subClassEdges || []);
|
|
56
|
+
const chains = {};
|
|
57
|
+
for (const obj of objects || []) {
|
|
58
|
+
if (obj in chains) continue;
|
|
59
|
+
const chain = ancestryChain(obj, parents, { cap });
|
|
60
|
+
if (chain.length > 1) chains[obj] = chain;
|
|
61
|
+
}
|
|
62
|
+
return chains;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The isa objects among a term's rows, first-seen order — the set worth an
|
|
66
|
+
* ancestry chain. */
|
|
67
|
+
export function isaObjectsOf(rows) {
|
|
68
|
+
const out = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const r of rows || []) {
|
|
71
|
+
if (!r || !ISA_PREDICATES.has(r.predicate)) continue;
|
|
72
|
+
if (seen.has(r.object)) continue;
|
|
73
|
+
seen.add(r.object);
|
|
74
|
+
out.push(r.object);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// digest/structures.mjs — stage 2 of the digest layer: validate the
|
|
2
|
+
// hand-authored sentence-structure bank and render one clause from a set of
|
|
3
|
+
// facts of a single relation family. Pure and deterministic; the raw parsed
|
|
4
|
+
// TOML rides in from a composition point exactly the way the construction banks
|
|
5
|
+
// do (the strategy never reads the filesystem), so this module imports only its
|
|
6
|
+
// sibling word helpers.
|
|
7
|
+
|
|
8
|
+
import { articleFor, pluralOf, capitalizeFirst, series } from "./words.mjs";
|
|
9
|
+
|
|
10
|
+
const VALID_FAMILIES = new Set(["isa", "location", "partOf", "capableOf", "usedFor"]);
|
|
11
|
+
const VALID_FORMS = new Set(["single", "several", "chained"]);
|
|
12
|
+
|
|
13
|
+
const keyFor = (family, form) => `${family}:${form}`;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Validate and index the raw [[structure]] rows into a Map keyed by
|
|
17
|
+
* `family:form`. Closed-vocabulary discipline, same as the construction banks:
|
|
18
|
+
* 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.
|
|
21
|
+
*/
|
|
22
|
+
export function buildStructureTable(structures) {
|
|
23
|
+
const table = new Map();
|
|
24
|
+
for (const s of structures || []) {
|
|
25
|
+
if (!s || typeof s.family !== "string" || !VALID_FAMILIES.has(s.family)) continue;
|
|
26
|
+
if (typeof s.form !== "string" || !VALID_FORMS.has(s.form)) continue;
|
|
27
|
+
if (typeof s.template !== "string" || !s.template.trim()) continue;
|
|
28
|
+
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 });
|
|
31
|
+
}
|
|
32
|
+
return table;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const withArticle = (word) => `${articleFor(word)} ${word}`;
|
|
36
|
+
|
|
37
|
+
/** Render "a mammal, and so an animal" from an ancestry chain [mammal, animal, …]. */
|
|
38
|
+
function renderChain(chain) {
|
|
39
|
+
const parts = (chain || []).map((c) => String(c || "").trim()).filter(Boolean);
|
|
40
|
+
if (!parts.length) return "";
|
|
41
|
+
const head = withArticle(parts[0]);
|
|
42
|
+
const rest = parts.slice(1);
|
|
43
|
+
if (!rest.length) return head;
|
|
44
|
+
return `${head}, and so ${series(rest.map(withArticle))}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
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) {
|
|
50
|
+
const t = String(term || "").trim();
|
|
51
|
+
const objs = (objects || []).map((o) => String(o || "").trim()).filter(Boolean);
|
|
52
|
+
const first = objs[0] || "";
|
|
53
|
+
return {
|
|
54
|
+
TERM: t,
|
|
55
|
+
TERM_CAP: capitalizeFirst(t),
|
|
56
|
+
A_TERM: withArticle(t),
|
|
57
|
+
A_TERM_CAP: capitalizeFirst(withArticle(t)),
|
|
58
|
+
TERMS: pluralOf(t),
|
|
59
|
+
TERMS_CAP: capitalizeFirst(pluralOf(t)),
|
|
60
|
+
PRONOUN: "it",
|
|
61
|
+
PRONOUN_CAP: "It",
|
|
62
|
+
OBJECT: first,
|
|
63
|
+
A_OBJECT: first ? withArticle(first) : "",
|
|
64
|
+
OBJECTS_A: series(objs.map(withArticle)),
|
|
65
|
+
OBJECTS_PLURAL: series(objs.map(pluralOf)),
|
|
66
|
+
OBJECTS_RAW: series(objs),
|
|
67
|
+
CHAIN: renderChain(chain && chain.length ? chain : objs.slice(0, 1)),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Substitute every `{SLOT}` in `template` with its value (empty when absent),
|
|
72
|
+
* then collapse any doubled spaces the empty slots left. */
|
|
73
|
+
function fill(template, slots) {
|
|
74
|
+
return String(template)
|
|
75
|
+
.replace(/\{([A-Z_]+)\}/g, (_, name) => (name in slots ? slots[name] : ""))
|
|
76
|
+
.replace(/\s+/g, " ")
|
|
77
|
+
.replace(/\s+([.,;])/g, "$1")
|
|
78
|
+
.trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Render one clause for `facts` (all of one family) using the structure the
|
|
83
|
+
* table holds for (family, form). `form` defaults by fact count — one fact is
|
|
84
|
+
* "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.
|
|
88
|
+
*/
|
|
89
|
+
export function renderStructure(table, family, facts, opts = {}) {
|
|
90
|
+
const rows = (facts || []).filter(Boolean);
|
|
91
|
+
if (!rows.length) return null;
|
|
92
|
+
const term = opts.term ?? rows[0].subject ?? "";
|
|
93
|
+
const form = opts.form || (rows.length > 1 ? "several" : "single");
|
|
94
|
+
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));
|
|
98
|
+
if (!text) return null;
|
|
99
|
+
return { text, rows, family, form };
|
|
100
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// digest/words.mjs — the small, self-contained English surface helpers the
|
|
2
|
+
// digest layer needs to turn a bare class label into a readable noun phrase:
|
|
3
|
+
// the indefinite article, a regular plural, and first-letter casing. Kept
|
|
4
|
+
// local (not imported from inflect.mjs) so the whole digest layer ships as one
|
|
5
|
+
// self-contained unit — inflect.mjs is excluded from the published package.
|
|
6
|
+
|
|
7
|
+
const VOWEL_START = /^[aeiou]/i;
|
|
8
|
+
|
|
9
|
+
/** "a" or "an" for the following word, by its leading letter. A blank word
|
|
10
|
+
* yields "a" (the caller never renders a phrase around an empty label). */
|
|
11
|
+
export function articleFor(word) {
|
|
12
|
+
return VOWEL_START.test(String(word || "").trim()) ? "an" : "a";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The regular English plural of a lemma — the same -s/-es/-ies rules the rest
|
|
16
|
+
* of the codebase uses, no irregular table. */
|
|
17
|
+
export function pluralOf(word) {
|
|
18
|
+
const w = String(word || "").trim();
|
|
19
|
+
if (!w) return w;
|
|
20
|
+
if (/(?:s|x|z|ch|sh)$/.test(w)) return `${w}es`;
|
|
21
|
+
if (/[^aeiou]y$/.test(w)) return `${w.slice(0, -1)}ies`;
|
|
22
|
+
return `${w}s`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Upper-case the first character only, leaving the rest of the phrase as-is
|
|
26
|
+
* (so "aardvark" -> "Aardvark", but "DNA sequence" keeps its inner caps). */
|
|
27
|
+
export function capitalizeFirst(text) {
|
|
28
|
+
const t = String(text || "");
|
|
29
|
+
return t ? t[0].toUpperCase() + t.slice(1) : t;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Join a list of phrases as an English series: "a", "a and b",
|
|
33
|
+
* "a, b, and c" (Oxford comma, deterministic). */
|
|
34
|
+
export function series(items) {
|
|
35
|
+
const parts = (items || []).map((s) => String(s || "").trim()).filter(Boolean);
|
|
36
|
+
if (parts.length === 0) return "";
|
|
37
|
+
if (parts.length === 1) return parts[0];
|
|
38
|
+
if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
|
|
39
|
+
return `${parts.slice(0, -1).join(", ")}, and ${parts[parts.length - 1]}`;
|
|
40
|
+
}
|
|
Binary file
|
|
@@ -28,6 +28,7 @@ import * as defaultSource from "../adapters/source.mjs";
|
|
|
28
28
|
import { resolveExtensions, mergedLexiconExtra } from "./extensions.mjs";
|
|
29
29
|
import { runTurn, hasSeededVocabulary, vocabExampleHint } from "./chat.mjs";
|
|
30
30
|
import { resolveGameConfig } from "../domain/game-config.mjs";
|
|
31
|
+
import { emptyRecord, resolveDiscourseConfig } from "../domain/discourse.mjs";
|
|
31
32
|
import { resolveResearchConfig } from "./research.mjs";
|
|
32
33
|
import { sessionLogHeaderMarkdown, sessionLogTurnMarkdown, sessionLogEndMarkdown } from "./session-log-format.mjs";
|
|
33
34
|
|
|
@@ -209,6 +210,13 @@ export async function createSession({
|
|
|
209
210
|
// the flag/env tiers above stay authoritative when set.
|
|
210
211
|
if (toml?.corpus?.tier === "tier3") liveReferenceOn = true;
|
|
211
212
|
|
|
213
|
+
// tmct.toml's [graph] read_only turns any session against this repo into a
|
|
214
|
+
// read-only one, exactly as --ephemeral does: the graph is read for
|
|
215
|
+
// structure but nothing (upsert, logs, memory) is written back into the
|
|
216
|
+
// repo's .tmct/. Committed example fixtures carry it so a plain
|
|
217
|
+
// `tmct chat --repo examples/<x>` never mutates the hand-stamped graph.
|
|
218
|
+
if (toml?.graph?.readOnly) ephemeral = true;
|
|
219
|
+
|
|
212
220
|
// Ephemeral: keep config.graphFile pointing at the READ graph, but divert the
|
|
213
221
|
// write base (repo → logs/memory/sessions) to a throwaway temp dir. The committed
|
|
214
222
|
// target is never touched; the demo's memory simply doesn't persist across runs.
|
|
@@ -344,6 +352,9 @@ export async function createSession({
|
|
|
344
352
|
let last = null; // the last dispatched answer ({query,answer,detail}) — why/say-more re-renders it
|
|
345
353
|
let planState = null; // the in-progress plan (goals/moves/cursor) — cleared by completion or a fresh goal, never by an aside
|
|
346
354
|
let researchState = null; // the in-progress research queue — advanced by "research next", cleared by completion or "research stop"
|
|
355
|
+
// The typed discourse record ([discourse] max_referents caps it) — session-scoped
|
|
356
|
+
// like the focus, threaded turn to turn, never persisted.
|
|
357
|
+
let discourseRecord = emptyRecord(resolveDiscourseConfig(toml));
|
|
347
358
|
let closed = false;
|
|
348
359
|
|
|
349
360
|
return {
|
|
@@ -368,7 +379,7 @@ export async function createSession({
|
|
|
368
379
|
async turn(line) {
|
|
369
380
|
let result;
|
|
370
381
|
try {
|
|
371
|
-
result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, liveReference: liveReferenceOn, vocabHint, tel, biasByBundle, planState, gameConfig, researchState, researchConfig });
|
|
382
|
+
result = await runTurn(line, { config, source, graph, focus, last, memoryDir, sessionId, env, lexicon, narrate: narrateOn, liveReference: liveReferenceOn, vocabHint, tel, biasByBundle, planState, gameConfig, researchState, researchConfig, discourse: discourseRecord });
|
|
372
383
|
} catch (e) {
|
|
373
384
|
const ts = new Date().toISOString();
|
|
374
385
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -384,6 +395,7 @@ export async function createSession({
|
|
|
384
395
|
last = nextLast;
|
|
385
396
|
if ("planState" in result) planState = result.planState;
|
|
386
397
|
if ("researchState" in result) researchState = result.researchState;
|
|
398
|
+
if ("discourse" in result) discourseRecord = result.discourse;
|
|
387
399
|
// /narrate on|off and /wiki on|off (runCommand) ride the turn RESULT the
|
|
388
400
|
// same way a focus update does — apply them to this handle's
|
|
389
401
|
// session-scoped state.
|