@polycode-projects/the-mechanical-code-talker 0.9.12 → 1.0.1

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/src/concept.mjs CHANGED
@@ -249,6 +249,14 @@ export const RELATION_TERM = Object.freeze({
249
249
  define: "defines", defines: "defines", defining: "defines", defined: "defines", definition: "defines", definitions: "defines", declaration: "defines",
250
250
  touch: "touches", touches: "touches", touching: "touches", touched: "touches",
251
251
  cochange: "cochange", "co-change": "cochange", "change-coupling": "cochange", coupled: "cochange",
252
+ // "export"/"exports" is ALSO a curated seon lexicon noun (corpus/seon/definitions.jsonl
253
+ // "export"), same shape as "imports" — but that meta reading only owns the "what
254
+ // does export mean"/"what is an export" shape; vagueTouchTermOf/relationTermOf are
255
+ // deliberately scoped to the NON-meta "what about X"/"tell me about X" touch (see
256
+ // relationTermOf's own docblock, frozen case am-meta-imports), so no conflict here.
257
+ export: "reexports", exports: "reexports", exporting: "reexports", exported: "reexports",
258
+ reexport: "reexports", reexports: "reexports", reexporting: "reexports",
259
+ "re-export": "reexports", "re-exports": "reexports", "re-exporting": "reexports",
252
260
  });
253
261
 
254
262
  /** concept key → the relationKind()s whose edges it enumerates. A concept can span
@@ -263,6 +271,7 @@ const RELATION_KINDS = Object.freeze({
263
271
  defines: ["defines"],
264
272
  touches: ["touches", "touchesSymbol"],
265
273
  cochange: ["cochange"],
274
+ reexports: ["reexports"],
266
275
  });
267
276
 
268
277
  /** concept key → the verb phrase that renders an edge as an English sentence
@@ -276,6 +285,7 @@ const RELATION_RENDER = Object.freeze({
276
285
  defines: { verb: "defines", edgeNoun: "definition" },
277
286
  touches: { verb: "touches", edgeNoun: "touch" },
278
287
  cochange: { verb: "changes together with", edgeNoun: "change-coupling" },
288
+ reexports: { verb: "re-exports", edgeNoun: "re-export" },
279
289
  });
280
290
 
281
291
  /** Per concept key, the candidate follow-up shapes in priority order. Each shape
@@ -316,6 +326,10 @@ const RELATION_FOLLOWUP_SHAPES = Object.freeze({
316
326
  { side: "obj", make: (x) => `where is ${x} defined` },
317
327
  { side: "subj", make: (x) => `which modules import ${x}` },
318
328
  ],
329
+ reexports: [
330
+ { side: "subj", make: (x) => `what does ${x} export` },
331
+ { side: "obj", make: (x) => `where is ${x} defined` },
332
+ ],
319
333
  });
320
334
 
321
335
  /** How many example edges the relation force shows before the remainder is held for
@@ -347,12 +361,24 @@ function buildRelationFollowups(graph, key, subjLabels, objLabels) {
347
361
  }
348
362
 
349
363
  /** Compose the three bands for a RELATION concept term, or null when it is NOT a
350
- * relation-force case — the term is not a known enumerable relation, has no curated
351
- * definition, or the graph has NO edges of that kind (honest miss stands, never a
352
- * fabricated edge). Returns the same string-band shape composeConcept does:
364
+ * relation-force case at all — the term is not a known enumerable relation, or has no
365
+ * curated definition. Returns the same string-band shape composeConcept does:
353
366
  * { definition, examples, followups, followupQueries, remainder, noun }
354
- * `examples` is always non-empty when non-null (we only fire with real edges);
355
- * `followups` is "" when no validated next-question exists. */
367
+ * `examples` is always non-empty when non-null; `followups` is "" when no validated
368
+ * next-question exists.
369
+ *
370
+ * A known relation whose graph has ZERO edges of that kind is NOT null — it degrades
371
+ * to a two-band answer (the definition + an explicit "this codebase has no X edges"
372
+ * line, `examples`-shaped so the caller renders it identically). Found live (an
373
+ * advisor tick on the 0.9.14 Tier-2 playtest cycle): returning null here for the
374
+ * zero-edge case let the caller's OWN raw grammar attempt at the vague-touch text
375
+ * ("what about exports", "tell me about reexports") stand instead — but that text was
376
+ * never meant to be parsed as an object search, so on a graph with no reexports edges
377
+ * (the realistic case for most repos, e.g. examples/mini-webapp) it fell through to a
378
+ * garbled `no module matching "about"/"exports" found`, not an honest miss. A relation
379
+ * kind the graph has NEVER SEEN AT ALL (relationKind returns nothing in RELATION_KINDS
380
+ * for this graph's shape) still degrades the same way — the definition is always
381
+ * worth stating; only the fabricated edge is refused. */
356
382
  export function composeRelation(graph, relTerm, { definition = null } = {}) {
357
383
  const key = RELATION_TERM[String(relTerm || "").toLowerCase()];
358
384
  if (!key || !definition) return null;
@@ -360,14 +386,29 @@ export function composeRelation(graph, relTerm, { definition = null } = {}) {
360
386
  const groups = (graph && Array.isArray(graph.relations) ? graph.relations : [])
361
387
  .filter((g) => kinds.includes(relationKind(g)));
362
388
  const edges = groups.flatMap((g) => (Array.isArray(g.edges) ? g.edges : []));
363
- if (!edges.length) return null; // no edges of this kind → honest miss stands
364
- const total = groups.reduce((s, g) => s + (Number(g.count) || (g.edges || []).length), 0);
365
-
366
389
  const { verb, edgeNoun } = RELATION_RENDER[key] || { verb: key, edgeNoun: key };
367
390
 
368
- // BAND 1 — the fact (the relation defined as a verb/relationship).
391
+ // BAND 1 — the fact (the relation defined as a verb/relationship). Stated
392
+ // regardless of whether the graph has any edges of this kind — only the
393
+ // fabricated EXAMPLE is refused when there are none.
369
394
  const bandDefinition = leadSentence(definition);
370
395
 
396
+ if (!edges.length) {
397
+ // No fabricated edge, but an honest, ON-TOPIC miss — never the caller's raw,
398
+ // unrelated object-search text.
399
+ return {
400
+ definition: bandDefinition,
401
+ examples: `This codebase has no ${edgeNoun} edges in the index.`,
402
+ followups: "",
403
+ followupQueries: [],
404
+ relation: key,
405
+ remainder: [],
406
+ noun: `${edgeNoun} edges`,
407
+ empty: true,
408
+ };
409
+ }
410
+ const total = groups.reduce((s, g) => s + (Number(g.count) || (g.edges || []).length), 0);
411
+
371
412
  // BAND 2 — the example edges, rendered as English sentences with a count. The
372
413
  // first MAX_EDGE_EXAMPLES are shown; the remainder is held for "more" pagination.
373
414
  const rendered = edges.map((e) => `${edgeSubjectLabel(e)} ${verb} ${edgeObjectLabel(e)}`);
package/src/finish.mjs CHANGED
@@ -222,7 +222,7 @@ function leadingWord(text) {
222
222
 
223
223
  /** Does `word` begin with a VOWEL SOUND? true → "an", false → "a", null → cannot
224
224
  * tell (refuse). Spelling-vs-sound exceptions come from the rule's TOML row. */
225
- function beginsWithVowelSound(word, rule) {
225
+ export function beginsWithVowelSound(word, rule) {
226
226
  const w = String(word).toLowerCase().replace(/^[^a-z]+/, "");
227
227
  if (!w) return null;
228
228
  for (const ex of rule.vowel_sound_consonants || []) if (w.startsWith(ex)) return true;
@@ -44,6 +44,19 @@ const correctionRe = (table) => new RegExp(
44
44
  const MISSPELLING_RE = correctionRe(MISSPELLINGS);
45
45
  const WRONG_WORD_RE = correctionRe(WRONG_WORDS);
46
46
 
47
+ /** Just the curated MISSPELLINGS correction (ask-vocab.mjs), standalone — for a
48
+ * caller that needs typo-tolerant ANCHOR-WORD matching (a closed regex shape
49
+ * keyed on a literal "what"/"which"/"where" etc.) without running the rest of
50
+ * normalizeQuery's pipeline (contractions, preamble/subordination/conditional
51
+ * frame rewrites, filler-word stripping) — those can restructure the sentence
52
+ * in ways a shape-matcher never expects (0.9.14 Tier-2 playtest: normalizeQuery
53
+ * turns "tell me about Controller" into "about Controller", which would break
54
+ * chat.mjs's OWN "^tell me about …" shape regex if fed through wholesale). Pure,
55
+ * idempotent, same table as normalizeQuery's own first correction step. */
56
+ export function correctMisspellings(text) {
57
+ return String(text || "").replace(MISSPELLING_RE, (m) => MISSPELLINGS[m.toLowerCase()]);
58
+ }
59
+
47
60
  // ---- closed PREAMBLE frames (0.8.2 feel wave, PLAN_CHAT_FEEL item 2) — the
48
61
  // conversational wrapping a developer puts AROUND a real question: a greeting
49
62
  // lead-in with a delimiter ("hey there, quick question - …"), a thanks lead-in
@@ -88,7 +101,25 @@ const isListingRemainder = (rest) => {
88
101
  * remainder are REQUIRED, so a bare "hey there" stays a greeting for chat's
89
102
  * conversational lane, and "hey tmct, …" (a vocative, no delimiter after the
90
103
  * greeting word) is left for the noise-strip tier that already owns it. */
91
- const GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy)(?:\s+there)?\s*[,—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
104
+ // "g'day"/"gday" (AU/NZ dialect, §3b): the same lead-in-with-delimiter shape as
105
+ // hi/hey/howdy — 0.9.14 Tier-2 playtest §3b spot-check found "g'day, what does
106
+ // Base contain" fell through to a bogus "'g'day Base' matches more than one
107
+ // module" object search instead of stripping the greeting.
108
+ // "good morning"/"good afternoon"/"good evening"/"good day"/"greetings"/
109
+ // "salutations" (formal register, §3b) — chat.mjs's own bare-turn GREETINGS/
110
+ // IDENTITY_PHRASES closed sets already recognize these exact phrases stand-
111
+ // alone, but this LEAD-IN regex (a greeting fused onto a real question in the
112
+ // same turn) didn't carry the multi-word formal forms at all: a second Tier-2
113
+ // playtest pass (0.9.14) found "Good day, what is a method" hit the grammar
114
+ // wall outright, and "Good morning, what about tests" fell through to a bogus
115
+ // "no module matching 'Good morning' found" object search — the exact same
116
+ // failure g'day had before its own fix, just for the formal register instead
117
+ // of the AU/NZ dialect. Formal register also plausibly types the lead-in as
118
+ // its OWN full sentence ("Good day. What is a method?") rather than a comma
119
+ // splice — "." joins the delimiter class for exactly this reason; a bare
120
+ // greeting alone ("hi.") still can't match since the regex also requires a
121
+ // non-empty remainder AFTER the delimiter.
122
+ const GREETING_PREAMBLE_RE = /^(?:hi|hiya|hello|hey|yo|howdy|g'?day|good\s+(?:morning|afternoon|evening|day)|greetings|salutations)(?:\s+there)?\s*[,.—–-]\s*(?:(?:just\s+a\s+)?quick\s+question\s*[,:—–-]?\s*)?(.+)$/i;
92
123
  /** Thanks lead-in with a delimiter (+ optional "quick question" bridge), the
93
124
  * sibling of GREETING_PREAMBLE_RE for the "thanks" word family (Bug B2, 0.8.2
94
125
  * follow-up): "thanks, <Q>" / "thanks so much, <Q>" -> "<Q>". chat.mjs's
@@ -107,6 +138,17 @@ const THANKS_PREAMBLE_RE = /^(?:thanks|thank\s+you|many\s+thanks|thx|ty|cheers)(
107
138
  * passes, so "can you tell me a joke" -> "tell me a joke" -> (FILLER) "a joke"
108
139
  * — byte-identical to what the bare form normalizes to (the hm-joke wall). */
109
140
  const MODAL_WRAPPER_RE = /^(?:can|could|would|will)\s+you\s+(?:please\s+)?(.+?)(?:[,\s]+please)?\??$/i;
141
+ /** "explain" politeness/ESL wrapper: "explain [to me|please]* <Q>" -> "<Q>"
142
+ * (0.9.13 Tier-1 playtest, §3b surface-variation axis) — a formal/ESL lead-in
143
+ * around an ordinary structural question ("explain please where is it
144
+ * defined") used to leave "explain"/"please" as noise words that corrupted the
145
+ * object term into a bogus search ("no module matching 'explain it' found").
146
+ * Anchored to an INTERROGATIVE remainder (same guard as the show/give-me
147
+ * bridge below) so this frame only unwraps a real WH-question underneath —
148
+ * chat.mjs's own IDENTITY_PHRASES ("explain what is this") and the bare
149
+ * "explain" elaboration request (WHY set) are matched on the RAW turn text
150
+ * before normalizeQuery ever runs, so neither is touched by this frame. */
151
+ const EXPLAIN_WRAPPER_RE = /^explain\s+(?:to\s+me\s+|please\s+)*(.+?)\??$/i;
110
152
  /** show/give-me presentation bridge: "show me [the] <thing>". Three-way:
111
153
  * a KIND-listing remainder is left untouched (the compositional list grammar
112
154
  * owns "show me untested modules"); a remainder carrying a relation verb or an
@@ -129,6 +171,8 @@ export function applyPreambleFrames(text) {
129
171
  if (m) q = m[1].trim();
130
172
  m = q.match(MODAL_WRAPPER_RE);
131
173
  if (m) q = m[1].trim();
174
+ m = q.match(EXPLAIN_WRAPPER_RE);
175
+ if (m && INTERROGATIVE_LEAD_RE.test(m[1].trim())) q = m[1].trim();
132
176
  m = q.match(SHOW_GIVE_ME_RE);
133
177
  if (m) {
134
178
  const rest = m[1].trim();
@@ -192,6 +236,36 @@ export function applySubordinationFrames(text) {
192
236
  return q;
193
237
  }
194
238
 
239
+ // ---- SELF-CORRECTION (found live, playtest sprint round 5, HANDOVER item 12.1): a
240
+ // mid-sentence false start, abandoned and restarted — "what -- sorry, who inherits
241
+ // from Record", "what is a class -- i mean, what is a module". Same species as the
242
+ // preamble/subordination frames above (closed, delimiter-anchored, first-match-wins,
243
+ // unmatched text passes through byte-unchanged), sized for a false-start clause
244
+ // instead of a leading wrapper clause. The delimiter is the marker phrase "sorry" or
245
+ // "i mean" — REQUIRED, with an optional interruption dash ("--"/"—"/"-") in front of
246
+ // it and a required trailing separator (dash/comma/colon) after it. Deliberately does
247
+ // NOT match on a bare dash alone with no marker word: an ordinary em-dash aside
248
+ // ("modules — like Base — that inherit from X") is common prose, not a restart, and
249
+ // treating every dash as a delimiter would be a guess this file's discipline forbids
250
+ // everywhere else. ----
251
+ const SELF_CORRECTION_RE =
252
+ /^.+?(?:\s*(?:--|—|-)\s*)?\b(?:sorry|i\s+mean)\b\s*(?:--|—|-|,|:)\s*(.+)$/i;
253
+
254
+ /** Apply the self-correction strip to a small fixpoint (a stacked restart —
255
+ * "what -- sorry, who -- sorry, what inherits from Record" — peels to the final
256
+ * restart). Pure; unmatched text passes through byte-unchanged. */
257
+ export function applySelfCorrectionFrames(text) {
258
+ let q = String(text || "");
259
+ for (let pass = 0; pass < 3; pass++) {
260
+ const m = q.match(SELF_CORRECTION_RE);
261
+ if (!m) break;
262
+ const next = m[1].trim();
263
+ if (!next || next === q) break;
264
+ q = next;
265
+ }
266
+ return q;
267
+ }
268
+
195
269
  /** relation-verb (bare 3rd-person singular, the RELATIONS table's own primary
196
270
  * form) -> gerund, the shape the compositional grammar's proven
197
271
  * "<kind> <gerund> <object> and <qualifier>" pattern needs. A small, closed,
@@ -274,6 +348,12 @@ export function normalizeQuery(text) {
274
348
  // bridge) — AFTER the correction tables (a repaired "give me"/"show me" still
275
349
  // feeds the bridge) but BEFORE the filler strip erases their anchor words.
276
350
  q = applyPreambleFrames(q);
351
+ // self-correction (strip an abandoned false-start clause) BEFORE subordination/
352
+ // conditional: a false start can itself look like the OPENING of a subordination
353
+ // clause ("since -- sorry, which modules import X" — "since" would otherwise be
354
+ // read as SUBORDINATION_FRAMES_RE's own anchor), so peeling the restart first
355
+ // means the real remainder is all either frame ever sees.
356
+ q = applySelfCorrectionFrames(q);
277
357
  // subordination (strip a leading framing clause) THEN conditional (compile
278
358
  // "if …" to an existing working shape) — subordination first so a stacked
279
359
  // "since we're refactoring, if a module imports X, is it tested" peels its
@@ -342,6 +422,15 @@ export const PHRASING_FRAMES = Object.freeze([
342
422
  { re: /^what\s+(?:defined|declared)\s+(?:the\s+)?(?:function\s+|method\s+|class\s+|module\s+|variable\s+|constant\s+)?(.+?)\??$/i, to: (m) => `where is ${m[1]} defined` },
343
423
  // "where's X defined" (the "where's" contraction is not in the contraction table)
344
424
  { re: /^where'?s\s+(?:the\s+)?(.+?)\s+(defined|declared|located|implemented)\??$/i, to: (m) => `where is ${m[1]} ${m[2]}` },
425
+ // "were is X defined" (0.9.13 Tier-1 playtest: the missing-h typo of "where").
426
+ // NOT curated as a blanket MISSPELLINGS entry — "were" is a real word already
427
+ // load-bearing as the TEMPORAL_AUX auxiliary ("when were the modules last
428
+ // touched"), so a global word-boundary rewrite would clobber that reading.
429
+ // This frame is anchored to the WHERE-DEFINED shape specifically ("were is
430
+ // … defined/declared/located/implemented"), a construction no legitimate
431
+ // temporal query produces ("were" as an auxiliary never leads directly into
432
+ // a bare "is").
433
+ { re: /^were\s+is\s+(?:the\s+)?(.+?)\s+(defined|declared|located|implemented)\??$/i, to: (m) => `where is ${m[1]} ${m[2]}` },
345
434
 
346
435
  // PREDICATIVE QUALIFIER → the ATTRIBUTIVE form the grammar already answers. The
347
436
  // adjective-qualifier post-filters (ask-vocab.mjs QUALIFIERS: tested/untested,
@@ -74,6 +74,7 @@ const MEMORY_VOCABULARY = [
74
74
  { prop: "rdf:predicate", note: "reified fact: the triple's predicate term" },
75
75
  { prop: "rdf:object", note: "reified fact: the triple's object term" },
76
76
  { prop: "mgx:factProvenance", note: "LEGACY COMPAT SHIM: the ' | '-joined provenance tag string a fact came from; the source-of-truth is now the mgx:statedBy edges derived from it" },
77
+ { prop: "mgx:factQuantifier", note: "OPTIONAL: the quantifier word a plural class-membership teach used ('every'/'some'/'a few'), for literal recall by 'how many Xs are Ys' — never real cardinality counting" },
77
78
  { prop: CREATED_AT_PROP, note: "when an individual was FIRST written, ISO-8601 (first-write-wins on upsert); the audit 'when', the recency input to trust, the novelty signal" },
78
79
  { prop: DERIVED_FROM_PROP, predicate: "derivedFrom", note: "umbrella: a Fact derived from a Source (or another Fact). ext ref prov:wasDerivedFrom (UNVERIFIED-pending-web-check)" },
79
80
  { prop: STATED_BY_PROP, predicate: "statedBy", note: "subPropertyOf derivedFrom: a Source directly asserts this Fact (one edge per independent source — replaces the factProvenance union)" },
@@ -444,7 +445,7 @@ const factIdFor = (s, p, o) => `fact:${fnv1aHex(`${s}\0${p}\0${o}`)}`;
444
445
  * carrying rdf:subject / rdf:predicate / rdf:object (+ provenance). The
445
446
  * Phase-2 ACE parser's write point. Same (s,p,o) → same id → upsert, never a
446
447
  * duplicate. Returns { id }. */
447
- export async function appendFact(dir, { subject, predicate, object, provenance = "", createdAt = "" } = {}) {
448
+ export async function appendFact(dir, { subject, predicate, object, provenance = "", createdAt = "", quantifier = "" } = {}) {
448
449
  const s = normFactTerm(subject);
449
450
  const p = normText(predicate);
450
451
  const o = normFactTerm(object);
@@ -452,6 +453,7 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
452
453
  const id = `fact:${fnv1aHex(`${s}${p}${o}`)}`;
453
454
  const text = `${s} ${p} ${o}`;
454
455
  const tokens = proseTokensFor({ doc: text });
456
+ const q = normText(quantifier);
455
457
  await mutateMemory(dir, (payload) => {
456
458
  const prior = payload.individuals.find((x) => x?.id === id);
457
459
  const priorProv = prior?.attributes?.find((a) => a?.prop === "mgx:factProvenance")?.value || "";
@@ -459,6 +461,10 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
459
461
  // still key on); the Source edges below are DERIVED from it, purely additive.
460
462
  const provs = [...new Set([...priorProv.split(" | "), normText(provenance)].filter(Boolean))];
461
463
  const createdAtVal = firstWriteCreatedAt(prior, createdAt); // first-write-wins
464
+ // first-write-wins for the quantifier too (a re-assert with none, e.g. a
465
+ // plain re-teach, never SILENTLY erases an already-recorded quantifier).
466
+ const priorQ = prior?.attributes?.find((a) => a?.prop === "mgx:factQuantifier")?.value || "";
467
+ const qVal = q || priorQ;
462
468
  upsertIndividual(payload, {
463
469
  id, label: labelOf(text), class: FACT_CLASS,
464
470
  derived_from: [], mentions: [],
@@ -470,6 +476,7 @@ export async function appendFact(dir, { subject, predicate, object, provenance =
470
476
  { prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
471
477
  ...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
472
478
  ...(tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: tokens.join(" ") }] : []),
479
+ ...(qVal ? [{ prop: "mgx:factQuantifier", key: "quantifier", value: qVal }] : []),
473
480
  ],
474
481
  });
475
482
  // Derive Source individuals + statedBy edges from the provenance union and
@@ -512,6 +519,7 @@ export async function appendFacts(dir, facts) {
512
519
  tokens: proseTokensFor({ doc: text }),
513
520
  provenance: normText(f?.provenance),
514
521
  createdAt: f?.createdAt || "",
522
+ quantifier: normText(f?.quantifier),
515
523
  });
516
524
  }
517
525
  const ids = [];
@@ -528,6 +536,9 @@ export async function appendFacts(dir, facts) {
528
536
  // compat shim); the Source edges below are DERIVED from it, purely additive.
529
537
  const provs = [...new Set([...priorProv.split(" | "), f.provenance].filter(Boolean))];
530
538
  const createdAtVal = firstWriteCreatedAt(prior, f.createdAt); // first-write-wins
539
+ // first-write-wins for the quantifier too — same discipline as appendFact.
540
+ const priorQ = prior?.attributes?.find((a) => a?.prop === "mgx:factQuantifier")?.value || "";
541
+ const qVal = f.quantifier || priorQ;
531
542
  const ind = {
532
543
  id: f.id, label: labelOf(f.text), class: FACT_CLASS,
533
544
  derived_from: [], mentions: [],
@@ -539,6 +550,7 @@ export async function appendFacts(dir, facts) {
539
550
  { prop: CREATED_AT_PROP, key: "createdAt", value: createdAtVal },
540
551
  ...(provs.length ? [{ prop: "mgx:factProvenance", key: "provenance", value: provs.join(" | ") }] : []),
541
552
  ...(f.tokens.length ? [{ prop: "mgx:hasProseTokens", key: "prose_tokens", value: f.tokens.join(" ") }] : []),
553
+ ...(qVal ? [{ prop: "mgx:factQuantifier", key: "quantifier", value: qVal }] : []),
542
554
  ],
543
555
  };
544
556
  // Upsert into BOTH the array (replace-in-place keeps order) and the index.
@@ -588,6 +600,7 @@ export function readFactRows(memory) {
588
600
  id: ind.id,
589
601
  subject: get("subject"), predicate: get("predicate"), object: get("object"),
590
602
  provenance: get("provenance"), // legacy compat string, verbatim
603
+ quantifier: get("quantifier"), // "" unless a plural class-membership teach set one (Feature A pt.3)
591
604
  sourceIds, sourceTypes,
592
605
  trust: Number((ind.attributes || []).find((a) => a?.prop === TRUST_SCORE_PROP)?.value) || 0,
593
606
  });