@lucern/contracts 0.1.0 → 0.1.1-alpha.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.
Files changed (87) hide show
  1. package/dist/index.d.ts +2003 -21
  2. package/dist/index.js +5627 -22
  3. package/package.json +15 -25
  4. package/src/agents/v1.ts +8 -0
  5. package/src/api-enums.contract.ts +183 -0
  6. package/src/auth-context.contract.ts +9 -0
  7. package/src/auth-session.contract.ts +9 -0
  8. package/src/auth.contract.ts +162 -0
  9. package/src/beliefs/v1.ts +8 -0
  10. package/src/context-pack.contract.ts +704 -0
  11. package/src/convex-admin.contract.ts +14 -0
  12. package/src/events-types.contract.ts +9 -0
  13. package/src/events.contract.ts +376 -0
  14. package/src/evidence/v1.ts +8 -0
  15. package/src/gateway.contract.ts +151 -0
  16. package/src/graph/v1.ts +8 -0
  17. package/src/ids.contract.ts +36 -0
  18. package/src/index.ts +30 -0
  19. package/src/lens-filter.contract.ts +183 -0
  20. package/src/lens-workflow.contract.ts +162 -0
  21. package/src/mcp-tools.contract.ts +3636 -0
  22. package/src/ontologies/v1.ts +8 -0
  23. package/src/ontology-matching.contract.ts +9 -0
  24. package/src/prompt.contract.ts +50 -0
  25. package/src/questions/v1.ts +8 -0
  26. package/src/sdk-methods.contract.ts +522 -0
  27. package/src/sdk-tools.contract.ts +1545 -0
  28. package/src/text-matching.contract.ts +347 -0
  29. package/src/topic-scope.contract.ts +9 -0
  30. package/src/topics/v1.ts +8 -0
  31. package/src/v1/agents/v1.ts +8 -0
  32. package/src/v1/beliefs/v1.ts +8 -0
  33. package/src/v1/evidence/v1.ts +8 -0
  34. package/src/v1/graph/v1.ts +8 -0
  35. package/src/v1/ontologies/v1.ts +276 -0
  36. package/src/v1/questions/v1.ts +8 -0
  37. package/src/v1/topics/v1.ts +79 -0
  38. package/src/v1/worktrees/v1.ts +8 -0
  39. package/src/workflow-runtime.contract.ts +440 -0
  40. package/src/worktrees/v1.ts +8 -0
  41. package/tsconfig.json +9 -0
  42. package/dist/api-enums.contract.d.ts +0 -59
  43. package/dist/api-enums.contract.d.ts.map +0 -1
  44. package/dist/api-enums.contract.js +0 -148
  45. package/dist/api-enums.contract.js.map +0 -1
  46. package/dist/auth-session.contract.d.ts +0 -54
  47. package/dist/auth-session.contract.d.ts.map +0 -1
  48. package/dist/auth-session.contract.js +0 -50
  49. package/dist/auth-session.contract.js.map +0 -1
  50. package/dist/context-pack.contract.d.ts +0 -495
  51. package/dist/context-pack.contract.d.ts.map +0 -1
  52. package/dist/context-pack.contract.js +0 -170
  53. package/dist/context-pack.contract.js.map +0 -1
  54. package/dist/gateway.contract.d.ts +0 -74
  55. package/dist/gateway.contract.d.ts.map +0 -1
  56. package/dist/gateway.contract.js +0 -12
  57. package/dist/gateway.contract.js.map +0 -1
  58. package/dist/index.d.ts.map +0 -1
  59. package/dist/index.js.map +0 -1
  60. package/dist/lens-filter.contract.d.ts +0 -71
  61. package/dist/lens-filter.contract.d.ts.map +0 -1
  62. package/dist/lens-filter.contract.js +0 -96
  63. package/dist/lens-filter.contract.js.map +0 -1
  64. package/dist/lens-workflow.contract.d.ts +0 -85
  65. package/dist/lens-workflow.contract.d.ts.map +0 -1
  66. package/dist/lens-workflow.contract.js +0 -55
  67. package/dist/lens-workflow.contract.js.map +0 -1
  68. package/dist/mcp-tools.contract.d.ts +0 -152
  69. package/dist/mcp-tools.contract.d.ts.map +0 -1
  70. package/dist/mcp-tools.contract.js +0 -3282
  71. package/dist/mcp-tools.contract.js.map +0 -1
  72. package/dist/prompt.contract.d.ts +0 -25
  73. package/dist/prompt.contract.d.ts.map +0 -1
  74. package/dist/prompt.contract.js +0 -25
  75. package/dist/prompt.contract.js.map +0 -1
  76. package/dist/sdk-methods.contract.d.ts +0 -356
  77. package/dist/sdk-methods.contract.d.ts.map +0 -1
  78. package/dist/sdk-methods.contract.js +0 -17
  79. package/dist/sdk-methods.contract.js.map +0 -1
  80. package/dist/sdk-tools.contract.d.ts +0 -93
  81. package/dist/sdk-tools.contract.d.ts.map +0 -1
  82. package/dist/sdk-tools.contract.js +0 -1399
  83. package/dist/sdk-tools.contract.js.map +0 -1
  84. package/dist/workflow-runtime.contract.d.ts +0 -162
  85. package/dist/workflow-runtime.contract.d.ts.map +0 -1
  86. package/dist/workflow-runtime.contract.js +0 -258
  87. package/dist/workflow-runtime.contract.js.map +0 -1
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Shared lexical matching primitives used across MCP handlers and graph utilities.
3
+ *
4
+ * The goal is not to replace downstream LLM scoring. It provides a fast,
5
+ * deterministic substrate for candidate generation, reranking, and light
6
+ * classification across belief/question/evidence/entity surfaces.
7
+ */
8
+
9
+ export type LexicalStrategy = "tokenOverlap" | "bigramJaccard" | "wordOverlap";
10
+
11
+ export type PreparedLexicalQuery = {
12
+ raw: string;
13
+ tokens: string[];
14
+ words: string[];
15
+ bigrams: Set<string>;
16
+ };
17
+
18
+ export type LexicalSignal = {
19
+ strategy?: LexicalStrategy;
20
+ text: string | null | undefined;
21
+ weight: number;
22
+ };
23
+
24
+ export type LexicalRerankOptions = {
25
+ lexicalWeight?: number;
26
+ rankWeight?: number;
27
+ };
28
+
29
+ const TOKEN_SPLIT_REGEX = /[^a-z0-9]+/;
30
+ const NON_ALPHANUMERIC_REGEX = /[^a-z0-9]/g;
31
+
32
+ /** Stop words that add noise to scoring. */
33
+ const STOP_WORDS = new Set([
34
+ "the",
35
+ "a",
36
+ "an",
37
+ "and",
38
+ "or",
39
+ "but",
40
+ "in",
41
+ "on",
42
+ "at",
43
+ "to",
44
+ "for",
45
+ "of",
46
+ "with",
47
+ "by",
48
+ "from",
49
+ "is",
50
+ "it",
51
+ "as",
52
+ "be",
53
+ "was",
54
+ "are",
55
+ "this",
56
+ "that",
57
+ "has",
58
+ "had",
59
+ "have",
60
+ "not",
61
+ "all",
62
+ "can",
63
+ "do",
64
+ "its",
65
+ "may",
66
+ "will",
67
+ "how",
68
+ "what",
69
+ "which",
70
+ "who",
71
+ "when",
72
+ "where",
73
+ "than",
74
+ "then",
75
+ "each",
76
+ "into",
77
+ "such",
78
+ "any",
79
+ "been",
80
+ "if",
81
+ "would",
82
+ "about",
83
+ "should",
84
+ "these",
85
+ "those",
86
+ "their",
87
+ "we",
88
+ "our",
89
+ "so",
90
+ ]);
91
+
92
+ /** Tokenize a string into lowercase words, removing stop words. */
93
+ export function tokenizeSearchText(text: string): string[] {
94
+ return text
95
+ .toLowerCase()
96
+ .split(TOKEN_SPLIT_REGEX)
97
+ .filter((token) => token.length >= 2 && !STOP_WORDS.has(token));
98
+ }
99
+
100
+ /** Simple stemmer: strip common English suffixes for fuzzy matching. */
101
+ export function stemToken(word: string): string {
102
+ if (word.length <= 4) {
103
+ return word;
104
+ }
105
+ if (word.endsWith("ation")) {
106
+ return word.slice(0, -5);
107
+ }
108
+ if (word.endsWith("ment")) {
109
+ return word.slice(0, -4);
110
+ }
111
+ if (word.endsWith("ness")) {
112
+ return word.slice(0, -4);
113
+ }
114
+ if (word.endsWith("ical")) {
115
+ return word.slice(0, -4);
116
+ }
117
+ if (word.endsWith("tion")) {
118
+ return word.slice(0, -4);
119
+ }
120
+ if (word.endsWith("sion")) {
121
+ return word.slice(0, -4);
122
+ }
123
+ if (word.endsWith("ing")) {
124
+ return word.slice(0, -3);
125
+ }
126
+ if (word.endsWith("ous")) {
127
+ return word.slice(0, -3);
128
+ }
129
+ if (word.endsWith("ive")) {
130
+ return word.slice(0, -3);
131
+ }
132
+ if (word.endsWith("ity")) {
133
+ return word.slice(0, -3);
134
+ }
135
+ if (word.endsWith("ics")) {
136
+ return word.slice(0, -3);
137
+ }
138
+ if (word.endsWith("ly")) {
139
+ return word.slice(0, -2);
140
+ }
141
+ if (word.endsWith("ed")) {
142
+ return word.slice(0, -2);
143
+ }
144
+ if (word.endsWith("er")) {
145
+ return word.slice(0, -2);
146
+ }
147
+ if (word.endsWith("es")) {
148
+ return word.slice(0, -2);
149
+ }
150
+ if (word.endsWith("al")) {
151
+ return word.slice(0, -2);
152
+ }
153
+ if (word.endsWith("ic")) {
154
+ return word.slice(0, -2);
155
+ }
156
+ if (word.endsWith("s") && !word.endsWith("ss")) {
157
+ return word.slice(0, -1);
158
+ }
159
+ return word;
160
+ }
161
+
162
+ /** Compute token overlap score between query tokens and text tokens. */
163
+ export function tokenOverlapScore(
164
+ queryTokens: string[],
165
+ textTokens: string[]
166
+ ): number {
167
+ if (queryTokens.length === 0 || textTokens.length === 0) {
168
+ return 0;
169
+ }
170
+
171
+ const stemmedText = new Set(textTokens.map(stemToken));
172
+ let matchCount = 0;
173
+
174
+ for (const queryToken of queryTokens) {
175
+ const stemmedQuery = stemToken(queryToken);
176
+
177
+ if (stemmedText.has(stemmedQuery)) {
178
+ matchCount += 1;
179
+ continue;
180
+ }
181
+
182
+ for (const textToken of stemmedText) {
183
+ if (
184
+ textToken.startsWith(stemmedQuery) ||
185
+ stemmedQuery.startsWith(textToken)
186
+ ) {
187
+ matchCount += 0.5;
188
+ break;
189
+ }
190
+ }
191
+ }
192
+
193
+ return matchCount / queryTokens.length;
194
+ }
195
+
196
+ /**
197
+ * Extract character bigrams from text. Normalizes to lowercase, removes
198
+ * non-alphanumeric characters, and generates overlapping pairs.
199
+ */
200
+ export function bigramTokenize(text: string): Set<string> {
201
+ const normalized = text.toLowerCase().replace(NON_ALPHANUMERIC_REGEX, "");
202
+ const bigrams = new Set<string>();
203
+ for (let i = 0; i < normalized.length - 1; i++) {
204
+ bigrams.add(normalized.slice(i, i + 2));
205
+ }
206
+ return bigrams;
207
+ }
208
+
209
+ /**
210
+ * Extract word-level tokens from text (for coarser matching).
211
+ * Normalizes to lowercase, splits on non-alphanumeric.
212
+ */
213
+ export function wordTokenize(text: string): string[] {
214
+ return text
215
+ .toLowerCase()
216
+ .split(TOKEN_SPLIT_REGEX)
217
+ .filter((token) => token.length > 1);
218
+ }
219
+
220
+ /** Jaccard similarity between two sets: |A ∩ B| / |A ∪ B|. */
221
+ export function jaccardSimilarity(
222
+ setA: Set<string>,
223
+ setB: Set<string>
224
+ ): number {
225
+ if (setA.size === 0 && setB.size === 0) {
226
+ return 0;
227
+ }
228
+
229
+ let intersectionSize = 0;
230
+ const smaller = setA.size <= setB.size ? setA : setB;
231
+ const larger = setA.size <= setB.size ? setB : setA;
232
+
233
+ for (const item of smaller) {
234
+ if (larger.has(item)) {
235
+ intersectionSize++;
236
+ }
237
+ }
238
+
239
+ const unionSize = setA.size + setB.size - intersectionSize;
240
+ return unionSize === 0 ? 0 : intersectionSize / unionSize;
241
+ }
242
+
243
+ /** Exact word overlap score: fraction of type words found in input text. */
244
+ export function wordOverlapScore(
245
+ inputWords: string[],
246
+ typeWords: string[]
247
+ ): number {
248
+ if (typeWords.length === 0) {
249
+ return 0;
250
+ }
251
+ let matches = 0;
252
+ for (const word of typeWords) {
253
+ if (inputWords.includes(word)) {
254
+ matches++;
255
+ }
256
+ }
257
+ return matches / typeWords.length;
258
+ }
259
+
260
+ /** Pre-compute reusable lexical structures for a query. */
261
+ export function prepareLexicalQuery(query: string): PreparedLexicalQuery {
262
+ return {
263
+ raw: query,
264
+ tokens: tokenizeSearchText(query),
265
+ words: wordTokenize(query),
266
+ bigrams: bigramTokenize(query),
267
+ };
268
+ }
269
+
270
+ /** Score a single lexical signal against a prepared query. */
271
+ export function scoreLexicalSignal(
272
+ query: PreparedLexicalQuery,
273
+ signal: LexicalSignal
274
+ ): number {
275
+ const text = signal.text?.trim();
276
+ if (!text) {
277
+ return 0;
278
+ }
279
+
280
+ switch (signal.strategy ?? "tokenOverlap") {
281
+ case "bigramJaccard":
282
+ return jaccardSimilarity(query.bigrams, bigramTokenize(text));
283
+ case "wordOverlap":
284
+ return wordOverlapScore(query.words, wordTokenize(text));
285
+ default:
286
+ return tokenOverlapScore(query.tokens, tokenizeSearchText(text));
287
+ }
288
+ }
289
+
290
+ /** Weighted lexical score across multiple textual signals. */
291
+ export function scoreLexicalSignals(
292
+ query: PreparedLexicalQuery,
293
+ signals: LexicalSignal[]
294
+ ): number {
295
+ let weightedScore = 0;
296
+ let totalWeight = 0;
297
+
298
+ for (const signal of signals) {
299
+ if (!signal.text?.trim() || signal.weight <= 0) {
300
+ continue;
301
+ }
302
+ weightedScore += scoreLexicalSignal(query, signal) * signal.weight;
303
+ totalWeight += signal.weight;
304
+ }
305
+
306
+ return totalWeight === 0 ? 0 : weightedScore / totalWeight;
307
+ }
308
+
309
+ /** Map a candidate's original rank position into a 0..1 prior. */
310
+ export function rankWindowScore(index: number, total: number): number {
311
+ if (total <= 1) {
312
+ return 1;
313
+ }
314
+ const clampedIndex = Math.max(0, Math.min(index, total - 1));
315
+ return 1 - clampedIndex / (total - 1);
316
+ }
317
+
318
+ /** Rerank a candidate window by lexical overlap while preserving original-rank prior. */
319
+ export function rerankLexicalWindow<T>(
320
+ query: string,
321
+ items: T[],
322
+ getText: (item: T) => string | null | undefined,
323
+ options?: LexicalRerankOptions
324
+ ): T[] {
325
+ const preparedQuery = prepareLexicalQuery(query);
326
+ if (preparedQuery.tokens.length === 0 || items.length <= 1) {
327
+ return items;
328
+ }
329
+
330
+ const lexicalWeight = options?.lexicalWeight ?? 0.65;
331
+ const rankWeight = options?.rankWeight ?? 0.35;
332
+
333
+ return items
334
+ .map((item, index) => {
335
+ const lexicalScore = scoreLexicalSignals(preparedQuery, [
336
+ { text: getText(item) ?? "", weight: 1, strategy: "tokenOverlap" },
337
+ ]);
338
+ const rankScore = rankWindowScore(index, items.length);
339
+
340
+ return {
341
+ item,
342
+ combinedScore: lexicalScore * lexicalWeight + rankScore * rankWeight,
343
+ };
344
+ })
345
+ .sort((left, right) => right.combinedScore - left.combinedScore)
346
+ .map(({ item }) => item);
347
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @lucern/contracts — topic-scope compat shim
3
+ *
4
+ * This file moved to ./v1/topics/v1.ts during EK-16 T1 PR 2.
5
+ * Retained here until the Lucern 1.0.0 barrel-sunset cut (D12).
6
+ * New code should import from "@lucern/contracts/v1/topics" directly.
7
+ */
8
+
9
+ export * from "./v1/topics/v1";
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Topics API Contract v1
3
+ *
4
+ * Defines the public surface for topic operations:
5
+ * create, update, tree traversal, coverage analysis.
6
+ * Migrates from front-end/lucern/contracts/ during EK-16.
7
+ */
8
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @lucern/contracts — Agents v1 namespace
3
+ *
4
+ * Placeholder. Resource contracts land in EK-16 T1 PR 2 (two-taxonomy migration).
5
+ * Do not add symbols here — they must move from the flat contract files
6
+ * during the PR 2 migration.
7
+ */
8
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @lucern/contracts — Beliefs v1 namespace
3
+ *
4
+ * Placeholder. Resource contracts land in EK-16 T1 PR 2 (two-taxonomy migration).
5
+ * Do not add symbols here — they must move from the flat contract files
6
+ * during the PR 2 migration.
7
+ */
8
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @lucern/contracts — Evidence v1 namespace
3
+ *
4
+ * Placeholder. Resource contracts land in EK-16 T1 PR 2 (two-taxonomy migration).
5
+ * Do not add symbols here — they must move from the flat contract files
6
+ * during the PR 2 migration.
7
+ */
8
+ export {};
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @lucern/contracts — Graph v1 namespace
3
+ *
4
+ * Placeholder. Resource contracts land in EK-16 T1 PR 2 (two-taxonomy migration).
5
+ * Do not add symbols here — they must move from the flat contract files
6
+ * during the PR 2 migration.
7
+ */
8
+ export {};
@@ -0,0 +1,276 @@
1
+ /**
2
+ * @lucern/contracts — OntologiesV1 namespace (resource contracts)
3
+ *
4
+ * Ontology Matching Engine — L0 entity type classification and similarity scoring.
5
+ * Provides bigram-based text similarity for matching free text against
6
+ * ontology entity types. Domain-agnostic: works identically for companies,
7
+ * molecules, code modules, or any tenant-defined entity vocabulary.
8
+ *
9
+ * Moved from src/ontology-matching.contract.ts in EK-16 T1 PR 2.
10
+ * Compat shim remains at the old path until the Lucern 1.0.0 cut.
11
+ */
12
+
13
+ import {
14
+ bigramTokenize,
15
+ jaccardSimilarity,
16
+ prepareLexicalQuery,
17
+ scoreLexicalSignals,
18
+ wordOverlapScore,
19
+ wordTokenize,
20
+ } from "../../text-matching.contract";
21
+
22
+ // =============================================================================
23
+ // TYPES
24
+ // =============================================================================
25
+
26
+ /** An entity type definition from a resolved ontology version. */
27
+ export type OntologyEntityType = {
28
+ value: string;
29
+ label: string;
30
+ description?: string;
31
+ subtypes?: Array<{ value: string; label: string; description?: string }>;
32
+ };
33
+
34
+ /** A scored match between input text and an entity type. */
35
+ export type EntityTypeMatch = {
36
+ entityType: string;
37
+ label: string;
38
+ score: number;
39
+ reason: string;
40
+ };
41
+
42
+ /** A candidate entity node that can be matched against a target node. */
43
+ export type EntityMatchCandidate = {
44
+ nodeId: string;
45
+ entityType: string;
46
+ title: string;
47
+ canonicalText: string;
48
+ connectedBeliefCount: number;
49
+ connectedEvidenceCount: number;
50
+ };
51
+
52
+ /** A scored entity match with suggested bridge edge type. */
53
+ export type EntityConnectionMatch = {
54
+ entityNodeId: string;
55
+ entityType: string;
56
+ title: string;
57
+ score: number;
58
+ suggestedEdgeType: string;
59
+ reason: string;
60
+ };
61
+
62
+ // =============================================================================
63
+ // ENTITY TYPE SCORING
64
+ // =============================================================================
65
+
66
+ /** Weights for combining scoring signals. */
67
+ const MATCH_WEIGHTS = {
68
+ tokenOverlap: 0.35,
69
+ bigramSimilarity: 0.25,
70
+ wordOverlap: 0.2,
71
+ descriptionBonus: 0.2,
72
+ } as const;
73
+
74
+ /**
75
+ * Score how well input text matches a single entity type definition.
76
+ * Combines bigram Jaccard similarity, word overlap, and description matching.
77
+ */
78
+ export function scoreEntityTypeMatch(
79
+ inputText: string,
80
+ entityType: OntologyEntityType
81
+ ): EntityTypeMatch {
82
+ const preparedQuery = prepareLexicalQuery(inputText);
83
+ const labelText = `${entityType.label} ${entityType.value}`;
84
+
85
+ const tokenScore = scoreLexicalSignals(preparedQuery, [
86
+ { text: labelText, weight: 1, strategy: "tokenOverlap" },
87
+ ]);
88
+
89
+ // Score against the type label (primary signal)
90
+ const labelBigrams = bigramTokenize(entityType.label);
91
+ const bigramScore = jaccardSimilarity(preparedQuery.bigrams, labelBigrams);
92
+
93
+ // Word overlap with label + value
94
+ const labelWords = wordTokenize(labelText);
95
+ const wordScore = wordOverlapScore(preparedQuery.words, labelWords);
96
+
97
+ // Description matching bonus (if description exists)
98
+ let descScore = 0;
99
+ if (entityType.description) {
100
+ descScore = scoreLexicalSignals(preparedQuery, [
101
+ { text: entityType.description, weight: 1, strategy: "tokenOverlap" },
102
+ ]);
103
+ }
104
+
105
+ // Check subtypes for additional matches
106
+ let subtypeBonus = 0;
107
+ if (entityType.subtypes && entityType.subtypes.length > 0) {
108
+ for (const subtype of entityType.subtypes) {
109
+ const subtypeScore = scoreLexicalSignals(preparedQuery, [
110
+ {
111
+ text: `${subtype.label} ${subtype.value} ${subtype.description || ""}`,
112
+ weight: 1,
113
+ strategy: "tokenOverlap",
114
+ },
115
+ ]);
116
+ subtypeBonus = Math.max(subtypeBonus, subtypeScore * 0.3); // up to 30% subtype bonus
117
+ }
118
+ }
119
+
120
+ const score = Math.min(
121
+ 1.0,
122
+ tokenScore * MATCH_WEIGHTS.tokenOverlap +
123
+ bigramScore * MATCH_WEIGHTS.bigramSimilarity +
124
+ wordScore * MATCH_WEIGHTS.wordOverlap +
125
+ descScore * MATCH_WEIGHTS.descriptionBonus +
126
+ subtypeBonus
127
+ );
128
+
129
+ // Generate human-readable reason
130
+ const reasons: string[] = [];
131
+ if (tokenScore > 0.3) {
132
+ reasons.push(`stem match: ${(tokenScore * 100).toFixed(0)}%`);
133
+ }
134
+ if (bigramScore > 0.3) {
135
+ reasons.push(`text similarity: ${(bigramScore * 100).toFixed(0)}%`);
136
+ }
137
+ if (wordScore > 0.3) {
138
+ reasons.push(`word match: ${(wordScore * 100).toFixed(0)}%`);
139
+ }
140
+ if (descScore > 0.2) {
141
+ reasons.push("description match");
142
+ }
143
+ if (subtypeBonus > 0.05) {
144
+ reasons.push("subtype match");
145
+ }
146
+ const reason = reasons.length > 0 ? reasons.join(", ") : "low similarity";
147
+
148
+ return {
149
+ entityType: entityType.value,
150
+ label: entityType.label,
151
+ score,
152
+ reason,
153
+ };
154
+ }
155
+
156
+ /**
157
+ * Rank all entity types in an ontology against input text.
158
+ * Returns matches sorted by score (descending), filtered to score > minScore.
159
+ */
160
+ export function rankEntityTypeMatches(
161
+ inputText: string,
162
+ entityTypes: OntologyEntityType[],
163
+ options?: { minScore?: number; limit?: number }
164
+ ): EntityTypeMatch[] {
165
+ const minScore = options?.minScore ?? 0.05;
166
+ const limit = options?.limit ?? 10;
167
+
168
+ const matches = entityTypes
169
+ .map((et) => scoreEntityTypeMatch(inputText, et))
170
+ .filter((m) => m.score >= minScore)
171
+ .sort((a, b) => b.score - a.score)
172
+ .slice(0, limit);
173
+
174
+ return matches;
175
+ }
176
+
177
+ // =============================================================================
178
+ // ENTITY DISCOVERY SCORING
179
+ // =============================================================================
180
+
181
+ /**
182
+ * Score how well a node's text matches an entity candidate.
183
+ * Used by discover_entity_connections to suggest missing bridge edges.
184
+ */
185
+ export function scoreEntityConnection(
186
+ nodeText: string,
187
+ candidate: EntityMatchCandidate,
188
+ options?: { connectivityWeight?: number }
189
+ ): EntityConnectionMatch {
190
+ const preparedQuery = prepareLexicalQuery(nodeText);
191
+ const connectivityWeight = options?.connectivityWeight ?? 0.3;
192
+ const textWeight = 1.0 - connectivityWeight;
193
+ const candidateText = `${candidate.title} ${candidate.canonicalText}`;
194
+ const tokenScore = scoreLexicalSignals(preparedQuery, [
195
+ { text: candidateText, weight: 1, strategy: "tokenOverlap" },
196
+ ]);
197
+ const textScore = scoreLexicalSignals(preparedQuery, [
198
+ { text: candidateText, weight: 1, strategy: "bigramJaccard" },
199
+ ]);
200
+ const wordScore = scoreLexicalSignals(preparedQuery, [
201
+ { text: candidateText, weight: 1, strategy: "wordOverlap" },
202
+ ]);
203
+
204
+ // Connectivity signal (normalized externally by caller)
205
+ const maxConnections = Math.max(
206
+ 1,
207
+ candidate.connectedBeliefCount + candidate.connectedEvidenceCount
208
+ );
209
+ const connectivityScore = Math.min(1.0, maxConnections / 10); // soft-cap at 10
210
+
211
+ const combinedTextScore =
212
+ tokenScore * 0.45 + textScore * 0.35 + wordScore * 0.2;
213
+ const score =
214
+ combinedTextScore * textWeight + connectivityScore * connectivityWeight;
215
+
216
+ // Suggest edge type based on entity type
217
+ const suggestedEdgeType = suggestEdgeType(candidate.entityType);
218
+
219
+ const reason =
220
+ tokenScore > 0.3
221
+ ? `stem match: ${(tokenScore * 100).toFixed(0)}%`
222
+ : textScore > 0.2
223
+ ? `name similarity: ${(textScore * 100).toFixed(0)}%`
224
+ : wordScore > 0.2
225
+ ? `keyword match: ${(wordScore * 100).toFixed(0)}%`
226
+ : `connectivity: ${candidate.connectedBeliefCount} beliefs`;
227
+
228
+ return {
229
+ entityNodeId: candidate.nodeId,
230
+ entityType: candidate.entityType,
231
+ title: candidate.title,
232
+ score,
233
+ suggestedEdgeType,
234
+ reason,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Suggest the most appropriate bridge edge type for an entity type.
240
+ */
241
+ function suggestEdgeType(entityType: string): string {
242
+ switch (entityType) {
243
+ case "company":
244
+ case "person":
245
+ case "investor":
246
+ return "contains";
247
+ case "function":
248
+ case "value_chain":
249
+ return "impacts";
250
+ default:
251
+ return "contains";
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Rank entity candidates against a node's text.
257
+ * Returns sorted matches above the minimum score threshold.
258
+ */
259
+ export function rankEntityConnections(
260
+ nodeText: string,
261
+ candidates: EntityMatchCandidate[],
262
+ options?: { minScore?: number; limit?: number; connectivityWeight?: number }
263
+ ): EntityConnectionMatch[] {
264
+ const minScore = options?.minScore ?? 0.05;
265
+ const limit = options?.limit ?? 10;
266
+
267
+ return candidates
268
+ .map((c) =>
269
+ scoreEntityConnection(nodeText, c, {
270
+ connectivityWeight: options?.connectivityWeight,
271
+ })
272
+ )
273
+ .filter((m) => m.score >= minScore)
274
+ .sort((a, b) => b.score - a.score)
275
+ .slice(0, limit);
276
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @lucern/contracts — Questions v1 namespace
3
+ *
4
+ * Placeholder. Resource contracts land in EK-16 T1 PR 2 (two-taxonomy migration).
5
+ * Do not add symbols here — they must move from the flat contract files
6
+ * during the PR 2 migration.
7
+ */
8
+ export {};