@polycode-projects/the-mechanical-code-talker 2.5.0 → 2.5.3

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 (94) hide show
  1. package/README.md +15 -11
  2. package/bin/tmct.mjs +8 -5
  3. package/corpus/LICENSES.json +7 -0
  4. package/corpus/README.md +3 -3
  5. package/corpus/reference/LICENSE-NOTICE +36 -0
  6. package/corpus/reference/README.md +19 -0
  7. package/corpus/reference/index.json.gz +0 -0
  8. package/corpus/reference/manifest.json +367 -0
  9. package/corpus/reference/shards/ref-00.jsonl.gz +0 -0
  10. package/corpus/reference/shards/ref-01.jsonl.gz +0 -0
  11. package/corpus/reference/shards/ref-02.jsonl.gz +0 -0
  12. package/corpus/reference/shards/ref-03.jsonl.gz +0 -0
  13. package/corpus/reference/shards/ref-04.jsonl.gz +0 -0
  14. package/corpus/reference/shards/ref-05.jsonl.gz +0 -0
  15. package/corpus/reference/shards/ref-06.jsonl.gz +0 -0
  16. package/corpus/reference/shards/ref-07.jsonl.gz +0 -0
  17. package/corpus/reference/shards/ref-08.jsonl.gz +0 -0
  18. package/corpus/reference/shards/ref-09.jsonl.gz +0 -0
  19. package/corpus/reference/shards/ref-0a.jsonl.gz +0 -0
  20. package/corpus/reference/shards/ref-0b.jsonl.gz +0 -0
  21. package/corpus/reference/shards/ref-0c.jsonl.gz +0 -0
  22. package/corpus/reference/shards/ref-0d.jsonl.gz +0 -0
  23. package/corpus/reference/shards/ref-0e.jsonl.gz +0 -0
  24. package/corpus/reference/shards/ref-0f.jsonl.gz +0 -0
  25. package/corpus/reference/shards/ref-10.jsonl.gz +0 -0
  26. package/corpus/reference/shards/ref-11.jsonl.gz +0 -0
  27. package/corpus/reference/shards/ref-12.jsonl.gz +0 -0
  28. package/corpus/reference/shards/ref-13.jsonl.gz +0 -0
  29. package/corpus/reference/shards/ref-14.jsonl.gz +0 -0
  30. package/corpus/reference/shards/ref-15.jsonl.gz +0 -0
  31. package/corpus/reference/shards/ref-16.jsonl.gz +0 -0
  32. package/corpus/reference/shards/ref-17.jsonl.gz +0 -0
  33. package/corpus/reference/shards/ref-18.jsonl.gz +0 -0
  34. package/corpus/reference/shards/ref-19.jsonl.gz +0 -0
  35. package/corpus/reference/shards/ref-1a.jsonl.gz +0 -0
  36. package/corpus/reference/shards/ref-1b.jsonl.gz +0 -0
  37. package/corpus/reference/shards/ref-1c.jsonl.gz +0 -0
  38. package/corpus/reference/shards/ref-1d.jsonl.gz +0 -0
  39. package/corpus/reference/shards/ref-1e.jsonl.gz +0 -0
  40. package/corpus/reference/shards/ref-1f.jsonl.gz +0 -0
  41. package/corpus/reference/shards/ref-20.jsonl.gz +0 -0
  42. package/corpus/reference/shards/ref-21.jsonl.gz +0 -0
  43. package/corpus/reference/shards/ref-22.jsonl.gz +0 -0
  44. package/corpus/reference/shards/ref-23.jsonl.gz +0 -0
  45. package/corpus/reference/shards/ref-24.jsonl.gz +0 -0
  46. package/corpus/reference/shards/ref-25.jsonl.gz +0 -0
  47. package/corpus/reference/shards/ref-26.jsonl.gz +0 -0
  48. package/corpus/reference/shards/ref-27.jsonl.gz +0 -0
  49. package/corpus/reference/shards/ref-28.jsonl.gz +0 -0
  50. package/corpus/reference/shards/ref-29.jsonl.gz +0 -0
  51. package/corpus/reference/shards/ref-2a.jsonl.gz +0 -0
  52. package/corpus/reference/shards/ref-2b.jsonl.gz +0 -0
  53. package/corpus/reference/shards/ref-2c.jsonl.gz +0 -0
  54. package/corpus/reference/shards/ref-2d.jsonl.gz +0 -0
  55. package/corpus/reference/shards/ref-2e.jsonl.gz +0 -0
  56. package/corpus/reference/shards/ref-2f.jsonl.gz +0 -0
  57. package/corpus/reference/shards/ref-30.jsonl.gz +0 -0
  58. package/corpus/reference/shards/ref-31.jsonl.gz +0 -0
  59. package/corpus/reference/shards/ref-32.jsonl.gz +0 -0
  60. package/corpus/reference/shards/ref-33.jsonl.gz +0 -0
  61. package/corpus/reference/shards/ref-34.jsonl.gz +0 -0
  62. package/corpus/reference/shards/ref-35.jsonl.gz +0 -0
  63. package/corpus/reference/shards/ref-36.jsonl.gz +0 -0
  64. package/corpus/reference/shards/ref-37.jsonl.gz +0 -0
  65. package/corpus/reference/shards/ref-38.jsonl.gz +0 -0
  66. package/corpus/reference/shards/ref-39.jsonl.gz +0 -0
  67. package/corpus/reference/shards/ref-3a.jsonl.gz +0 -0
  68. package/corpus/reference/shards/ref-3b.jsonl.gz +0 -0
  69. package/corpus/reference/shards/ref-3c.jsonl.gz +0 -0
  70. package/corpus/reference/shards/ref-3d.jsonl.gz +0 -0
  71. package/corpus/reference/shards/ref-3e.jsonl.gz +0 -0
  72. package/corpus/reference/shards/ref-3f.jsonl.gz +0 -0
  73. package/package.json +8 -4
  74. package/src/adapters/corpus/reference-pack.mjs +107 -0
  75. package/src/adapters/memory/core.mjs +104 -10
  76. package/src/domain/ask.mjs +66 -9
  77. package/src/domain/cli-verbs.mjs +3 -3
  78. package/src/domain/codegraph.mjs +6 -0
  79. package/src/domain/dialogue-acts.mjs +155 -0
  80. package/src/domain/grammar/lexicon-core.json +1 -1
  81. package/src/domain/interpret/normalize.mjs +14 -2
  82. package/src/domain/interpret/strategies/keywords.mjs +10 -1
  83. package/src/domain/memory/trust.mjs +12 -0
  84. package/src/domain/reference-pack.mjs +72 -0
  85. package/src/domain/skos-view.mjs +111 -0
  86. package/src/domain/syllogise.mjs +647 -56
  87. package/src/services/chat.mjs +499 -123
  88. package/src/services/init.mjs +2 -2
  89. package/src/surfaces/web/chat-browser-entry.mjs +87 -0
  90. package/src/surfaces/web/memory-ask-browser.bundle.js +865 -74
  91. package/src/tools/definitions.mjs +13 -0
  92. package/src/tools/handlers/index.mjs +2 -0
  93. package/src/tools/handlers/tmct-related.mjs +30 -0
  94. package/ROADMAP.md +0 -130
@@ -0,0 +1,155 @@
1
+ // The closed dialogue-act vocabulary: tmct's turn types named to ISO 24617-2's
2
+ // communicative functions, using the standard's own camelCase data-category
3
+ // spellings. The router already sorts every turn into a lane, so the label a
4
+ // consumer attaches is a fixed lookup over a decision already made — a table,
5
+ // never a classifier, and no model anywhere.
6
+ //
7
+ // The honesty-preserving line runs through this table: tmct's miss is
8
+ // autoNegative in the autoFeedback dimension — the sender reporting that its
9
+ // OWN processing could not ground the turn — never a task answer. A flat
10
+ // intent set collapses that distinction; keeping it is why the standard is
11
+ // worth adopting over an ad-hoc set.
12
+ //
13
+ // The subset here covers what tmct's lanes produce. The standard's responsive
14
+ // accept/decline family is deliberately absent; a session extending the table
15
+ // should know the standard's own cross-wiring: accepting a request or a
16
+ // suggestion is a COMMISSIVE, while accepting an offer is a DIRECTIVE, and the
17
+ // address* functions are the general "dealing with" nodes those specialise.
18
+ //
19
+ // Source of the names: docs/references/schemas/iso-24617-2-dialogue-acts.md —
20
+ // a good-faith reading of the editors' LREC 2020 paper and the public DIS
21
+ // draft, not a quotation of the paywalled published text.
22
+
23
+ /** The standard's ten dimensions (second edition), camelCase per its Annex F
24
+ * data-category names. Every act in DIALOGUE_ACTS carries exactly one. */
25
+ export const DIALOGUE_ACT_DIMENSIONS = Object.freeze([
26
+ "task",
27
+ "autoFeedback",
28
+ "alloFeedback",
29
+ "turnManagement",
30
+ "timeManagement",
31
+ "discourseStructuring",
32
+ "ownCommunicationManagement",
33
+ "partnerCommunicationManagement",
34
+ "socialObligationsManagement",
35
+ "contactManagement",
36
+ ]);
37
+
38
+ /** Every communicative function tmct's lanes cover. General-purpose functions
39
+ * (questions, informs, directives, commissives) carry the dimension tmct
40
+ * annotates them in — task; the feedback and social functions carry the fixed
41
+ * dimension the standard gives them. `gloss` says what the function is on the
42
+ * standard's terms, with tmct's use in brackets where one exists. */
43
+ export const DIALOGUE_ACTS = Object.freeze({
44
+ propositionalQuestion: Object.freeze({
45
+ dimension: "task",
46
+ gloss: "a yes/no question about whether a proposition holds ('does X import Y?')",
47
+ }),
48
+ checkQuestion: Object.freeze({
49
+ dimension: "task",
50
+ gloss: "a yes/no question whose asker already expects the answer yes ('..., right?')",
51
+ }),
52
+ setQuestion: Object.freeze({
53
+ dimension: "task",
54
+ gloss: "a wh-question asking for the members of a set ('what does X import?')",
55
+ }),
56
+ choiceQuestion: Object.freeze({
57
+ dimension: "task",
58
+ gloss: "a question asking which of the listed alternatives holds ('is X a module or a class?')",
59
+ }),
60
+ inform: Object.freeze({
61
+ dimension: "task",
62
+ gloss: "a declarative telling the addressee something (a teach turn; also tmct explaining its own function)",
63
+ }),
64
+ answer: Object.freeze({
65
+ dimension: "task",
66
+ gloss: "an inform that discharges a question just asked (an answer grounded in the graph)",
67
+ }),
68
+ confirm: Object.freeze({
69
+ dimension: "task",
70
+ gloss: "an answer 'yes' to a check question",
71
+ }),
72
+ disconfirm: Object.freeze({
73
+ dimension: "task",
74
+ gloss: "an answer 'no' to a check question",
75
+ }),
76
+ agreement: Object.freeze({
77
+ dimension: "task",
78
+ gloss: "an inform stating that the speaker holds what the addressee just stated to be true",
79
+ }),
80
+ disagreement: Object.freeze({
81
+ dimension: "task",
82
+ gloss: "an inform stating that the speaker holds what the addressee just stated to be false",
83
+ }),
84
+ correction: Object.freeze({
85
+ dimension: "task",
86
+ gloss: "a disagreement that also supplies the replacement ('no, I meant Y')",
87
+ }),
88
+ request: Object.freeze({
89
+ dimension: "task",
90
+ gloss: "asks the addressee to perform an action ('solve it' — a goal turn)",
91
+ }),
92
+ instruct: Object.freeze({
93
+ dimension: "task",
94
+ gloss: "a request the addressee is expected to carry out without negotiation (a bare imperative)",
95
+ }),
96
+ suggestion: Object.freeze({
97
+ dimension: "task",
98
+ gloss: "puts an action forward as advisable without claiming authority over the addressee",
99
+ }),
100
+ offer: Object.freeze({
101
+ dimension: "task",
102
+ gloss: "commits the speaker to an action, conditional on the addressee wanting it",
103
+ }),
104
+ autoPositive: Object.freeze({
105
+ dimension: "autoFeedback",
106
+ gloss: "the sender reports its own processing of the previous turn succeeded (an acknowledgement)",
107
+ }),
108
+ autoNegative: Object.freeze({
109
+ dimension: "autoFeedback",
110
+ gloss: "the sender reports its own processing of the previous turn failed — tmct's honest miss",
111
+ }),
112
+ initialGreeting: Object.freeze({
113
+ dimension: "socialObligationsManagement",
114
+ gloss: "opens an exchange of greetings ('hi')",
115
+ }),
116
+ returnGreeting: Object.freeze({
117
+ dimension: "socialObligationsManagement",
118
+ gloss: "answers a greeting with a greeting",
119
+ }),
120
+ thanking: Object.freeze({
121
+ dimension: "socialObligationsManagement",
122
+ gloss: "expresses gratitude for something the addressee did",
123
+ }),
124
+ apology: Object.freeze({
125
+ dimension: "socialObligationsManagement",
126
+ gloss: "expresses regret for something the speaker did",
127
+ }),
128
+ });
129
+
130
+ /** The router-lane → dialogue-act lookup the chat surface attaches per turn: a
131
+ * deterministic map over the lane the router already picked. Keys name router
132
+ * lanes; values name DIALOGUE_ACTS entries. The honest-miss row is the one
133
+ * that must never drift: a miss is autoFeedback about tmct's own processing,
134
+ * not a task answer. */
135
+ export const LANE_DIALOGUE_ACTS = Object.freeze({
136
+ teach: "inform",
137
+ "ask-set": "setQuestion",
138
+ "ask-propositional": "propositionalQuestion",
139
+ goal: "request",
140
+ imperative: "instruct",
141
+ "honest-miss": "autoNegative",
142
+ greeting: "initialGreeting",
143
+ thanks: "thanking",
144
+ help: "inform",
145
+ });
146
+
147
+ /** The dialogue act a router lane resolves to, with its dimension — or null
148
+ * for a lane the lookup does not cover (the caller keeps its own naming). */
149
+ export function dialogueActForLane(lane) {
150
+ // hasOwn, so an inherited name ("constructor") is uncovered, never a hit
151
+ // found on the prototype chain.
152
+ if (typeof lane !== "string" || !Object.hasOwn(LANE_DIALOGUE_ACTS, lane)) return null;
153
+ const act = LANE_DIALOGUE_ACTS[lane];
154
+ return { act, dimension: DIALOGUE_ACTS[act].dimension };
155
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "comment": "tmct's starter lexicon (ROADMAP Phase 2, item 2), now covering BOTH the original software domain and the default 'human-world' persona's everyday people/places/objects/nature/time/body/mind vocabulary. Every word the ACE-OWL sub-fragment parser (src/domain/grammar/ace.mjs) is allowed to understand is DECLARED here — tmct never guesses a word's category. Nouns may declare a possessive property typing ('data' or 'object', pattern 7) or an irregular 'plural' (WordNet-sourced human nouns like 'man'/'child' need this — the morphology fold only handles regular -s/-ies/-es endings); adjectives MUST declare a type ('subclass' forms a class, 'data' asserts a boolean-ish datatype property, pattern 8); verbs may declare a preposition ('depend' + 'on' → dependsOn). The human-domain additions are flat and simple (breadth over depth — no gender/kinship taxonomy, no formal role hierarchy), matching corpus/tier2/generate.mjs's CORPUSES.human.lexicon sub-key, which --verify drift-checks against. Extend via loadLexicon(extra) with this same shape.",
2
+ "comment": "tmct's starter lexicon, now covering BOTH the original software domain and the default 'human-world' persona's everyday people/places/objects/nature/time/body/mind vocabulary. Every word the ACE-OWL sub-fragment parser (src/domain/grammar/ace.mjs) is allowed to understand is DECLARED here — tmct never guesses a word's category. Nouns may declare a possessive property typing ('data' or 'object', pattern 7) or an irregular 'plural' (WordNet-sourced human nouns like 'man'/'child' need this — the morphology fold only handles regular -s/-ies/-es endings); adjectives MUST declare a type ('subclass' forms a class, 'data' asserts a boolean-ish datatype property, pattern 8); verbs may declare a preposition ('depend' + 'on' → dependsOn). The human-domain additions are flat and simple (breadth over depth — no gender/kinship taxonomy, no formal role hierarchy), matching corpus/tier2/generate.mjs's CORPUSES.human.lexicon sub-key, which --verify drift-checks against. Extend via loadLexicon(extra) with this same shape.",
3
3
  "nouns": {
4
4
  "module": {},
5
5
  "class": {},
@@ -153,14 +153,14 @@ const TELL_ME_WRAPPER_RE = /^tell\s+me\s+(.+?)\??$/i;
153
153
  const KNOW_WRAPPER_RE = /^do\s+you\s+know\s+(.+?)\??$/i;
154
154
  /** "i'd like to know <Q>" / "i want to know <Q>" -> "<Q>", same
155
155
  * interrogative-remainder gate as KNOW_WRAPPER_RE. */
156
- const WANT_KNOW_WRAPPER_RE = /^i(?:'d|\s+would)?\s+(?:like|want|need)\s+to\s+know\s+(.+?)\??$/i;
156
+ const WANT_KNOW_WRAPPER_RE = /^i(?:'d|\s+would)?\s+(?:(?:like|want|need)\s+to|wanna)\s+know\s+(.+?)\??$/i;
157
157
  /** "i was wondering <Q>" / "i wondered <Q>" / "i'm curious <Q>" -> "<Q>", the
158
158
  * same wrapper family and the same interrogative-remainder gate. The modal
159
159
  * form ("could you tell me what a dog is") was already unwrapped; this one
160
160
  * states the wish rather than asking, which is how it fell through every
161
161
  * frame. The optional "if"/"whether"/"about" tail carries the same clause
162
162
  * MODAL_WRAPPER_RE's own remainder does. */
163
- const WONDERING_WRAPPER_RE = /^i(?:\s+was|\s+am|'m)?\s+(?:just\s+)?(?:wonder(?:ing|ed)|curious)(?:\s+(?:if|whether|about))?\s+(.+?)\??$/i;
163
+ const WONDERING_WRAPPER_RE = /^i(?:\s+was|\s+am|'m)?\s+(?:just\s+)?(?:wonder(?:ing|ed)?|curious)(?:\s+(?:if|whether|about))?\s+(.+?)\??$/i;
164
164
  /** EMBEDDED-QUESTION DE-INVERSION: the wrappers above unwrap "could you
165
165
  * tell me what a dog is" down to the embedded clause "what a dog is",
166
166
  * which keeps declarative word order — nothing downstream parses it. Fold
@@ -498,6 +498,18 @@ const PHRASING_FRAMES = Object.freeze([
498
498
  // NEEDS-TESTS → the untested-module survey.
499
499
  { re: /^what\s+needs\s+(?:to\s+be\s+)?(?:a\s+)?(?:tested|tests?|testing|coverage|covering)\??$/i, to: () => "untested modules" },
500
500
 
501
+ // NO-TESTS → the same untested survey, attributive form. "no tests" would
502
+ // otherwise read as a literal object term ("…defines no tests") and the
503
+ // resulting empty answers as "it has none" to a coverage question.
504
+ {
505
+ re: /^(?:which|what|list|show(?:\s+me)?|find)?\s*(?:the\s+|all\s+)?([a-z][a-z-]*?)\s+(?:(?:that|which)\s+)?(?:have|has|having|with)\s+no\s+tests?\??$/i,
506
+ to: (m) => `untested ${m[1].toLowerCase()}`,
507
+ },
508
+ {
509
+ re: /^(?:which|what|list|show(?:\s+me)?|find)?\s*(?:the\s+|all\s+)?([a-z][a-z-]*?)\s+without\s+(?:any\s+)?tests?\??$/i,
510
+ to: (m) => `untested ${m[1].toLowerCase()}`,
511
+ },
512
+
501
513
  // DOES-X-VERB-ANYTHING-ELSE → "what does X <verb>" (drops the placeholder
502
514
  // "anything/something else" object, which otherwise made the two parse
503
515
  // strategies disagree on the span). Anchored to VERB_TO_KIND so it can't
@@ -222,7 +222,16 @@ export function parseKeywordSpot(text, nlp = null) {
222
222
  if (INHERITS_REVERSE_VERBS.includes(verbPhrase)) [subject, object] = [object, subject];
223
223
  return stamp({ shape: "ask", entityType: null, modifier: "direct", kind, subject, object });
224
224
  }
225
- if (afterText) return stamp({ shape: "reverse", entityType, modifier, kind, object: afterText });
225
+ if (afterText) {
226
+ // A type word riding beside the named object ("what uses the Store
227
+ // CLASS") describes the OBJECT's grain — it says which "Store" is meant,
228
+ // not what class of thing may answer. Reading it as the result filter
229
+ // turned "what uses the Store class" into "which classes use Store" and
230
+ // answered "no classes" over a module-level graph. Only a type word on
231
+ // the wh-side of the verb ("which CLASSES use Store") filters the result.
232
+ const resultType = entityHit && entityHit.start >= verbHit.end ? null : entityType;
233
+ return stamp({ shape: "reverse", entityType: resultType, modifier, kind, object: afterText });
234
+ }
226
235
  // "what is a kind of class": when the object is itself an entity-type noun, the
227
236
  // entity match swallows the whole post-verb span as a grain qualifier, leaving
228
237
  // afterText empty — re-read that span as the object instead.
@@ -33,6 +33,9 @@ function parseChatTagRest(rest) {
33
33
  * ace:chat:<session>@<ts> -> { kind:"operator", createdAt:<ts>, sessionId:<session> }
34
34
  * teach:chat:<session>@<ts> -> { kind:"teach", createdAt:<ts>, sessionId:<session> }
35
35
  * web:<url> | url:<url> -> { kind:"web", url:<url> }
36
+ * reference:<pack>:<article>[@revid] -> { kind:"reference", pack, article }
37
+ * (split on the first two colons only; the article keeps any @revid and
38
+ * any spaces — "reference:simplewiki:Polar bear@912" stays one article)
36
39
  * extracted:<file-basename> -> { kind:"extracted", name:<file-basename> }
37
40
  * entailed:<rule> -> { kind:"entailed", rule:<rule> }
38
41
  * chat:/session: refs map to the operator; an unknown tag -> null (no Source).
@@ -40,6 +43,14 @@ function parseChatTagRest(rest) {
40
43
  export function provenanceTagToSource(tag) {
41
44
  const t = String(tag || "").trim();
42
45
  if (!t) return null;
46
+ if (t.startsWith("reference:")) {
47
+ // parsed from the FULL tag, not the whitespace-split head: an article
48
+ // title may contain spaces ("Polar bear@912").
49
+ const rest = t.slice("reference:".length);
50
+ const colon = rest.indexOf(":");
51
+ if (colon < 0) return { kind: "reference", pack: rest || "unknown", article: "" };
52
+ return { kind: "reference", pack: rest.slice(0, colon) || "unknown", article: rest.slice(colon + 1) };
53
+ }
43
54
  const head = t.split(/\s+/)[0]; // drop trailing " /r/IsA" etc.
44
55
  if (head.startsWith("corpus-weak:")) return { kind: "corpusWeak", name: head.slice("corpus-weak:".length) || "unknown" };
45
56
  if (head.startsWith("corpus:")) return { kind: "corpus", name: head.slice("corpus:".length) || "unknown" };
@@ -63,6 +74,7 @@ export const SOURCE_PRIOR = Object.freeze({
63
74
  teach: 0.95,
64
75
  provider: 0.9,
65
76
  corpus: 0.7,
77
+ reference: 0.6,
66
78
  corpusWeak: 0.55,
67
79
  web: 0.4,
68
80
  extracted: 0.45,
@@ -0,0 +1,72 @@
1
+ // reference-pack.mjs — the pure half of the shipped reference pack: the shard
2
+ // naming contract, the index/row shape validators every writer and reader
3
+ // share, the cited answer line, the provenance tag, and the pure part of the
4
+ // clean-miss gate. The pack itself is 64 gzipped JSONL shards plus a gzipped
5
+ // term index; loading them is I/O and lives in
6
+ // src/adapters/corpus/reference-pack.mjs.
7
+
8
+ import { fnv1aHex, normFactTerm } from "./hash.mjs";
9
+ import { loadLexicon, lookupNoun } from "./grammar/lexicon.mjs";
10
+ import { RELATION_TERM } from "./concept.mjs";
11
+
12
+ export const REFERENCE_PACK_NAME = "simplewiki";
13
+ export const REFERENCE_SHARD_COUNT = 64;
14
+
15
+ /** The shard a term's article row lives in: FNV-1a first byte mod 64, as the
16
+ * file basename "ref-00" … "ref-3f". Part of the pack's on-disk contract —
17
+ * the build script shards with THIS function, so the reader never scans. */
18
+ export function shardNameFor(term) {
19
+ const byte = parseInt(fnv1aHex(String(term ?? "")).slice(0, 2), 16);
20
+ return `ref-${(byte % REFERENCE_SHARD_COUNT).toString(16).padStart(2, "0")}`;
21
+ }
22
+
23
+ const SHARD_NAME_RE = /^ref-[0-3][0-9a-f]$/;
24
+
25
+ /** An index entry {s, t, r}: the shard holding the row, the row's canonical
26
+ * term key (an alias entry points at its target's), and the revision id. */
27
+ export function isReferenceIndexEntry(e) {
28
+ return !!e && typeof e === "object"
29
+ && typeof e.s === "string" && SHARD_NAME_RE.test(e.s)
30
+ && typeof e.t === "string" && e.t.length > 0
31
+ && Number.isInteger(e.r) && e.r > 0;
32
+ }
33
+
34
+ /** A shard row: {term, title, text, summary, url, revid, isa?}. */
35
+ export function isReferenceArticleRow(row) {
36
+ if (!row || typeof row !== "object") return false;
37
+ for (const field of ["term", "title", "text", "summary", "url"]) {
38
+ if (typeof row[field] !== "string" || !row[field]) return false;
39
+ }
40
+ if (!Number.isInteger(row.revid) || row.revid <= 0) return false;
41
+ if (row.isa !== undefined && (typeof row.isa !== "string" || !row.isa)) return false;
42
+ return true;
43
+ }
44
+
45
+ /** 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. */
47
+ export function renderReferenceAnswer(term, article) {
48
+ return `${term} — ${article.summary} (source: reference article "${article.title}", `
49
+ + `Simple English Wikipedia, CC BY-SA 4.0 — ${article.url}?oldid=${article.revid})`;
50
+ }
51
+
52
+ /** The provenance tag a fact stored from a pack article carries —
53
+ * memory/trust.mjs parses it back to { kind:"reference", pack, article }. */
54
+ export function referenceProvenanceTag(article) {
55
+ return `reference:${REFERENCE_PACK_NAME}:${article.title}@${article.revid}`;
56
+ }
57
+
58
+ /** The pure half of the clean-miss gate: is this term even a pack-lookup
59
+ * candidate, and under which key? Returns the pack key (the lexicon entry's
60
+ * lemma, normFactTerm-folded) or null when the term is empty, not plain
61
+ * words, a vague relation touch (RELATION_TERM owns those), or not in the
62
+ * lexicon at all — an unknown word is never a clean miss. The async half of
63
+ * the gate (parse succeeded, graph/memory genuinely empty on the term) stays
64
+ * with the caller. */
65
+ export function cleanMissReferenceTerm(term, lexicon = loadLexicon()) {
66
+ const t = normFactTerm(term);
67
+ if (!t || !/^[a-z][a-z' -]*$/.test(t)) return null;
68
+ if (RELATION_TERM[t]) return null;
69
+ const entry = lookupNoun(lexicon, t);
70
+ if (!entry) return null;
71
+ return normFactTerm(entry.lemma);
72
+ }
@@ -0,0 +1,111 @@
1
+ // A derived SKOS concept-identity view over the store's bare-string terms.
2
+ //
3
+ // The corpus stores its term relations as reified facts whose subject/object are
4
+ // bare, normalised strings ("module", not "tmct:module") — normFactTerm strips
5
+ // the CURIE prefix at write time. SKOS S19/S20 fix the domain and range of
6
+ // skos:related to skos:Concept, so the relations cannot map onto SKOS while the
7
+ // terms are plain strings with no concept identity.
8
+ //
9
+ // buildSkosConceptView mints that identity WITHOUT touching storage: it reads the
10
+ // fact rows a store already returns, mints one skos:Concept per normalised term
11
+ // (or per synonym-merged group), and re-expresses mgx:synonym and mgx:relatedTo
12
+ // as SKOS. Nothing is written; the same store, read twice, yields the same view.
13
+ // The minted concepts ARE skos:Concepts by construction, so skos:related between
14
+ // them satisfies S19/S20. skos:prefLabel/altLabel carry no domain (S10/S11), so
15
+ // labelling the minted concepts is well-formed SKOS.
16
+
17
+ import { normFactTerm } from "./hash.mjs";
18
+
19
+ export const SKOS_NS = "http://www.w3.org/2004/02/skos/core#";
20
+
21
+ // mgx:antonym is deliberately absent: SKOS has no opposition relation, and
22
+ // asserting skos:related for an antonym would claim the association the corpus
23
+ // denies. It stays mgx: and reads as a bare string — the honest miss.
24
+ export const DEFAULT_RELATION_MAP = {
25
+ synonym: ["mgx:synonym"],
26
+ related: ["mgx:relatedTo", "mgx:similarTo"],
27
+ };
28
+
29
+ export function buildSkosConceptView(rows, { conceptBase = "concept:", relationMap = DEFAULT_RELATION_MAP } = {}) {
30
+ const synonymPreds = new Set(relationMap.synonym || []);
31
+ const relatedPreds = new Set(relationMap.related || []);
32
+
33
+ const parent = new Map();
34
+ const ensure = (t) => { if (!parent.has(t)) parent.set(t, t); };
35
+ const find = (x) => {
36
+ let r = x;
37
+ while (parent.get(r) !== r) r = parent.get(r);
38
+ while (parent.get(x) !== r) { const next = parent.get(x); parent.set(x, r); x = next; }
39
+ return r;
40
+ };
41
+ const union = (a, b) => {
42
+ const ra = find(a), rb = find(b);
43
+ if (ra === rb) return;
44
+ if (ra < rb) parent.set(rb, ra); else parent.set(ra, rb); // smaller string wins — deterministic
45
+ };
46
+
47
+ const relatedRaw = [];
48
+ for (const r of rows) {
49
+ const p = r.predicate;
50
+ if (!synonymPreds.has(p) && !relatedPreds.has(p)) continue;
51
+ const s = normFactTerm(r.subject), o = normFactTerm(r.object);
52
+ if (!s || !o) continue;
53
+ ensure(s); ensure(o);
54
+ if (synonymPreds.has(p)) union(s, o); else relatedRaw.push({ s, o });
55
+ }
56
+
57
+ const iriFor = (rep) => conceptBase + rep.replace(/ /g, "_");
58
+ const componentTerms = new Map();
59
+ for (const t of parent.keys()) {
60
+ const rep = find(t);
61
+ if (!componentTerms.has(rep)) componentTerms.set(rep, new Set());
62
+ componentTerms.get(rep).add(t);
63
+ }
64
+
65
+ const concepts = [];
66
+ for (const [rep, terms] of componentTerms) {
67
+ const sorted = [...terms].sort();
68
+ concepts.push({ id: iriFor(rep), prefLabel: rep, altLabels: sorted.filter((t) => t !== rep) });
69
+ }
70
+ concepts.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
71
+
72
+ const seen = new Set();
73
+ const related = [];
74
+ for (const { s, o } of relatedRaw) {
75
+ const cs = iriFor(find(s)), co = iriFor(find(o));
76
+ if (cs === co) continue; // a synonym merge collapsed both terms into one concept
77
+ // NUL-delimited pair key, same idiom as the store's fact ids — spelled as an
78
+ // escape so this file stays plain text (a literal NUL reads as binary to git)
79
+ const key = cs < co ? `${cs}\u0000${co}` : `${co}\u0000${cs}`;
80
+ if (seen.has(key)) continue;
81
+ seen.add(key);
82
+ related.push({ subject: cs, object: co });
83
+ }
84
+ related.sort((a, b) => (`${a.subject}${a.object}` < `${b.subject}${b.object}` ? -1 : 1));
85
+
86
+ const conceptIdForTerm = (term) => {
87
+ const t = normFactTerm(term);
88
+ return parent.has(t) ? iriFor(find(t)) : null;
89
+ };
90
+ return { concepts, related, conceptIdForTerm, namespace: SKOS_NS };
91
+ }
92
+
93
+ /** One term's SKOS neighbourhood out of a store's fact rows: its concept, the
94
+ * synonym group read as labels, and the skos:related neighbour concepts.
95
+ * `synonyms` is the group minus the queried term — the caller-facing "another
96
+ * word for X" answer. Null when the term mints no concept (unknown, or no
97
+ * synonym/related facts), so the caller keeps its honest miss. */
98
+ export function relatedForTerm(rows, term, options = {}) {
99
+ const view = buildSkosConceptView(rows, options);
100
+ const conceptId = view.conceptIdForTerm(term);
101
+ if (!conceptId) return null;
102
+ const byId = new Map(view.concepts.map((c) => [c.id, c]));
103
+ const concept = byId.get(conceptId);
104
+ const queried = normFactTerm(term);
105
+ const synonyms = [concept.prefLabel, ...concept.altLabels].filter((label) => label !== queried);
106
+ const related = view.related
107
+ .filter((r) => r.subject === conceptId || r.object === conceptId)
108
+ .map((r) => byId.get(r.subject === conceptId ? r.object : r.subject))
109
+ .filter(Boolean);
110
+ return { conceptId, prefLabel: concept.prefLabel, altLabels: concept.altLabels, synonyms, related };
111
+ }