@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
@@ -4,7 +4,56 @@ var fs = require('fs');
4
4
  var os = require('os');
5
5
  var path = require('path');
6
6
 
7
+ // src/utils/llmTransportResult.ts
8
+ function transportResultFrom(provider, model, options) {
9
+ return {
10
+ provider,
11
+ model,
12
+ framingStripped: !!options?.framingStripped,
13
+ upstreamError: options?.upstreamError,
14
+ usage: options?.usage
15
+ };
16
+ }
17
+
7
18
  // src/utils/llmClient.ts
19
+ async function responseJsonCompat(response) {
20
+ const text = await response.text();
21
+ try {
22
+ return JSON.parse(text);
23
+ } catch {
24
+ const start = text.indexOf("{");
25
+ if (start < 0) {
26
+ throw new LlmClientError(`LLM API returned a non-JSON body: ${text.slice(0, 200)}`);
27
+ }
28
+ let depth = 0;
29
+ let inString = false;
30
+ let escape = false;
31
+ for (let i = start; i < text.length; i++) {
32
+ const ch = text[i];
33
+ if (escape) {
34
+ escape = false;
35
+ continue;
36
+ }
37
+ if (ch === "\\") {
38
+ escape = true;
39
+ continue;
40
+ }
41
+ if (ch === '"') {
42
+ inString = !inString;
43
+ continue;
44
+ }
45
+ if (inString) continue;
46
+ if (ch === "{") depth++;
47
+ if (ch === "}") {
48
+ depth--;
49
+ if (depth === 0) {
50
+ return JSON.parse(text.slice(start, i + 1));
51
+ }
52
+ }
53
+ }
54
+ throw new LlmClientError(`LLM API returned an unterminated JSON body: ${text.slice(0, 200)}`);
55
+ }
56
+ }
8
57
  var LlmClientError = class extends Error {
9
58
  constructor(message, status, cause) {
10
59
  super(message);
@@ -13,47 +62,46 @@ var LlmClientError = class extends Error {
13
62
  this.name = "LlmClientError";
14
63
  }
15
64
  };
16
- function resolveProviderChain(config) {
17
- const chain = [];
18
- const push = (label, baseUrl, apiKey, model) => {
19
- if (apiKey && baseUrl && model && !chain.some((c) => c.baseUrl === baseUrl && c.model === model)) {
20
- chain.push({ label, baseUrl, apiKey, model });
21
- }
22
- };
23
- if (config?.apiKey) {
24
- push("config", config.baseUrl || "https://api.openai.com/v1", config.apiKey, config.model || "gpt-4o-mini");
25
- }
26
- 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 || "");
27
- 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 || "");
28
- push("nvidia", "https://integrate.api.nvidia.com/v1", process.env.NVIDIA_API_KEY || "", process.env.NVIDIA_MODEL || "nvidia/nemotron-3-ultra-550b-a55b:free");
29
- push("openrouter", "https://openrouter.ai/api/v1", process.env.OPENROUTER_API_KEY || "", process.env.OPENROUTER_MODEL || "nvidia/nemotron-3-ultra-550b-a55b:free");
30
- if (process.env.OPENROUTER_MODEL) {
31
- const or = chain.find((c) => c.label === "openrouter");
32
- if (or) {
33
- const rest = chain.filter((c) => c !== or);
34
- return [or, ...rest];
35
- }
36
- }
37
- return chain;
65
+ function build9routerProvider(model) {
66
+ const apiKey = process.env.NINEROUTER_API_KEY;
67
+ if (!apiKey) return null;
68
+ const baseUrl = (process.env.NINEROUTER_BASE_URL || "https://ai-router.orchable.app/v1").replace(/^["']|["']$/g, "").trim();
69
+ const label = "9router";
70
+ const modelName = (model || process.env.NINEROUTER_MODEL || "laguna-s-2.1").trim();
71
+ return { label, baseUrl, apiKey, model: modelName };
72
+ }
73
+ function resolve9routerChain(configModel) {
74
+ const primary = build9routerProvider(configModel);
75
+ if (!primary) return [];
76
+ const alt = process.env.NINEROUTER_MODEL_ALT && process.env.NINEROUTER_MODEL_ALT.trim();
77
+ if (!alt) return [primary];
78
+ const secondary = build9routerProvider(alt);
79
+ if (!secondary) return [primary];
80
+ return primary.model === secondary.model ? [primary] : [primary, secondary];
38
81
  }
39
82
  var MIN_REQUEST_INTERVAL_MS = 2500;
83
+ var requestIntervalMs = Number(process.env.LLM_MIN_REQUEST_INTERVAL_MS || MIN_REQUEST_INTERVAL_MS);
40
84
  var lastRequestAt = 0;
41
85
  async function pacedDelay() {
42
- const wait = lastRequestAt + MIN_REQUEST_INTERVAL_MS - Date.now();
86
+ const wait = lastRequestAt + requestIntervalMs - Date.now();
43
87
  if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
44
88
  lastRequestAt = Date.now();
45
89
  }
46
90
  function createLlmClient(config) {
47
- const chain = resolveProviderChain(config);
91
+ const chain = resolve9routerChain(config?.model);
48
92
  const primary = chain[0];
49
93
  if (!primary) {
50
94
  throw new LlmClientError(
51
- "No LLM provider configured. Set NVIDIA_API_KEY (primary) and/or OPENROUTER_API_KEY (fallback), or pass llmConfig."
95
+ "No 9router provider configured. Set NINEROUTER_API_KEY and NINEROUTER_MODEL (or the NINEROUTER_* env defaults expected by the kit)."
52
96
  );
53
97
  }
54
98
  primary.apiKey;
55
99
  primary.baseUrl;
56
100
  const model = primary.model;
101
+ if (config?.minRequestIntervalMs !== void 0) {
102
+ requestIntervalMs = Math.max(0, config.minRequestIntervalMs);
103
+ }
104
+ const callLog = [];
57
105
  async function chat(messages, options) {
58
106
  const temperature = options?.temperature ?? config?.temperature ?? 0.1;
59
107
  const maxTokens = options?.maxTokens ?? config?.maxTokens ?? parseInt(process.env.LLM_MAX_TOKENS || "65536", 10);
@@ -68,6 +116,7 @@ function createLlmClient(config) {
68
116
  await new Promise((resolve) => setTimeout(resolve, 2e4));
69
117
  }
70
118
  for (const provider of chain) {
119
+ const recIdx = callLog.push({ provider: provider.label, model: provider.model, ok: false }) - 1;
71
120
  try {
72
121
  const headers = {
73
122
  "Content-Type": "application/json",
@@ -125,16 +174,22 @@ function createLlmClient(config) {
125
174
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
126
175
  });
127
176
  if (!retry.ok) throw err;
128
- const retryData = await retry.json();
177
+ const retryData = await responseJsonCompat(retry);
129
178
  const retryContent2 = retryData.choices?.[0]?.message?.content || "";
130
179
  if (!retryContent2.trim()) throw err;
180
+ callLog[recIdx] = { ...callLog[recIdx], ok: true };
181
+ const transport2 = transportResultFrom(provider.label, retryData.model || provider.model, {
182
+ usage: retryData.usage,
183
+ framingStripped: true
184
+ });
131
185
  return {
132
186
  content: retryContent2,
133
187
  model: retryData.model || provider.model,
134
188
  provider: provider.label,
135
189
  attempts: attempts.slice(),
136
190
  usage: retryData.usage,
137
- finishReason: retryData.choices?.[0]?.finish_reason
191
+ finishReason: retryData.choices?.[0]?.finish_reason,
192
+ _transport: transport2
138
193
  };
139
194
  }
140
195
  if (TRANSIENT.has(response.status)) {
@@ -154,20 +209,26 @@ function createLlmClient(config) {
154
209
  })
155
210
  });
156
211
  if (!retry.ok) throw err;
157
- const retryData = await retry.json();
212
+ const retryData = await responseJsonCompat(retry);
158
213
  const retryContent = retryData.choices?.[0]?.message?.content || "";
214
+ callLog[recIdx] = { ...callLog[recIdx], ok: true };
215
+ const transport2 = transportResultFrom(provider.label, retryData.model || provider.model, {
216
+ usage: retryData.usage,
217
+ framingStripped: true
218
+ });
159
219
  return {
160
220
  content: retryContent,
161
221
  model: retryData.model || provider.model,
162
222
  provider: provider.label,
163
223
  attempts: attempts.slice(),
164
224
  usage: retryData.usage,
165
- finishReason: retryData.choices?.[0]?.finish_reason
225
+ finishReason: retryData.choices?.[0]?.finish_reason,
226
+ _transport: transport2
166
227
  };
167
228
  }
168
229
  throw err;
169
230
  }
170
- const data = await response.json();
231
+ const data = await responseJsonCompat(response);
171
232
  const wrappedError = data.error;
172
233
  if (wrappedError) {
173
234
  throw new LlmClientError("[" + provider.label + "] upstream error " + (wrappedError.code ?? "") + ": " + (wrappedError.message ?? "unknown"));
@@ -176,16 +237,22 @@ function createLlmClient(config) {
176
237
  if (!content.trim()) {
177
238
  throw new LlmClientError("[" + provider.label + "] empty completion returned");
178
239
  }
240
+ const transport = transportResultFrom(provider.label, data.model || provider.model, {
241
+ usage: data.usage,
242
+ framingStripped: true
243
+ });
179
244
  return {
180
245
  content,
181
246
  model: data.model || provider.model,
182
247
  provider: provider.label,
183
248
  attempts: attempts.slice(),
184
249
  usage: data.usage,
185
- finishReason: data.choices?.[0]?.finish_reason
250
+ finishReason: data.choices?.[0]?.finish_reason,
251
+ _transport: transport
186
252
  };
187
253
  } catch (err) {
188
254
  const msg = err instanceof Error ? err.message : String(err);
255
+ callLog[recIdx] = { ...callLog[recIdx], ok: false, error: msg.slice(0, 200) };
189
256
  attempts.push(`${provider.label}: ${msg}`);
190
257
  lastError = err instanceof LlmClientError ? err : new LlmClientError(`[${provider.label}] ${msg}`);
191
258
  }
@@ -195,7 +262,7 @@ function createLlmClient(config) {
195
262
  }
196
263
  throw lastError ?? new LlmClientError("All LLM providers failed after " + MAX_CHAIN_ROUNDS + " rounds.");
197
264
  }
198
- return { chat, model };
265
+ return { chat, model, callLog };
199
266
  }
200
267
  function parseJsonFromLlm(text) {
201
268
  let cleaned = text.trim();
@@ -259,8 +326,8 @@ async function llmChatJson(client, systemPrompt, userPrompt, options) {
259
326
 
260
327
  // src/graph/scaffoldExtractor.ts
261
328
  async function extractScaffold(options) {
262
- const { goal, techStack, fileList, llmConfig } = options;
263
- const client = createLlmClient(llmConfig);
329
+ const { goal, techStack, fileList, llmConfig, sharedClient } = options;
330
+ const client = sharedClient ?? createLlmClient(llmConfig);
264
331
  const fileListStr = fileList.length > 0 ? fileList.join("\n") : "(empty)";
265
332
  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.";
266
333
  const userPrompt = `App goal: ${goal}
@@ -345,8 +412,8 @@ ${content}`);
345
412
  return blocks.join("\n\n");
346
413
  }
347
414
  async function extractProjectOverview(options) {
348
- const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig } = options;
349
- const client = createLlmClient(llmConfig);
415
+ const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig, sharedClient } = options;
416
+ const client = sharedClient ?? createLlmClient(llmConfig);
350
417
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
351
418
  const allFilesStr = buildSourceText(fileContentsMap);
352
419
  let keywordsAnchor = "";
@@ -355,7 +422,7 @@ async function extractProjectOverview(options) {
355
422
  (kw) => `- ${kw.keyword} (from ${kw.source})`
356
423
  );
357
424
  if (kwItems.length > 0) {
358
- keywordsAnchor = "\nAST Domain Keywords & Patterns (from static analysis \u2014 use as context anchor):\n" + kwItems.join("\n") + "\n";
425
+ keywordsAnchor = "\nDomain Keywords & Patterns (from regex-based static scan \u2014 use as context anchor):\n" + kwItems.join("\n") + "\n";
359
426
  }
360
427
  }
361
428
  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.";
@@ -415,8 +482,8 @@ ${content}`);
415
482
  return blocks.join("\n\n");
416
483
  }
417
484
  async function extractFeatureSteps(options) {
418
- const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig } = options;
419
- const client = createLlmClient(llmConfig);
485
+ const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig, sharedClient } = options;
486
+ const client = sharedClient ?? createLlmClient(llmConfig);
420
487
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
421
488
  const allFilesStr = buildSourceText2(fileContentsMap);
422
489
  const fid = feature.id || "F_UNK";
@@ -483,8 +550,8 @@ Split this feature into 2-6 action steps. Return JSON:
483
550
  concept_codes: []
484
551
  }));
485
552
  }
486
- async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig) {
487
- const client = createLlmClient(llmConfig);
553
+ async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig, sharedClient) {
554
+ const client = sharedClient ?? createLlmClient(llmConfig);
488
555
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
489
556
  const sourceText = buildSourceText2(fileContentsMap);
490
557
  const featuresListing = featuresMeta.map(
@@ -751,7 +818,7 @@ function inferDepthFromContext(keyword, fileContentsMap) {
751
818
  return { depth: "sio", rationale: `Default: treating '${keyword}' as SIO (implementation-level)` };
752
819
  }
753
820
  async function escalateAndMapConcepts(options) {
754
- const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig } = options;
821
+ const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig, sharedClient } = options;
755
822
  const features = projectGraph.features || [];
756
823
  const allTerms = /* @__PURE__ */ new Set();
757
824
  for (const feature of features) {
@@ -767,7 +834,7 @@ async function escalateAndMapConcepts(options) {
767
834
  llmResults = conceptMapOverride;
768
835
  } else {
769
836
  try {
770
- const client = createLlmClient(llmConfig);
837
+ const client = sharedClient ?? createLlmClient(llmConfig);
771
838
  const conceptBank = (availableConcepts || []).sort().join(", ") || "(empty)";
772
839
  const termEntries = termList.map((term) => {
773
840
  const snippets = extractTermSnippets(term, fileContentsMap);
@@ -820,13 +887,16 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
820
887
  const cName = info.concept_name || kw;
821
888
  let depth = "sio";
822
889
  let depthRationale = "Default: SIO";
890
+ let depthSource = "override";
823
891
  if (info.depth && ["ulo", "cio", "sio"].includes(info.depth)) {
824
892
  depth = info.depth;
825
893
  depthRationale = `LLM classified as ${depth}`;
894
+ depthSource = "llm";
826
895
  } else {
827
896
  const inferred = inferDepthFromContext(kw, fileContentsMap);
828
897
  depth = inferred.depth;
829
898
  depthRationale = inferred.rationale;
899
+ depthSource = "heuristic";
830
900
  }
831
901
  const kwKey = `${cCode}::${kw}`;
832
902
  if (seen.has(kwKey)) continue;
@@ -844,6 +914,7 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
844
914
  keyword: kw,
845
915
  depth,
846
916
  depth_rationale: depthRationale,
917
+ depth_source: depthSource,
847
918
  evidence_files: evidenceFiles
848
919
  });
849
920
  }
@@ -968,6 +1039,13 @@ var ULO_CODE_RE = /^ULO-[A-Z][A-Z0-9_]*-\d{2}$/;
968
1039
  var CIO_CODE_RE = /^CIO-[A-Z][A-Z0-9_]*-\d{2}-[A-Z][A-Z0-9_]*$/;
969
1040
  var SIO_CODE_RE = /^SIO-[A-Z0-9]+-[A-Z][A-Z0-9_]*-\d{2}$/;
970
1041
 
1042
+ // src/utils/diacritics.ts
1043
+ var COMBINING_MARKS = /[\u0300-\u036f]/g;
1044
+ var D_WITH_STROKE = /đ/gi;
1045
+ function stripDiacritics(s) {
1046
+ return s.normalize("NFD").replace(COMBINING_MARKS, "").replace(D_WITH_STROKE, "d").toLowerCase();
1047
+ }
1048
+
971
1049
  // src/parsers/syllabusParser.ts
972
1050
  var STOP_WORDS = /* @__PURE__ */ new Set([
973
1051
  "and",
@@ -1360,7 +1438,110 @@ function techSignalHits(text) {
1360
1438
  }
1361
1439
  return hits;
1362
1440
  }
1363
- function auditDepthLayers(concepts) {
1441
+ var COMPLETABLE_VERBS = [
1442
+ "build",
1443
+ "create",
1444
+ "implement",
1445
+ "write",
1446
+ "configure",
1447
+ "add",
1448
+ "use",
1449
+ "apply",
1450
+ "display",
1451
+ "store",
1452
+ "load",
1453
+ "save",
1454
+ "handle",
1455
+ "parse",
1456
+ "validate",
1457
+ "test",
1458
+ "register",
1459
+ "compute",
1460
+ "generate",
1461
+ "print",
1462
+ "format",
1463
+ "convert",
1464
+ "extend",
1465
+ "define",
1466
+ "execute",
1467
+ "run",
1468
+ "read",
1469
+ "fetch",
1470
+ "wrap",
1471
+ "log",
1472
+ "call",
1473
+ "raise",
1474
+ "install",
1475
+ "import",
1476
+ "export",
1477
+ "declare",
1478
+ "embed",
1479
+ "model",
1480
+ "represent",
1481
+ "override",
1482
+ "customize",
1483
+ "enforce",
1484
+ "mock",
1485
+ "assert",
1486
+ "query",
1487
+ "index",
1488
+ "cache",
1489
+ "stream",
1490
+ "split",
1491
+ "join",
1492
+ "strip",
1493
+ "encode",
1494
+ "decode",
1495
+ "traverse",
1496
+ "measure",
1497
+ "evaluate",
1498
+ "benchmark",
1499
+ "diagnose",
1500
+ "demonstrate",
1501
+ "verify",
1502
+ "organize",
1503
+ "structure",
1504
+ "choose",
1505
+ "select",
1506
+ "compare",
1507
+ "distinguish",
1508
+ "attach",
1509
+ "annotate",
1510
+ "expose",
1511
+ "accept",
1512
+ "manage",
1513
+ "group",
1514
+ "sort",
1515
+ "filter",
1516
+ "map",
1517
+ "reduce",
1518
+ "catch",
1519
+ "throw",
1520
+ "migrate",
1521
+ "inspect",
1522
+ "trace",
1523
+ "parameterize",
1524
+ "construct",
1525
+ "design",
1526
+ "capture",
1527
+ // Completable implementation verbs present in production SIO output but
1528
+ // missing from the original registry (2026-10-07 smoke test, swift kg):
1529
+ "update",
1530
+ "inject",
1531
+ "compose",
1532
+ "pass",
1533
+ "modify",
1534
+ "connect",
1535
+ "remove",
1536
+ "transform",
1537
+ "trigger",
1538
+ "refresh",
1539
+ "persist",
1540
+ "observe",
1541
+ "arrange",
1542
+ "derive"
1543
+ ];
1544
+ function auditDepthLayers(concepts, transport) {
1364
1545
  return concepts.map((c) => {
1365
1546
  const ulo = (c.ulo ?? "").trim();
1366
1547
  const cio = (c.cio ?? "").trim();
@@ -1381,7 +1562,7 @@ function auditDepthLayers(concepts) {
1381
1562
  });
1382
1563
  const conceptKeywords = (c.keywords ?? []).map((k) => k.toLowerCase()).filter(Boolean);
1383
1564
  const sioOffConceptItems = conceptKeywords.length > 0 ? sioItems.filter((s) => !boundToConcept(s)) : [];
1384
- 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;
1565
+ const COMPLETABLE = new RegExp("\\b(?:" + COMPLETABLE_VERBS.join("|") + ")\\b", "i");
1385
1566
  const sioNotCompletable = sioItems.filter((s) => !COMPLETABLE.test(s) && !/`[^`]+`/.test(s));
1386
1567
  const density = (text) => {
1387
1568
  if (!text) return 0;
@@ -1421,7 +1602,8 @@ function auditDepthLayers(concepts) {
1421
1602
  clauseOk,
1422
1603
  codesOk,
1423
1604
  codeIssues,
1424
- passed
1605
+ passed,
1606
+ transport
1425
1607
  };
1426
1608
  });
1427
1609
  }
@@ -1431,14 +1613,14 @@ function excerpt(text, max) {
1431
1613
  const t = (text || "").trim();
1432
1614
  return t.length > max ? t.slice(0, max) + "..." : t;
1433
1615
  }
1434
- async function chatJson(client, system, user) {
1616
+ async function chatJson(client, system, user, transport) {
1435
1617
  const r = await client.chat(
1436
1618
  [{ role: "system", content: system }, { role: "user", content: user }],
1437
1619
  { temperature: 0.1, maxTokens: parseInt(process.env.KG_MAX_TOKENS || "65536", 10) }
1438
1620
  );
1439
1621
  return parseJsonFromLlm(r.content);
1440
1622
  }
1441
- var LO_BATCH_SIZE = 6;
1623
+ var LO_BATCH_SIZE = 10;
1442
1624
  function chunkConcepts(items, size) {
1443
1625
  const out = [];
1444
1626
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -1477,7 +1659,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1477
1659
  for (const batch of chunkConcepts(concepts, LO_BATCH_SIZE)) {
1478
1660
  const batchList = batch.map((c) => "- " + c.id + " (" + c.name + ")" + (c.description ? ": " + excerpt(c.description, 160) : "")).join("\n");
1479
1661
  const batchUser = "Concepts:\n" + batchList + '\n\nReturn JSON:\n{"ulos":[{"concept_id":"C1","ulo":"..."}]}';
1480
- const uloRes = await chatJson(client, uloSystem, batchUser);
1662
+ const uloRes = await chatJson(client, uloSystem, batchUser, transportResultFrom("9router", client.model, { framingStripped: true }));
1481
1663
  for (const u of uloRes.ulos ?? []) {
1482
1664
  if (u.concept_id && u.ulo) uloByConcept[u.concept_id] = standardStatement(u.ulo);
1483
1665
  }
@@ -1516,7 +1698,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1516
1698
  new Set(batch.map((c) => c.id));
1517
1699
  const batchUloContext = batch.map((c) => "- " + c.id + " (" + c.name + "): " + (uloByConcept[c.id] || "(missing)")).join("\n");
1518
1700
  const batchCioUser = "Approved ULOs:\n" + batchUloContext + '\n\nReturn JSON:\n{"cios":[{"concept_id":"C1","cio":"..."}]}';
1519
- const cioRes = await chatJson(client, cioSystem, batchCioUser);
1701
+ const cioRes = await chatJson(client, cioSystem, batchCioUser, transportResultFrom("9router", client.model, { framingStripped: true }));
1520
1702
  for (const x of cioRes.cios ?? []) {
1521
1703
  if (x.concept_id && x.cio) cioByConcept[x.concept_id] = standardStatement(x.cio);
1522
1704
  }
@@ -1541,15 +1723,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1541
1723
  " context where it is used. Application/Analysis/Creation skills are assessed",
1542
1724
  " INDIRECTLY through completing these tasks \u2014 there is no fixed Bloom ceiling.",
1543
1725
  " MACHINE CHECK: every item MUST contain at least one ACTION VERB from this whitelist",
1544
- " (build, create, implement, write, configure, add, use, apply, display, store, load,",
1545
- " save, handle, parse, validate, test, register, compute, generate, print, format,",
1546
- " convert, extend, define, execute, run, read, fetch, wrap, log, call, raise, install,",
1547
- " import, export, declare, embed, model, represent, override, customize, enforce, mock,",
1548
- " assert, query, index, cache, stream, split, join, strip, encode, decode, traverse,",
1549
- " measure, evaluate, benchmark, diagnose, demonstrate, verify, organize, structure,",
1550
- " choose, select, compare, distinguish, attach, annotate, expose, accept, manage, group,",
1551
- " sort, filter, map, reduce, catch, throw, migrate, inspect, trace, parameterize,",
1552
- " construct, design, capture) \u2014 OR wrap the concrete construct in backticks.",
1726
+ " (" + COMPLETABLE_VERBS.join(", ") + ") \u2014 OR wrap the concrete construct in backticks.",
1553
1727
  " Items with abstract verbs (understand, learn, know, grasp, appreciate, explore) and",
1554
1728
  " no backtick construct FAIL the audit. Prefer starting the task phrase with the verb.",
1555
1729
  "3. 2-3 items per concept, each referencing a DIFFERENT concrete construct.",
@@ -1577,7 +1751,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1577
1751
  "Return JSON:",
1578
1752
  '{"sios":[{"concept_id":"C1","sio":["...","..."]}]}'
1579
1753
  ].join("\n");
1580
- const sioRes = await chatJson(client, sioSystem, batchSioUser);
1754
+ const sioRes = await chatJson(client, sioSystem, batchSioUser, transportResultFrom("9router", client.model, { framingStripped: true }));
1581
1755
  for (const x of sioRes.sios ?? []) {
1582
1756
  if (x.concept_id && Array.isArray(x.sio)) sioByConcept[x.concept_id] = x.sio.map((item) => standardStatement(String(item)));
1583
1757
  }
@@ -1630,7 +1804,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1630
1804
  "- CIO: HOW mechanism, technology-agnostic, more concrete than ULO, Marr 2-Language neutral.",
1631
1805
  "- SIO: keyword-bound completable implementation tasks naming real constructs of " + techStack + ".",
1632
1806
  "- 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).",
1633
- "- 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.",
1807
+ "- 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.",
1634
1808
  "",
1635
1809
  JSON_ONLY
1636
1810
  ].join("\n");
@@ -1678,7 +1852,7 @@ async function authorDepthLayersHierarchically(client, concepts, techStack, warn
1678
1852
  "Depth-layer contract audit failed after repair for: " + detail + " \u2014 regenerate or fix the prompt contract (fail-closed, no silent fallback)"
1679
1853
  );
1680
1854
  }
1681
- return { uloByConcept, cioByConcept, sioByConcept, audits, warnings };
1855
+ return { uloByConcept, cioByConcept, sioByConcept, audits, warnings, lastTransport: audits[0]?.transport };
1682
1856
  }
1683
1857
  function cell(v) {
1684
1858
  return (v || "").split(",").map((s) => s.trim()).filter(Boolean);
@@ -1730,7 +1904,7 @@ function loadKnowledgeTreeCatalog(tsvPath) {
1730
1904
  }
1731
1905
  function tokens(s) {
1732
1906
  return new Set(
1733
- s.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
1907
+ stripDiacritics(s).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
1734
1908
  );
1735
1909
  }
1736
1910
  function filterBySelection(catalog, selectedCategories, selectedTopics, analysisConcepts, maxConcepts = 120) {
@@ -1821,7 +1995,7 @@ async function standardizeConcepts(opts) {
1821
1995
  " Adopt the tree's terminology. Only propose new when the tree genuinely has NO concept teaching that knowledge (broader AND narrower both fail).",
1822
1996
  ' 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.',
1823
1997
  " If between 1 and 3 tree concepts are CLOSE but not exact, list their codes in possible_tree_matches for human review.",
1824
- `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.`,
1998
+ `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.`,
1825
1999
  "Rules:",
1826
2000
  "- Judge by MEANING (definition + keywords), not by string similarity.",
1827
2001
  '- 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.',
@@ -1861,10 +2035,25 @@ async function standardizeConcepts(opts) {
1861
2035
  const tc = catalog.concepts.find((x) => x.code === d.tree_code);
1862
2036
  if (!tc) throw new Error("Concept " + c.id + " standardized to unknown tree code " + d.tree_code + " \u2014 fail-closed");
1863
2037
  const treeKw = new Set(tc.keywords.map((k) => k.toLowerCase()));
1864
- const adopted = (d.adopted_keywords || []).map((k) => String(k)).filter((k) => {
1865
- if (treeKw.has(k.toLowerCase())) return true;
1866
- throw new Error("Concept " + c.id + ' adopted keyword "' + k + '" not in tree concept ' + tc.code + " \u2014 fail-closed");
1867
- });
2038
+ const rawAdopted = (d.adopted_keywords || []).map((k) => String(k)).filter(Boolean);
2039
+ let adopted;
2040
+ if (tc.keywords.length === 0) {
2041
+ adopted = rawAdopted.filter((k) => (c.keywords ?? []).some((ck) => ck.toLowerCase() === k.toLowerCase()));
2042
+ if (adopted.length === 0) adopted = (c.keywords ?? []).slice(0, 8);
2043
+ warnings.push("Concept " + c.id + ": tree concept " + tc.code + " has no keyword registry \u2014 adopted " + adopted.length + " keyword(s) from the analysis concept");
2044
+ } else {
2045
+ const norm = (k) => k.toLowerCase().replace(/ies$/, "y").replace(/s$/, "");
2046
+ const treeKwNorm = new Set([...treeKw].map(norm));
2047
+ const dropped = [];
2048
+ adopted = rawAdopted.filter((k) => {
2049
+ if (treeKw.has(k.toLowerCase()) || treeKwNorm.has(norm(k))) return true;
2050
+ dropped.push(k);
2051
+ return false;
2052
+ });
2053
+ if (dropped.length > 0) {
2054
+ warnings.push("Concept " + c.id + ": dropped " + dropped.length + " adopted keyword(s) not in tree concept " + tc.code + " registry: " + dropped.join(", "));
2055
+ }
2056
+ }
1868
2057
  if (tc.keywords.length > 0 && adopted.length === 0) {
1869
2058
  throw new Error("Concept " + c.id + " standardized to " + tc.code + " without adopted_keywords \u2014 fail-closed");
1870
2059
  }
@@ -1904,6 +2093,15 @@ async function standardizeConcepts(opts) {
1904
2093
  }
1905
2094
 
1906
2095
  // src/graph/knowledgeGraphPipeline.ts
2096
+ var MIN_CALIBRATION_MINUTES = 15;
2097
+ var MAX_CALIBRATION_MINUTES = 60;
2098
+ var DEFAULT_CONCEPT_MINUTES = 30;
2099
+ var MIN_CALIBRATION_DIVERGENCE = 0.5;
2100
+ function calibrateConceptMinutes(sioCount, problemTypeCount, prereqCount, hasHighBloom) {
2101
+ const ratio = (value, cap) => Math.min(1, Math.max(0, value) / cap);
2102
+ const score = 0.3 * ratio(sioCount, 4) + 0.25 * ratio(problemTypeCount, 4) + 0.2 * ratio(prereqCount, 4) + 0.25 * (hasHighBloom ? 1 : 0);
2103
+ return Math.round(MIN_CALIBRATION_MINUTES + score * (MAX_CALIBRATION_MINUTES - MIN_CALIBRATION_MINUTES));
2104
+ }
1907
2105
  function topologicalSort(concepts) {
1908
2106
  const inDegree = /* @__PURE__ */ new Map();
1909
2107
  const adjacency = /* @__PURE__ */ new Map();
@@ -1950,7 +2148,7 @@ async function generateKnowledgeGraph(options) {
1950
2148
  log("STEP_1", `Parsed syllabus: ${parsedSyllabus.units.length} units, ${parsedSyllabus.allTopics.length} topics`);
1951
2149
  }
1952
2150
  const client = createLlmClient(llmConfig);
1953
- const maxSyllabusChars = parseInt(process.env.KG_MAX_SYLLABUS_CHARS || "16000", 10);
2151
+ const maxSyllabusChars = parseInt(process.env.KG_MAX_SYLLABUS_CHARS || "32000", 10);
1954
2152
  let truncatedSyllabus = "";
1955
2153
  if (syllabusText) {
1956
2154
  if (syllabusText.length > maxSyllabusChars) {
@@ -2069,7 +2267,7 @@ Return JSON:
2069
2267
  }
2070
2268
  {
2071
2269
  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"]);
2072
- const tokensFrom = (text) => text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4 && !STOP.has(w));
2270
+ const tokensFrom = (text) => stripDiacritics(text).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4 && !STOP.has(w));
2073
2271
  for (const c of concepts) {
2074
2272
  const existing = (c.keywords || []).map((k) => String(k).trim()).filter(Boolean);
2075
2273
  if (existing.length === 0) {
@@ -2140,23 +2338,32 @@ Return JSON:
2140
2338
  }
2141
2339
  }
2142
2340
  const idByTreeCode = new Map(concepts.filter((c) => c.tree_code).map((c) => [c.tree_code, c.id]));
2341
+ const unmappedTreePrereqs = /* @__PURE__ */ new Set();
2143
2342
  for (const c of concepts) {
2144
2343
  for (const tp of c.tree_prerequisites || []) {
2145
2344
  const target = idByTreeCode.get(tp);
2146
2345
  if (target && target !== c.id && !(c.prerequisites || []).includes(target)) {
2147
2346
  c.prerequisites = [...c.prerequisites || [], target];
2148
2347
  warnings.push("Prereq seeded from tree: " + c.id + " <- " + tp + " (" + target + ")");
2348
+ } else if (!target) {
2349
+ unmappedTreePrereqs.add(tp);
2149
2350
  }
2150
2351
  }
2151
2352
  }
2353
+ if (unmappedTreePrereqs.size > 0) {
2354
+ const shown = [...unmappedTreePrereqs].slice(0, 10).join(", ");
2355
+ warnings.push("Tree prereqs outside THIS analysis (no edge seeded, " + unmappedTreePrereqs.size + " codes): " + shown + (unmappedTreePrereqs.size > 10 ? ", ..." : ""));
2356
+ }
2152
2357
  log("STEP_2_5", "Standardized: " + std.decisions.filter((d) => d.decision === "standardized").length + ", proposed new: " + std.decisions.filter((d) => d.decision === "proposed_new").length);
2358
+ } else {
2359
+ warnings.push("No knowledgeTreePath provided \u2014 concepts are NOT standardized against the knowledge tree (all proposed_new)");
2153
2360
  }
2154
2361
  log("STEP_3", "Checking for prerequisite cycles...");
2155
2362
  const cycles = detectPrerequisiteCycles(concepts);
2156
2363
  if (cycles.length > 0) {
2157
2364
  log("STEP_3", `Found ${cycles.length} cycle(s) in prerequisite chain \u2014 breaking cycles`);
2158
2365
  for (const cycle of cycles) {
2159
- warnings.push(`Cycle detected: ${cycle.join(" \u2192 ")} \u2014 breaking at weakest edge`);
2366
+ warnings.push(`Cycle detected: ${cycle.join(" \u2192 ")} \u2014 removing the closing back-edge (last DFS edge; pedagogically arbitrary)`);
2160
2367
  }
2161
2368
  const { fixed, removed } = breakPrerequisiteCycles(concepts, cycles);
2162
2369
  for (let i = 0; i < concepts.length; i++) {
@@ -2237,14 +2444,11 @@ Return JSON:
2237
2444
  const hasHighBloom = (concept.problem_types || []).some(
2238
2445
  (pt) => ["Analyze", "Evaluate", "Create"].includes(pt.bloom_level || "")
2239
2446
  );
2240
- const score = Math.min(1, sioCount * 0.2 + ptCount * 0.15 + prereqCount * 0.15 + (hasHighBloom ? 0.2 : 0));
2241
- const calibrated = Math.round(15 + score * 45);
2242
- const llmEstimate = concept.estimated_minutes || 30;
2243
- if (llmEstimate === 30 || Math.abs(calibrated - llmEstimate) / llmEstimate > 0.5) {
2447
+ const calibrated = calibrateConceptMinutes(sioCount, ptCount, prereqCount, hasHighBloom);
2448
+ const llmEstimate = concept.estimated_minutes || DEFAULT_CONCEPT_MINUTES;
2449
+ if (Math.abs(calibrated - llmEstimate) / llmEstimate > MIN_CALIBRATION_DIVERGENCE) {
2244
2450
  concept.estimated_minutes = calibrated;
2245
- if (llmEstimate !== calibrated) {
2246
- warnings.push("Concept " + concept.id + ": minutes calibrated " + llmEstimate + "m \u2192 " + calibrated + "m (score=" + score.toFixed(2) + ")");
2247
- }
2451
+ warnings.push("Concept " + concept.id + ": minutes calibrated " + llmEstimate + "m \u2192 " + calibrated + "m");
2248
2452
  }
2249
2453
  }
2250
2454
  log("STEP_5c", "Minute calibration complete");
@@ -2269,7 +2473,8 @@ Return JSON:
2269
2473
  return {
2270
2474
  knowledgeGraph: verifiedGraph,
2271
2475
  warnings,
2272
- verificationReport: report
2476
+ verificationReport: report,
2477
+ provenance: client.callLog
2273
2478
  };
2274
2479
  }
2275
2480
 
@@ -2296,9 +2501,10 @@ async function generateHybridGraph(options) {
2296
2501
  description: "",
2297
2502
  total_project_minutes: 0,
2298
2503
  total_concept_minutes: 0,
2299
- estimated_total_minutes: 0
2504
+ linked_prereq_minutes: 0
2300
2505
  },
2301
- warnings
2506
+ warnings,
2507
+ provenance: []
2302
2508
  };
2303
2509
  }
2304
2510
  const client = createLlmClient(llmConfig);
@@ -2379,9 +2585,14 @@ async function generateHybridGraph(options) {
2379
2585
  description: "Progressive completion path: " + phases.length + " phases, " + features.length + " features, " + concepts.length + " concepts",
2380
2586
  total_project_minutes: totalProjectMinutes,
2381
2587
  total_concept_minutes: totalConceptMinutes,
2382
- estimated_total_minutes: totalProjectMinutes + totalPrereqMinutes
2588
+ // HG-1 (2026-10-05): renamed from estimated_total_minutes. It counts only
2589
+ // concepts that HAVE a feature link (max per concept, not summed), so it
2590
+ // is NOT the course total — see HybridGraphSchema.linked_prereq_minutes.
2591
+ // The phase planner below commits to totalProjectMinutes + totalConceptMinutes.
2592
+ linked_prereq_minutes: totalProjectMinutes + totalPrereqMinutes
2383
2593
  },
2384
- warnings
2594
+ warnings,
2595
+ provenance: client.callLog
2385
2596
  };
2386
2597
  }
2387
2598
  function auditPhasePlan(plan, features, concepts, links, warnings, log) {
@@ -2457,12 +2668,20 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
2457
2668
  } else {
2458
2669
  newIntroPhase.set(cid, order);
2459
2670
  }
2671
+ introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
2672
+ }
2673
+ for (const intro of introductions) {
2674
+ const cid = intro.concept_id;
2675
+ if (intro.aspect === "advanced") continue;
2676
+ const introOrder = newIntroPhase.get(cid);
2677
+ if (introOrder === void 0 || introOrder !== order) continue;
2460
2678
  for (const pre of prereqOf.get(cid) || []) {
2461
- if (!newIntroPhase.has(pre)) {
2462
- errors.push("Phase " + id + ": concept " + cid + " introduced before its prerequisite " + pre + " is introduced - violates the prerequisite chain");
2679
+ const previewOrder = previewPhase.get(pre);
2680
+ const covered = newIntroPhase.has(pre) || previewOrder !== void 0 && previewOrder < order;
2681
+ if (!covered) {
2682
+ errors.push("Phase " + id + ": concept " + cid + " introduced before its prerequisite " + pre + " is introduced (or previewed in an earlier phase) - violates the prerequisite chain");
2463
2683
  }
2464
2684
  }
2465
- introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
2466
2685
  }
2467
2686
  phases.push({
2468
2687
  id,
@@ -2500,6 +2719,9 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
2500
2719
  if (anchor !== void 0 && anchor <= pOrder) {
2501
2720
  errors.push("Concept " + cid + " previewed in phase " + pOrder + " but fully introduced in phase " + anchor + " - preview must precede the anchor introduction");
2502
2721
  }
2722
+ if (anchor === void 0) {
2723
+ 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");
2724
+ }
2503
2725
  }
2504
2726
  return { phases, errors };
2505
2727
  }