@thanh01.pmt/domain-kit 0.5.0 → 0.6.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 (101) hide show
  1. package/README.md +682 -168
  2. package/dist/assembly/index.d.cts +3 -2
  3. package/dist/assembly/index.d.ts +3 -2
  4. package/dist/{chunk-TYXSJUQU.mjs → chunk-4UCXYRM3.mjs} +208 -310
  5. package/dist/chunk-4UCXYRM3.mjs.map +1 -0
  6. package/dist/chunk-7A2SG466.mjs +325 -0
  7. package/dist/chunk-7A2SG466.mjs.map +1 -0
  8. package/dist/{chunk-E4GCCWJO.mjs → chunk-CGNXGVHV.mjs} +37 -4
  9. package/dist/chunk-CGNXGVHV.mjs.map +1 -0
  10. package/dist/{chunk-DRVOV5ZD.mjs → chunk-DSEJLTUO.mjs} +2 -2
  11. package/dist/chunk-DSEJLTUO.mjs.map +1 -0
  12. package/dist/{chunk-VCPIWJ7R.mjs → chunk-H4W3YJLK.mjs} +29 -4
  13. package/dist/chunk-H4W3YJLK.mjs.map +1 -0
  14. package/dist/chunk-IBVXXVGE.mjs +14 -0
  15. package/dist/chunk-IBVXXVGE.mjs.map +1 -0
  16. package/dist/{chunk-G7HLFMD2.mjs → chunk-KHCZYLKM.mjs} +68 -33
  17. package/dist/chunk-KHCZYLKM.mjs.map +1 -0
  18. package/dist/chunk-OYSZQVPY.mjs +10 -0
  19. package/dist/chunk-OYSZQVPY.mjs.map +1 -0
  20. package/dist/{chunk-BL5NBKPH.mjs → chunk-WD3P5V3B.mjs} +19 -8
  21. package/dist/chunk-WD3P5V3B.mjs.map +1 -0
  22. package/dist/{chunk-TWKIVUTQ.mjs → chunk-WKTH3JPA.mjs} +197 -36
  23. package/dist/chunk-WKTH3JPA.mjs.map +1 -0
  24. package/dist/{conceptEscalator-ltRLtPhf.d.cts → conceptEscalator-BmMJD8C5.d.cts} +10 -1
  25. package/dist/{conceptEscalator-YfMKIKCZ.d.ts → conceptEscalator-DMsehscs.d.ts} +10 -1
  26. package/dist/concepts/index.d.cts +4 -3
  27. package/dist/concepts/index.d.ts +4 -3
  28. package/dist/{cpp-CZUPqJt9.d.cts → cpp-vlDl6pCL.d.cts} +4 -4
  29. package/dist/{cpp-CZUPqJt9.d.ts → cpp-vlDl6pCL.d.ts} +4 -4
  30. package/dist/{curriculumFeedSchema-DUFwFyr2.d.ts → curriculumFeedSchema-DK-TUAJZ.d.cts} +164 -9
  31. package/dist/{curriculumFeedSchema-DUFwFyr2.d.cts → curriculumFeedSchema-DK-TUAJZ.d.ts} +164 -9
  32. package/dist/detector/index.cjs +22 -6
  33. package/dist/detector/index.cjs.map +1 -1
  34. package/dist/detector/index.d.cts +2 -2
  35. package/dist/detector/index.d.ts +2 -2
  36. package/dist/detector/index.mjs +2 -1
  37. package/dist/{domainProfileDetector-DHb1T0zB.d.ts → domainProfileDetector-6pjgL-6_.d.ts} +1 -1
  38. package/dist/{domainProfileDetector-bzugdQmS.d.cts → domainProfileDetector-BsADsuRV.d.cts} +1 -1
  39. package/dist/{domainProfileSchema-CwT3Ffsw.d.ts → domainProfileSchema-779FGk14.d.cts} +38 -1
  40. package/dist/{domainProfileSchema-CwT3Ffsw.d.cts → domainProfileSchema-779FGk14.d.ts} +38 -1
  41. package/dist/extractors/index.d.cts +3 -3
  42. package/dist/extractors/index.d.ts +3 -3
  43. package/dist/feed/index.cjs +229 -33
  44. package/dist/feed/index.cjs.map +1 -1
  45. package/dist/feed/index.d.cts +32 -2
  46. package/dist/feed/index.d.ts +32 -2
  47. package/dist/feed/index.mjs +2 -2
  48. package/dist/graph/index.cjs +308 -86
  49. package/dist/graph/index.cjs.map +1 -1
  50. package/dist/graph/index.d.cts +20 -8
  51. package/dist/graph/index.d.ts +20 -8
  52. package/dist/graph/index.mjs +4 -1
  53. package/dist/{hybridGraphPipeline-dT-bio-3.d.cts → hybridGraphPipeline-B4mamLim.d.cts} +6 -2
  54. package/dist/{hybridGraphPipeline-DXQDbBEj.d.ts → hybridGraphPipeline-D2_1GUva.d.ts} +6 -2
  55. package/dist/{hybridGraphSchema-nrUeSwpU.d.ts → hybridGraphSchema-C37qxdTM.d.cts} +16 -3
  56. package/dist/{hybridGraphSchema-nrUeSwpU.d.cts → hybridGraphSchema-C37qxdTM.d.ts} +16 -3
  57. package/dist/index.cjs +3948 -3464
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.d.cts +11 -10
  60. package/dist/index.d.ts +11 -10
  61. package/dist/index.mjs +14 -11
  62. package/dist/{keywordExtractor-BxlGRMHC.d.cts → keywordExtractor-1YAFntsn.d.cts} +1 -1
  63. package/dist/{keywordExtractor-DU5XRN8-.d.ts → keywordExtractor-DWw8z_AF.d.ts} +1 -1
  64. package/dist/parsers/index.cjs.map +1 -1
  65. package/dist/parsers/index.d.cts +1 -1
  66. package/dist/parsers/index.d.ts +1 -1
  67. package/dist/parsers/index.mjs +1 -1
  68. package/dist/pipeline/index.cjs +2436 -1994
  69. package/dist/pipeline/index.cjs.map +1 -1
  70. package/dist/pipeline/index.d.cts +19 -10
  71. package/dist/pipeline/index.d.ts +19 -10
  72. package/dist/pipeline/index.mjs +11 -8
  73. package/dist/schemas/index.cjs +63 -2
  74. package/dist/schemas/index.cjs.map +1 -1
  75. package/dist/schemas/index.d.cts +3 -3
  76. package/dist/schemas/index.d.ts +3 -3
  77. package/dist/schemas/index.mjs +2 -2
  78. package/dist/utils/llmClient.cjs +344 -0
  79. package/dist/utils/llmClient.cjs.map +1 -0
  80. package/dist/utils/llmClient.d.cts +118 -0
  81. package/dist/utils/llmClient.d.ts +118 -0
  82. package/dist/utils/llmClient.mjs +4 -0
  83. package/dist/utils/llmClient.mjs.map +1 -0
  84. package/dist/utils/llmTransportResult.cjs +16 -0
  85. package/dist/utils/llmTransportResult.cjs.map +1 -0
  86. package/dist/utils/llmTransportResult.d.cts +36 -0
  87. package/dist/utils/llmTransportResult.d.ts +36 -0
  88. package/dist/utils/llmTransportResult.mjs +3 -0
  89. package/dist/utils/llmTransportResult.mjs.map +1 -0
  90. package/package.json +1 -1
  91. package/dist/chunk-BL5NBKPH.mjs.map +0 -1
  92. package/dist/chunk-DRVOV5ZD.mjs.map +0 -1
  93. package/dist/chunk-E4GCCWJO.mjs.map +0 -1
  94. package/dist/chunk-G7HLFMD2.mjs.map +0 -1
  95. package/dist/chunk-TWKIVUTQ.mjs.map +0 -1
  96. package/dist/chunk-TYXSJUQU.mjs.map +0 -1
  97. package/dist/chunk-VCPIWJ7R.mjs.map +0 -1
  98. package/dist/curriculumFeedEmitter-T7NUBLAM.mjs +0 -4
  99. package/dist/curriculumFeedEmitter-T7NUBLAM.mjs.map +0 -1
  100. package/dist/llmClient-ysPhLjcH.d.cts +0 -16
  101. package/dist/llmClient-ysPhLjcH.d.ts +0 -16
@@ -1,265 +1,15 @@
1
+ import { createLlmClient, llmChatJson, parseJsonFromLlm } from './chunk-7A2SG466.mjs';
2
+ import { transportResultFrom } from './chunk-IBVXXVGE.mjs';
1
3
  import { parseSyllabus } from './chunk-T4Y2DE2W.mjs';
4
+ import { stripDiacritics } from './chunk-OYSZQVPY.mjs';
2
5
  import { readFileSync, mkdirSync, writeFileSync } from 'fs';
3
6
  import { tmpdir } from 'os';
4
7
  import { join } from 'path';
5
8
 
6
- // src/utils/llmClient.ts
7
- var LlmClientError = class extends Error {
8
- constructor(message, status, cause) {
9
- super(message);
10
- this.status = status;
11
- this.cause = cause;
12
- this.name = "LlmClientError";
13
- }
14
- };
15
- function resolveProviderChain(config) {
16
- const chain = [];
17
- const push = (label, baseUrl, apiKey, model) => {
18
- if (apiKey && baseUrl && model && !chain.some((c) => c.baseUrl === baseUrl && c.model === model)) {
19
- chain.push({ label, baseUrl, apiKey, model });
20
- }
21
- };
22
- if (config?.apiKey) {
23
- push("config", config.baseUrl || "https://api.openai.com/v1", config.apiKey, config.model || "gpt-4o-mini");
24
- }
25
- push("legacy", process.env.LLM_BASE_URL || process.env.OPENAI_BASE_URL || "", process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || "", process.env.LLM_MODEL || process.env.LLM_TIER_FAST || "");
26
- push("dashscope", process.env.DASHSCOPE_BASE_URL || process.env.ALIBABA_BASE_URL || "", process.env.DASHSCOPE_API_KEY || process.env.ALIBABA_API_KEY || "", process.env.DASHSCOPE_MODEL || process.env.DEFAULT_AI_MODEL || "");
27
- push("nvidia", "https://integrate.api.nvidia.com/v1", process.env.NVIDIA_API_KEY || "", process.env.NVIDIA_MODEL || "nvidia/nemotron-3-ultra-550b-a55b:free");
28
- push("openrouter", "https://openrouter.ai/api/v1", process.env.OPENROUTER_API_KEY || "", process.env.OPENROUTER_MODEL || "nvidia/nemotron-3-ultra-550b-a55b:free");
29
- if (process.env.OPENROUTER_MODEL) {
30
- const or = chain.find((c) => c.label === "openrouter");
31
- if (or) {
32
- const rest = chain.filter((c) => c !== or);
33
- return [or, ...rest];
34
- }
35
- }
36
- return chain;
37
- }
38
- var MIN_REQUEST_INTERVAL_MS = 2500;
39
- var lastRequestAt = 0;
40
- async function pacedDelay() {
41
- const wait = lastRequestAt + MIN_REQUEST_INTERVAL_MS - Date.now();
42
- if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
43
- lastRequestAt = Date.now();
44
- }
45
- function createLlmClient(config) {
46
- const chain = resolveProviderChain(config);
47
- const primary = chain[0];
48
- if (!primary) {
49
- throw new LlmClientError(
50
- "No LLM provider configured. Set NVIDIA_API_KEY (primary) and/or OPENROUTER_API_KEY (fallback), or pass llmConfig."
51
- );
52
- }
53
- primary.apiKey;
54
- primary.baseUrl;
55
- const model = primary.model;
56
- async function chat(messages, options) {
57
- const temperature = options?.temperature ?? config?.temperature ?? 0.1;
58
- const maxTokens = options?.maxTokens ?? config?.maxTokens ?? parseInt(process.env.LLM_MAX_TOKENS || "65536", 10);
59
- const attempts = [];
60
- let lastError = null;
61
- const TRANSIENT = /* @__PURE__ */ new Set([408, 429, 500, 502, 503, 504]);
62
- const REQUEST_TIMEOUT_MS = Number(process.env.LLM_REQUEST_TIMEOUT_MS || 3e5);
63
- const MAX_CHAIN_ROUNDS = 3;
64
- for (let round = 1; round <= MAX_CHAIN_ROUNDS; round++) {
65
- if (round > 1) {
66
- attempts.push("round " + (round - 1) + " failed \u2014 backing off 20s before rewalking the chain");
67
- await new Promise((resolve) => setTimeout(resolve, 2e4));
68
- }
69
- for (const provider of chain) {
70
- try {
71
- const headers = {
72
- "Content-Type": "application/json",
73
- "Authorization": `Bearer ${provider.apiKey}`
74
- };
75
- const payload = {
76
- model: provider.model,
77
- messages,
78
- temperature,
79
- max_tokens: maxTokens
80
- };
81
- await pacedDelay();
82
- const doFetch = () => fetch(`${provider.baseUrl}/chat/completions`, {
83
- method: "POST",
84
- headers: {
85
- "Content-Type": "application/json",
86
- "Authorization": `Bearer ${provider.apiKey}`
87
- },
88
- body: JSON.stringify({
89
- model: provider.model,
90
- messages,
91
- temperature,
92
- max_tokens: maxTokens
93
- }),
94
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
95
- });
96
- let response;
97
- try {
98
- response = await doFetch();
99
- } catch (netErr) {
100
- attempts.push(`${provider.label}: network error (${netErr instanceof Error ? netErr.message : String(netErr)}) \u2014 retrying once`);
101
- await new Promise((r) => setTimeout(r, 5e3));
102
- lastRequestAt = Date.now();
103
- response = await doFetch();
104
- }
105
- if (!response.ok) {
106
- const body = await response.text().catch(() => "");
107
- const err = new LlmClientError(
108
- `[${provider.label}] LLM API error: ${response.status} ${response.statusText} \u2014 ${body.slice(0, 200)}`,
109
- response.status
110
- );
111
- if (response.status === 429) {
112
- const retryAfterRaw = response.headers.get("retry-after");
113
- const retryAfterMs = Math.min(
114
- 6e4,
115
- Math.max(15e3, (Number.isFinite(Number(retryAfterRaw)) ? Number(retryAfterRaw) : 20) * 1e3)
116
- );
117
- attempts.push(`${provider.label}: 429 rate-limited \u2014 backing off ${Math.round(retryAfterMs / 1e3)}s`);
118
- await new Promise((resolve) => setTimeout(resolve, retryAfterMs));
119
- lastRequestAt = Date.now();
120
- const retry = await fetch(`${provider.baseUrl}/chat/completions`, {
121
- method: "POST",
122
- headers,
123
- body: JSON.stringify(payload),
124
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
125
- });
126
- if (!retry.ok) throw err;
127
- const retryData = await retry.json();
128
- const retryContent2 = retryData.choices?.[0]?.message?.content || "";
129
- if (!retryContent2.trim()) throw err;
130
- return {
131
- content: retryContent2,
132
- model: retryData.model || provider.model,
133
- provider: provider.label,
134
- attempts: attempts.slice(),
135
- usage: retryData.usage,
136
- finishReason: retryData.choices?.[0]?.finish_reason
137
- };
138
- }
139
- if (TRANSIENT.has(response.status)) {
140
- await new Promise((r) => setTimeout(r, 3e3));
141
- const retry = await fetch(`${provider.baseUrl}/chat/completions`, {
142
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
143
- method: "POST",
144
- headers: {
145
- "Content-Type": "application/json",
146
- "Authorization": `Bearer ${provider.apiKey}`
147
- },
148
- body: JSON.stringify({
149
- model: provider.model,
150
- messages,
151
- temperature,
152
- max_tokens: maxTokens
153
- })
154
- });
155
- if (!retry.ok) throw err;
156
- const retryData = await retry.json();
157
- const retryContent = retryData.choices?.[0]?.message?.content || "";
158
- return {
159
- content: retryContent,
160
- model: retryData.model || provider.model,
161
- provider: provider.label,
162
- attempts: attempts.slice(),
163
- usage: retryData.usage,
164
- finishReason: retryData.choices?.[0]?.finish_reason
165
- };
166
- }
167
- throw err;
168
- }
169
- const data = await response.json();
170
- const wrappedError = data.error;
171
- if (wrappedError) {
172
- throw new LlmClientError("[" + provider.label + "] upstream error " + (wrappedError.code ?? "") + ": " + (wrappedError.message ?? "unknown"));
173
- }
174
- const content = data.choices?.[0]?.message?.content || "";
175
- if (!content.trim()) {
176
- throw new LlmClientError("[" + provider.label + "] empty completion returned");
177
- }
178
- return {
179
- content,
180
- model: data.model || provider.model,
181
- provider: provider.label,
182
- attempts: attempts.slice(),
183
- usage: data.usage,
184
- finishReason: data.choices?.[0]?.finish_reason
185
- };
186
- } catch (err) {
187
- const msg = err instanceof Error ? err.message : String(err);
188
- attempts.push(`${provider.label}: ${msg}`);
189
- lastError = err instanceof LlmClientError ? err : new LlmClientError(`[${provider.label}] ${msg}`);
190
- }
191
- }
192
- if (round < MAX_CHAIN_ROUNDS) continue;
193
- throw lastError ?? new LlmClientError("All LLM providers failed (empty chain).");
194
- }
195
- throw lastError ?? new LlmClientError("All LLM providers failed after " + MAX_CHAIN_ROUNDS + " rounds.");
196
- }
197
- return { chat, model };
198
- }
199
- function parseJsonFromLlm(text) {
200
- let cleaned = text.trim();
201
- if (cleaned.startsWith("```json")) {
202
- cleaned = cleaned.slice(7);
203
- } else if (cleaned.startsWith("```")) {
204
- cleaned = cleaned.slice(3);
205
- }
206
- if (cleaned.endsWith("```")) {
207
- cleaned = cleaned.slice(0, -3);
208
- }
209
- cleaned = cleaned.trim();
210
- try {
211
- return JSON.parse(cleaned);
212
- } catch {
213
- const start = cleaned.indexOf("{");
214
- if (start >= 0) {
215
- let depth = 0;
216
- let inString = false;
217
- let escape = false;
218
- for (let i = start; i < cleaned.length; i++) {
219
- const ch = cleaned[i];
220
- if (escape) {
221
- escape = false;
222
- continue;
223
- }
224
- if (ch === "\\") {
225
- escape = true;
226
- continue;
227
- }
228
- if (ch === '"') {
229
- inString = !inString;
230
- continue;
231
- }
232
- if (inString) continue;
233
- if (ch === "{") depth++;
234
- if (ch === "}") {
235
- depth--;
236
- if (depth === 0) {
237
- try {
238
- return JSON.parse(cleaned.slice(start, i + 1));
239
- } catch {
240
- }
241
- }
242
- }
243
- }
244
- }
245
- throw new LlmClientError(`Failed to parse JSON from LLM response: ${cleaned.slice(0, 200)}`);
246
- }
247
- }
248
- async function llmChatJson(client, systemPrompt, userPrompt, options) {
249
- const result = await client.chat(
250
- [
251
- { role: "system", content: systemPrompt },
252
- { role: "user", content: userPrompt }
253
- ],
254
- options
255
- );
256
- return parseJsonFromLlm(result.content);
257
- }
258
-
259
9
  // src/graph/scaffoldExtractor.ts
260
10
  async function extractScaffold(options) {
261
- const { goal, techStack, fileList, llmConfig } = options;
262
- const client = createLlmClient(llmConfig);
11
+ const { goal, techStack, fileList, llmConfig, sharedClient } = options;
12
+ const client = sharedClient ?? createLlmClient(llmConfig);
263
13
  const fileListStr = fileList.length > 0 ? fileList.join("\n") : "(empty)";
264
14
  const systemPrompt = "You are a programming pedagogy expert. For ONE concrete project, extract the 'FOUNDATION & SETUP' feature (id F0) \u2014 the COMPLETE set of 'MUST-KNOW' steps the learner needs before studying the first feature. Break it into concrete ACTION steps (5-8 steps), each with completion_level='base'. Include:\n1. Tools required for this tech stack (IDE, simulator/emulator, terminal, git, package manager) + basic operations (open project, build, run, debug, commit).\n2. Create the initial project: from a template OR clone/open the base-project (the provided repo).\n3. MINIMAL programming knowledge of the language (variables, types, functions, if/for, view declaration) + build one small demo (e.g. a single-screen Hello World app) BEFORE touching the real project.\n4. Repo map of the actual project: folder structure, entry point, how to build/run, main architecture (MVVM/Flux...), important packages/modules.\n5. Development loop (build->run->see result->debug), how to read/fix basic compile errors, minimal git workflow (clone/branch/commit/push).\nkeywords[]: real tool/language terms (e.g. 'Xcode', 'Simulator', 'Git', 'Swift', '@main', 'MVVM'). files[]: leave [] or use real paths if the step touches specific files. description: ONE concise sentence, MAX 140 characters. intent: WHY this step matters. outcome: {user_visible: string, technical: string}. acceptance[]: 2 verifiable criteria. effort: {estimated_minutes: number, complexity: low|medium|high}. NEVER invent. ALL text must be in ENGLISH (technical terms stay as-is). Return JSON containing only feature F0.";
265
15
  const userPrompt = `App goal: ${goal}
@@ -344,8 +94,8 @@ ${content}`);
344
94
  return blocks.join("\n\n");
345
95
  }
346
96
  async function extractProjectOverview(options) {
347
- const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig } = options;
348
- const client = createLlmClient(llmConfig);
97
+ const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig, sharedClient } = options;
98
+ const client = sharedClient ?? createLlmClient(llmConfig);
349
99
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
350
100
  const allFilesStr = buildSourceText(fileContentsMap);
351
101
  let keywordsAnchor = "";
@@ -354,7 +104,7 @@ async function extractProjectOverview(options) {
354
104
  (kw) => `- ${kw.keyword} (from ${kw.source})`
355
105
  );
356
106
  if (kwItems.length > 0) {
357
- keywordsAnchor = "\nAST Domain Keywords & Patterns (from static analysis \u2014 use as context anchor):\n" + kwItems.join("\n") + "\n";
107
+ keywordsAnchor = "\nDomain Keywords & Patterns (from regex-based static scan \u2014 use as context anchor):\n" + kwItems.join("\n") + "\n";
358
108
  }
359
109
  }
360
110
  const systemPrompt = "You are a software architect. The app the user will build IS EXACTLY the provided files (not an SDK/lib). Analyze them and return a PROJECT OVERVIEW \u2014 meta only, DO NOT list steps (steps are handled in a later step). NEVER invent files/APIs. NEVER reference internal SDK/lib unless it appears in the given files.\nEND-USER VIEW (M1/Design Thinking): product.purpose must describe the USER PROBLEM (what the user does with the app, which need it solves). NEVER describe repo/artifacts.\nDEVELOPMENT STAGES: Include 4-6 development stages in product.development_stages. Each stage must have: stage (name), need (list of feature needs), learn (concepts learned), product_state (what works), temporary_approach, and validation.\nMISSING GAPS: Identify 0-5 missing_gaps (features/functionality that should exist but don't).\nTECH DEBT: Identify 0-5 tech_debt issues (code quality problems).\nALL text must be in ENGLISH.";
@@ -414,8 +164,8 @@ ${content}`);
414
164
  return blocks.join("\n\n");
415
165
  }
416
166
  async function extractFeatureSteps(options) {
417
- const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig } = options;
418
- const client = createLlmClient(llmConfig);
167
+ const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig, sharedClient } = options;
168
+ const client = sharedClient ?? createLlmClient(llmConfig);
419
169
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
420
170
  const allFilesStr = buildSourceText2(fileContentsMap);
421
171
  const fid = feature.id || "F_UNK";
@@ -482,8 +232,8 @@ Split this feature into 2-6 action steps. Return JSON:
482
232
  concept_codes: []
483
233
  }));
484
234
  }
485
- async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig) {
486
- const client = createLlmClient(llmConfig);
235
+ async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig, sharedClient) {
236
+ const client = sharedClient ?? createLlmClient(llmConfig);
487
237
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
488
238
  const sourceText = buildSourceText2(fileContentsMap);
489
239
  const featuresListing = featuresMeta.map(
@@ -750,7 +500,7 @@ function inferDepthFromContext(keyword, fileContentsMap) {
750
500
  return { depth: "sio", rationale: `Default: treating '${keyword}' as SIO (implementation-level)` };
751
501
  }
752
502
  async function escalateAndMapConcepts(options) {
753
- const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig } = options;
503
+ const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig, sharedClient } = options;
754
504
  const features = projectGraph.features || [];
755
505
  const allTerms = /* @__PURE__ */ new Set();
756
506
  for (const feature of features) {
@@ -766,7 +516,7 @@ async function escalateAndMapConcepts(options) {
766
516
  llmResults = conceptMapOverride;
767
517
  } else {
768
518
  try {
769
- const client = createLlmClient(llmConfig);
519
+ const client = sharedClient ?? createLlmClient(llmConfig);
770
520
  const conceptBank = (availableConcepts || []).sort().join(", ") || "(empty)";
771
521
  const termEntries = termList.map((term) => {
772
522
  const snippets = extractTermSnippets(term, fileContentsMap);
@@ -819,13 +569,16 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
819
569
  const cName = info.concept_name || kw;
820
570
  let depth = "sio";
821
571
  let depthRationale = "Default: SIO";
572
+ let depthSource = "override";
822
573
  if (info.depth && ["ulo", "cio", "sio"].includes(info.depth)) {
823
574
  depth = info.depth;
824
575
  depthRationale = `LLM classified as ${depth}`;
576
+ depthSource = "llm";
825
577
  } else {
826
578
  const inferred = inferDepthFromContext(kw, fileContentsMap);
827
579
  depth = inferred.depth;
828
580
  depthRationale = inferred.rationale;
581
+ depthSource = "heuristic";
829
582
  }
830
583
  const kwKey = `${cCode}::${kw}`;
831
584
  if (seen.has(kwKey)) continue;
@@ -843,6 +596,7 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
843
596
  keyword: kw,
844
597
  depth,
845
598
  depth_rationale: depthRationale,
599
+ depth_source: depthSource,
846
600
  evidence_files: evidenceFiles
847
601
  });
848
602
  }
@@ -1150,7 +904,110 @@ function techSignalHits(text) {
1150
904
  }
1151
905
  return hits;
1152
906
  }
1153
- function auditDepthLayers(concepts) {
907
+ var COMPLETABLE_VERBS = [
908
+ "build",
909
+ "create",
910
+ "implement",
911
+ "write",
912
+ "configure",
913
+ "add",
914
+ "use",
915
+ "apply",
916
+ "display",
917
+ "store",
918
+ "load",
919
+ "save",
920
+ "handle",
921
+ "parse",
922
+ "validate",
923
+ "test",
924
+ "register",
925
+ "compute",
926
+ "generate",
927
+ "print",
928
+ "format",
929
+ "convert",
930
+ "extend",
931
+ "define",
932
+ "execute",
933
+ "run",
934
+ "read",
935
+ "fetch",
936
+ "wrap",
937
+ "log",
938
+ "call",
939
+ "raise",
940
+ "install",
941
+ "import",
942
+ "export",
943
+ "declare",
944
+ "embed",
945
+ "model",
946
+ "represent",
947
+ "override",
948
+ "customize",
949
+ "enforce",
950
+ "mock",
951
+ "assert",
952
+ "query",
953
+ "index",
954
+ "cache",
955
+ "stream",
956
+ "split",
957
+ "join",
958
+ "strip",
959
+ "encode",
960
+ "decode",
961
+ "traverse",
962
+ "measure",
963
+ "evaluate",
964
+ "benchmark",
965
+ "diagnose",
966
+ "demonstrate",
967
+ "verify",
968
+ "organize",
969
+ "structure",
970
+ "choose",
971
+ "select",
972
+ "compare",
973
+ "distinguish",
974
+ "attach",
975
+ "annotate",
976
+ "expose",
977
+ "accept",
978
+ "manage",
979
+ "group",
980
+ "sort",
981
+ "filter",
982
+ "map",
983
+ "reduce",
984
+ "catch",
985
+ "throw",
986
+ "migrate",
987
+ "inspect",
988
+ "trace",
989
+ "parameterize",
990
+ "construct",
991
+ "design",
992
+ "capture",
993
+ // Completable implementation verbs present in production SIO output but
994
+ // missing from the original registry (2026-10-07 smoke test, swift kg):
995
+ "update",
996
+ "inject",
997
+ "compose",
998
+ "pass",
999
+ "modify",
1000
+ "connect",
1001
+ "remove",
1002
+ "transform",
1003
+ "trigger",
1004
+ "refresh",
1005
+ "persist",
1006
+ "observe",
1007
+ "arrange",
1008
+ "derive"
1009
+ ];
1010
+ function auditDepthLayers(concepts, transport) {
1154
1011
  return concepts.map((c) => {
1155
1012
  const ulo = (c.ulo ?? "").trim();
1156
1013
  const cio = (c.cio ?? "").trim();
@@ -1171,7 +1028,7 @@ function auditDepthLayers(concepts) {
1171
1028
  });
1172
1029
  const conceptKeywords = (c.keywords ?? []).map((k) => k.toLowerCase()).filter(Boolean);
1173
1030
  const sioOffConceptItems = conceptKeywords.length > 0 ? sioItems.filter((s) => !boundToConcept(s)) : [];
1174
- const COMPLETABLE = /\b(build|create|implement|write|configure|add|use|apply|display|store|load|save|handle|parse|validate|test|register|compute|generate|print|format|convert|extend|define|execute|run|read|fetch|wrap|log|call|raise|install|import|export|declare|embed|model|represent|override|customize|enforce|mock|assert|query|index|cache|stream|split|join|strip|encode|decode|traverse|measure|evaluate|benchmark|diagnose|demonstrate|verify|organize|structure|choose|select|compare|distinguish|attach|annotate|expose|accept|manage|group|sort|filter|map|reduce|catch|throw|migrate|inspect|trace|parameterize|construct|design|capture)\b/i;
1031
+ const COMPLETABLE = new RegExp("\\b(?:" + COMPLETABLE_VERBS.join("|") + ")\\b", "i");
1175
1032
  const sioNotCompletable = sioItems.filter((s) => !COMPLETABLE.test(s) && !/`[^`]+`/.test(s));
1176
1033
  const density = (text) => {
1177
1034
  if (!text) return 0;
@@ -1211,7 +1068,8 @@ function auditDepthLayers(concepts) {
1211
1068
  clauseOk,
1212
1069
  codesOk,
1213
1070
  codeIssues,
1214
- passed
1071
+ passed,
1072
+ transport
1215
1073
  };
1216
1074
  });
1217
1075
  }
@@ -1246,14 +1104,14 @@ function excerpt(text, max) {
1246
1104
  const t = (text || "").trim();
1247
1105
  return t.length > max ? t.slice(0, max) + "..." : t;
1248
1106
  }
1249
- async function chatJson(client, system, user) {
1107
+ async function chatJson(client, system, user, transport) {
1250
1108
  const r = await client.chat(
1251
1109
  [{ role: "system", content: system }, { role: "user", content: user }],
1252
1110
  { temperature: 0.1, maxTokens: parseInt(process.env.KG_MAX_TOKENS || "65536", 10) }
1253
1111
  );
1254
1112
  return parseJsonFromLlm(r.content);
1255
1113
  }
1256
- var LO_BATCH_SIZE = 6;
1114
+ var LO_BATCH_SIZE = 10;
1257
1115
  function chunkConcepts(items, size) {
1258
1116
  const out = [];
1259
1117
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -1292,7 +1150,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1292
1150
  for (const batch of chunkConcepts(concepts, LO_BATCH_SIZE)) {
1293
1151
  const batchList = batch.map((c) => "- " + c.id + " (" + c.name + ")" + (c.description ? ": " + excerpt(c.description, 160) : "")).join("\n");
1294
1152
  const batchUser = "Concepts:\n" + batchList + '\n\nReturn JSON:\n{"ulos":[{"concept_id":"C1","ulo":"..."}]}';
1295
- const uloRes = await chatJson(client, uloSystem, batchUser);
1153
+ const uloRes = await chatJson(client, uloSystem, batchUser, transportResultFrom("9router", client.model, { framingStripped: true }));
1296
1154
  for (const u of uloRes.ulos ?? []) {
1297
1155
  if (u.concept_id && u.ulo) uloByConcept[u.concept_id] = standardStatement(u.ulo);
1298
1156
  }
@@ -1331,7 +1189,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1331
1189
  new Set(batch.map((c) => c.id));
1332
1190
  const batchUloContext = batch.map((c) => "- " + c.id + " (" + c.name + "): " + (uloByConcept[c.id] || "(missing)")).join("\n");
1333
1191
  const batchCioUser = "Approved ULOs:\n" + batchUloContext + '\n\nReturn JSON:\n{"cios":[{"concept_id":"C1","cio":"..."}]}';
1334
- const cioRes = await chatJson(client, cioSystem, batchCioUser);
1192
+ const cioRes = await chatJson(client, cioSystem, batchCioUser, transportResultFrom("9router", client.model, { framingStripped: true }));
1335
1193
  for (const x of cioRes.cios ?? []) {
1336
1194
  if (x.concept_id && x.cio) cioByConcept[x.concept_id] = standardStatement(x.cio);
1337
1195
  }
@@ -1356,15 +1214,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1356
1214
  " context where it is used. Application/Analysis/Creation skills are assessed",
1357
1215
  " INDIRECTLY through completing these tasks \u2014 there is no fixed Bloom ceiling.",
1358
1216
  " MACHINE CHECK: every item MUST contain at least one ACTION VERB from this whitelist",
1359
- " (build, create, implement, write, configure, add, use, apply, display, store, load,",
1360
- " save, handle, parse, validate, test, register, compute, generate, print, format,",
1361
- " convert, extend, define, execute, run, read, fetch, wrap, log, call, raise, install,",
1362
- " import, export, declare, embed, model, represent, override, customize, enforce, mock,",
1363
- " assert, query, index, cache, stream, split, join, strip, encode, decode, traverse,",
1364
- " measure, evaluate, benchmark, diagnose, demonstrate, verify, organize, structure,",
1365
- " choose, select, compare, distinguish, attach, annotate, expose, accept, manage, group,",
1366
- " sort, filter, map, reduce, catch, throw, migrate, inspect, trace, parameterize,",
1367
- " construct, design, capture) \u2014 OR wrap the concrete construct in backticks.",
1217
+ " (" + COMPLETABLE_VERBS.join(", ") + ") \u2014 OR wrap the concrete construct in backticks.",
1368
1218
  " Items with abstract verbs (understand, learn, know, grasp, appreciate, explore) and",
1369
1219
  " no backtick construct FAIL the audit. Prefer starting the task phrase with the verb.",
1370
1220
  "3. 2-3 items per concept, each referencing a DIFFERENT concrete construct.",
@@ -1392,7 +1242,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1392
1242
  "Return JSON:",
1393
1243
  '{"sios":[{"concept_id":"C1","sio":["...","..."]}]}'
1394
1244
  ].join("\n");
1395
- const sioRes = await chatJson(client, sioSystem, batchSioUser);
1245
+ const sioRes = await chatJson(client, sioSystem, batchSioUser, transportResultFrom("9router", client.model, { framingStripped: true }));
1396
1246
  for (const x of sioRes.sios ?? []) {
1397
1247
  if (x.concept_id && Array.isArray(x.sio)) sioByConcept[x.concept_id] = x.sio.map((item) => standardStatement(String(item)));
1398
1248
  }
@@ -1445,7 +1295,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1445
1295
  "- CIO: HOW mechanism, technology-agnostic, more concrete than ULO, Marr 2-Language neutral.",
1446
1296
  "- SIO: keyword-bound completable implementation tasks naming real constructs of " + techStack + ".",
1447
1297
  "- BINDING RULE: every SIO item must contain at least one of the concept's listed keywords VERBATIM (copy the exact keyword text into the sentence).",
1448
- "- COMPLETABILITY RULE: every SIO item must contain an actionable verb (build, create, implement, write, configure, parse, validate, handle, store, load, run, read, fetch, display, generate, format, convert, test, debug, trace, measure, design, structure, select, filter, sort, map, reduce, catch, throw, migrate, deploy, query, index, cache, stream, inspect) or wrap a concrete construct in backticks \u2014 abstract verbs (understand, learn, know, grasp, explore, appreciate) always fail.",
1298
+ "- COMPLETABILITY RULE: every SIO item must contain an actionable verb (" + ["build", "create", "update", "inject", "compose", "pass", "modify", "use"].join(", ") + " ...full registry in the SIO authoring step) or wrap a concrete construct in backticks \u2014 abstract verbs (understand, learn, know, grasp, explore, appreciate) always fail.",
1449
1299
  "",
1450
1300
  JSON_ONLY
1451
1301
  ].join("\n");
@@ -1493,7 +1343,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1493
1343
  "Depth-layer contract audit failed after repair for: " + detail + " \u2014 regenerate or fix the prompt contract (fail-closed, no silent fallback)"
1494
1344
  );
1495
1345
  }
1496
- return { uloByConcept, cioByConcept, sioByConcept, audits, warnings };
1346
+ return { uloByConcept, cioByConcept, sioByConcept, audits, warnings, lastTransport: audits[0]?.transport };
1497
1347
  }
1498
1348
  function cell(v) {
1499
1349
  return (v || "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -1545,7 +1395,7 @@ function loadKnowledgeTreeCatalog(tsvPath) {
1545
1395
  }
1546
1396
  function tokens(s) {
1547
1397
  return new Set(
1548
- s.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
1398
+ stripDiacritics(s).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
1549
1399
  );
1550
1400
  }
1551
1401
  function filterBySelection(catalog, selectedCategories, selectedTopics, analysisConcepts, maxConcepts = 120) {
@@ -1636,7 +1486,7 @@ async function standardizeConcepts(opts) {
1636
1486
  " Adopt the tree's terminology. Only propose new when the tree genuinely has NO concept teaching that knowledge (broader AND narrower both fail).",
1637
1487
  ' 2. "proposed_new" - no tree concept covers it at the same granularity. Propose an UPPER_SNAKE_CASE code (descriptive, stable, max 5 words) and a one-sentence definition.',
1638
1488
  " If between 1 and 3 tree concepts are CLOSE but not exact, list their codes in possible_tree_matches for human review.",
1639
- `For a "standardized" decision ALSO return "adopted_keywords": the subset of that tree concept's keywords (from the shortlist) that make sense for the course TECH STACK. Drop keywords that name tooling from other ecosystems (e.g. JS test runners for a Python course) \u2014 keep conceptual keywords even when spelled generically. Maximum 8, ordered most-relevant first.`,
1489
+ `For a "standardized" decision ALSO return "adopted_keywords": copy VERBATIM from that tree concept's keyword list in the shortlist (exact tokens, do not re-spell, singularize or translate). Pick the subset that makes sense for the course TECH STACK; if none fit, return an EMPTY array \u2014 never invent a keyword. Maximum 8, ordered most-relevant first.`,
1640
1490
  "Rules:",
1641
1491
  "- Judge by MEANING (definition + keywords), not by string similarity.",
1642
1492
  '- COVERAGE RULE: a "standardized" match must cover ALL primary dimensions of the input concept \u2014 every distinct aspect named in its title or description. A tree concept covering only ONE of several aspects (e.g. "interpreting error messages" for a concept about error handling AND user experience) is a PARTIAL match: decide "proposed_new" and list that tree code in possible_tree_matches.',
@@ -1676,10 +1526,25 @@ async function standardizeConcepts(opts) {
1676
1526
  const tc = catalog.concepts.find((x) => x.code === d.tree_code);
1677
1527
  if (!tc) throw new Error("Concept " + c.id + " standardized to unknown tree code " + d.tree_code + " \u2014 fail-closed");
1678
1528
  const treeKw = new Set(tc.keywords.map((k) => k.toLowerCase()));
1679
- const adopted = (d.adopted_keywords || []).map((k) => String(k)).filter((k) => {
1680
- if (treeKw.has(k.toLowerCase())) return true;
1681
- throw new Error("Concept " + c.id + ' adopted keyword "' + k + '" not in tree concept ' + tc.code + " \u2014 fail-closed");
1682
- });
1529
+ const rawAdopted = (d.adopted_keywords || []).map((k) => String(k)).filter(Boolean);
1530
+ let adopted;
1531
+ if (tc.keywords.length === 0) {
1532
+ adopted = rawAdopted.filter((k) => (c.keywords ?? []).some((ck) => ck.toLowerCase() === k.toLowerCase()));
1533
+ if (adopted.length === 0) adopted = (c.keywords ?? []).slice(0, 8);
1534
+ warnings.push("Concept " + c.id + ": tree concept " + tc.code + " has no keyword registry \u2014 adopted " + adopted.length + " keyword(s) from the analysis concept");
1535
+ } else {
1536
+ const norm = (k) => k.toLowerCase().replace(/ies$/, "y").replace(/s$/, "");
1537
+ const treeKwNorm = new Set([...treeKw].map(norm));
1538
+ const dropped = [];
1539
+ adopted = rawAdopted.filter((k) => {
1540
+ if (treeKw.has(k.toLowerCase()) || treeKwNorm.has(norm(k))) return true;
1541
+ dropped.push(k);
1542
+ return false;
1543
+ });
1544
+ if (dropped.length > 0) {
1545
+ warnings.push("Concept " + c.id + ": dropped " + dropped.length + " adopted keyword(s) not in tree concept " + tc.code + " registry: " + dropped.join(", "));
1546
+ }
1547
+ }
1683
1548
  if (tc.keywords.length > 0 && adopted.length === 0) {
1684
1549
  throw new Error("Concept " + c.id + " standardized to " + tc.code + " without adopted_keywords \u2014 fail-closed");
1685
1550
  }
@@ -1719,6 +1584,15 @@ async function standardizeConcepts(opts) {
1719
1584
  }
1720
1585
 
1721
1586
  // src/graph/knowledgeGraphPipeline.ts
1587
+ var MIN_CALIBRATION_MINUTES = 15;
1588
+ var MAX_CALIBRATION_MINUTES = 60;
1589
+ var DEFAULT_CONCEPT_MINUTES = 30;
1590
+ var MIN_CALIBRATION_DIVERGENCE = 0.5;
1591
+ function calibrateConceptMinutes(sioCount, problemTypeCount, prereqCount, hasHighBloom) {
1592
+ const ratio = (value, cap) => Math.min(1, Math.max(0, value) / cap);
1593
+ const score = 0.3 * ratio(sioCount, 4) + 0.25 * ratio(problemTypeCount, 4) + 0.2 * ratio(prereqCount, 4) + 0.25 * (hasHighBloom ? 1 : 0);
1594
+ return Math.round(MIN_CALIBRATION_MINUTES + score * (MAX_CALIBRATION_MINUTES - MIN_CALIBRATION_MINUTES));
1595
+ }
1722
1596
  function topologicalSort(concepts) {
1723
1597
  const inDegree = /* @__PURE__ */ new Map();
1724
1598
  const adjacency = /* @__PURE__ */ new Map();
@@ -1765,7 +1639,7 @@ async function generateKnowledgeGraph(options) {
1765
1639
  log("STEP_1", `Parsed syllabus: ${parsedSyllabus.units.length} units, ${parsedSyllabus.allTopics.length} topics`);
1766
1640
  }
1767
1641
  const client = createLlmClient(llmConfig);
1768
- const maxSyllabusChars = parseInt(process.env.KG_MAX_SYLLABUS_CHARS || "16000", 10);
1642
+ const maxSyllabusChars = parseInt(process.env.KG_MAX_SYLLABUS_CHARS || "32000", 10);
1769
1643
  let truncatedSyllabus = "";
1770
1644
  if (syllabusText) {
1771
1645
  if (syllabusText.length > maxSyllabusChars) {
@@ -1884,7 +1758,7 @@ Return JSON:
1884
1758
  }
1885
1759
  {
1886
1760
  const STOP = /* @__PURE__ */ new Set(["and", "the", "for", "with", "from", "that", "this", "into", "when", "while", "using", "their", "your", "each", "how", "why", "what", "between", "through", "about"]);
1887
- const tokensFrom = (text) => text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4 && !STOP.has(w));
1761
+ const tokensFrom = (text) => stripDiacritics(text).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4 && !STOP.has(w));
1888
1762
  for (const c of concepts) {
1889
1763
  const existing = (c.keywords || []).map((k) => String(k).trim()).filter(Boolean);
1890
1764
  if (existing.length === 0) {
@@ -1955,23 +1829,32 @@ Return JSON:
1955
1829
  }
1956
1830
  }
1957
1831
  const idByTreeCode = new Map(concepts.filter((c) => c.tree_code).map((c) => [c.tree_code, c.id]));
1832
+ const unmappedTreePrereqs = /* @__PURE__ */ new Set();
1958
1833
  for (const c of concepts) {
1959
1834
  for (const tp of c.tree_prerequisites || []) {
1960
1835
  const target = idByTreeCode.get(tp);
1961
1836
  if (target && target !== c.id && !(c.prerequisites || []).includes(target)) {
1962
1837
  c.prerequisites = [...c.prerequisites || [], target];
1963
1838
  warnings.push("Prereq seeded from tree: " + c.id + " <- " + tp + " (" + target + ")");
1839
+ } else if (!target) {
1840
+ unmappedTreePrereqs.add(tp);
1964
1841
  }
1965
1842
  }
1966
1843
  }
1844
+ if (unmappedTreePrereqs.size > 0) {
1845
+ const shown = [...unmappedTreePrereqs].slice(0, 10).join(", ");
1846
+ warnings.push("Tree prereqs outside THIS analysis (no edge seeded, " + unmappedTreePrereqs.size + " codes): " + shown + (unmappedTreePrereqs.size > 10 ? ", ..." : ""));
1847
+ }
1967
1848
  log("STEP_2_5", "Standardized: " + std.decisions.filter((d) => d.decision === "standardized").length + ", proposed new: " + std.decisions.filter((d) => d.decision === "proposed_new").length);
1849
+ } else {
1850
+ warnings.push("No knowledgeTreePath provided \u2014 concepts are NOT standardized against the knowledge tree (all proposed_new)");
1968
1851
  }
1969
1852
  log("STEP_3", "Checking for prerequisite cycles...");
1970
1853
  const cycles = detectPrerequisiteCycles(concepts);
1971
1854
  if (cycles.length > 0) {
1972
1855
  log("STEP_3", `Found ${cycles.length} cycle(s) in prerequisite chain \u2014 breaking cycles`);
1973
1856
  for (const cycle of cycles) {
1974
- warnings.push(`Cycle detected: ${cycle.join(" \u2192 ")} \u2014 breaking at weakest edge`);
1857
+ warnings.push(`Cycle detected: ${cycle.join(" \u2192 ")} \u2014 removing the closing back-edge (last DFS edge; pedagogically arbitrary)`);
1975
1858
  }
1976
1859
  const { fixed, removed } = breakPrerequisiteCycles(concepts, cycles);
1977
1860
  for (let i = 0; i < concepts.length; i++) {
@@ -2052,14 +1935,11 @@ Return JSON:
2052
1935
  const hasHighBloom = (concept.problem_types || []).some(
2053
1936
  (pt) => ["Analyze", "Evaluate", "Create"].includes(pt.bloom_level || "")
2054
1937
  );
2055
- const score = Math.min(1, sioCount * 0.2 + ptCount * 0.15 + prereqCount * 0.15 + (hasHighBloom ? 0.2 : 0));
2056
- const calibrated = Math.round(15 + score * 45);
2057
- const llmEstimate = concept.estimated_minutes || 30;
2058
- if (llmEstimate === 30 || Math.abs(calibrated - llmEstimate) / llmEstimate > 0.5) {
1938
+ const calibrated = calibrateConceptMinutes(sioCount, ptCount, prereqCount, hasHighBloom);
1939
+ const llmEstimate = concept.estimated_minutes || DEFAULT_CONCEPT_MINUTES;
1940
+ if (Math.abs(calibrated - llmEstimate) / llmEstimate > MIN_CALIBRATION_DIVERGENCE) {
2059
1941
  concept.estimated_minutes = calibrated;
2060
- if (llmEstimate !== calibrated) {
2061
- warnings.push("Concept " + concept.id + ": minutes calibrated " + llmEstimate + "m \u2192 " + calibrated + "m (score=" + score.toFixed(2) + ")");
2062
- }
1942
+ warnings.push("Concept " + concept.id + ": minutes calibrated " + llmEstimate + "m \u2192 " + calibrated + "m");
2063
1943
  }
2064
1944
  }
2065
1945
  log("STEP_5c", "Minute calibration complete");
@@ -2084,7 +1964,8 @@ Return JSON:
2084
1964
  return {
2085
1965
  knowledgeGraph: verifiedGraph,
2086
1966
  warnings,
2087
- verificationReport: report
1967
+ verificationReport: report,
1968
+ provenance: client.callLog
2088
1969
  };
2089
1970
  }
2090
1971
 
@@ -2111,9 +1992,10 @@ async function generateHybridGraph(options) {
2111
1992
  description: "",
2112
1993
  total_project_minutes: 0,
2113
1994
  total_concept_minutes: 0,
2114
- estimated_total_minutes: 0
1995
+ linked_prereq_minutes: 0
2115
1996
  },
2116
- warnings
1997
+ warnings,
1998
+ provenance: []
2117
1999
  };
2118
2000
  }
2119
2001
  const client = createLlmClient(llmConfig);
@@ -2194,9 +2076,14 @@ async function generateHybridGraph(options) {
2194
2076
  description: "Progressive completion path: " + phases.length + " phases, " + features.length + " features, " + concepts.length + " concepts",
2195
2077
  total_project_minutes: totalProjectMinutes,
2196
2078
  total_concept_minutes: totalConceptMinutes,
2197
- estimated_total_minutes: totalProjectMinutes + totalPrereqMinutes
2079
+ // HG-1 (2026-10-05): renamed from estimated_total_minutes. It counts only
2080
+ // concepts that HAVE a feature link (max per concept, not summed), so it
2081
+ // is NOT the course total — see HybridGraphSchema.linked_prereq_minutes.
2082
+ // The phase planner below commits to totalProjectMinutes + totalConceptMinutes.
2083
+ linked_prereq_minutes: totalProjectMinutes + totalPrereqMinutes
2198
2084
  },
2199
- warnings
2085
+ warnings,
2086
+ provenance: client.callLog
2200
2087
  };
2201
2088
  }
2202
2089
  function auditPhasePlan(plan, features, concepts, links, warnings, log) {
@@ -2272,12 +2159,20 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
2272
2159
  } else {
2273
2160
  newIntroPhase.set(cid, order);
2274
2161
  }
2162
+ introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
2163
+ }
2164
+ for (const intro of introductions) {
2165
+ const cid = intro.concept_id;
2166
+ if (intro.aspect === "advanced") continue;
2167
+ const introOrder = newIntroPhase.get(cid);
2168
+ if (introOrder === void 0 || introOrder !== order) continue;
2275
2169
  for (const pre of prereqOf.get(cid) || []) {
2276
- if (!newIntroPhase.has(pre)) {
2277
- errors.push("Phase " + id + ": concept " + cid + " introduced before its prerequisite " + pre + " is introduced - violates the prerequisite chain");
2170
+ const previewOrder = previewPhase.get(pre);
2171
+ const covered = newIntroPhase.has(pre) || previewOrder !== void 0 && previewOrder < order;
2172
+ if (!covered) {
2173
+ errors.push("Phase " + id + ": concept " + cid + " introduced before its prerequisite " + pre + " is introduced (or previewed in an earlier phase) - violates the prerequisite chain");
2278
2174
  }
2279
2175
  }
2280
- introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
2281
2176
  }
2282
2177
  phases.push({
2283
2178
  id,
@@ -2315,6 +2210,9 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
2315
2210
  if (anchor !== void 0 && anchor <= pOrder) {
2316
2211
  errors.push("Concept " + cid + " previewed in phase " + pOrder + " but fully introduced in phase " + anchor + " - preview must precede the anchor introduction");
2317
2212
  }
2213
+ if (anchor === void 0) {
2214
+ errors.push("Concept " + cid + " is previewed in phase " + pOrder + " but never fully introduced (aspect new) in any phase - a preview must have an anchor teach");
2215
+ }
2318
2216
  }
2319
2217
  return { phases, errors };
2320
2218
  }
@@ -2371,6 +2269,6 @@ async function decomposePhases(args) {
2371
2269
  return phases;
2372
2270
  }
2373
2271
 
2374
- export { CIO_CODE_RE, CONCEPT_CODE_RE, LEARNER_CLAUSE, SIO_CODE_RE, ULO_CODE_RE, assignConceptCodes, auditDepthLayers, auditPhasePlan, auditSyllabusCoverage, breakPrerequisiteCycles, cioActionSlug, cioCode, conceptCodeFromName, createLlmClient, detectPrerequisiteCycles, escalateAndMapConcepts, extractFeatureSteps, extractFeatureStepsBatched, extractProjectOverview, extractScaffold, generateHybridGraph, generateKnowledgeGraph, llmChatJson, sioCode, standardStatement, techTagFor, uloCode, verifyKnowledgeGraph, verifyProjectGraph };
2375
- //# sourceMappingURL=chunk-TYXSJUQU.mjs.map
2376
- //# sourceMappingURL=chunk-TYXSJUQU.mjs.map
2272
+ export { CIO_CODE_RE, CONCEPT_CODE_RE, LEARNER_CLAUSE, SIO_CODE_RE, ULO_CODE_RE, assignConceptCodes, auditDepthLayers, auditPhasePlan, auditSyllabusCoverage, breakPrerequisiteCycles, cioActionSlug, cioCode, conceptCodeFromName, detectPrerequisiteCycles, escalateAndMapConcepts, extractFeatureSteps, extractFeatureStepsBatched, extractProjectOverview, extractScaffold, generateHybridGraph, generateKnowledgeGraph, sioCode, standardStatement, techTagFor, uloCode, verifyKnowledgeGraph, verifyProjectGraph };
2273
+ //# sourceMappingURL=chunk-4UCXYRM3.mjs.map
2274
+ //# sourceMappingURL=chunk-4UCXYRM3.mjs.map