@thanh01.pmt/domain-kit 0.4.0 → 0.6.0

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 (68) hide show
  1. package/README.md +682 -168
  2. package/dist/assembly/index.d.cts +2 -2
  3. package/dist/assembly/index.d.ts +2 -2
  4. package/dist/{chunk-HILSUZOF.mjs → chunk-2OGNQUXC.mjs} +171 -31
  5. package/dist/chunk-2OGNQUXC.mjs.map +1 -0
  6. package/dist/{chunk-LZQLLAYP.mjs → chunk-5OMSYKQP.mjs} +43 -23
  7. package/dist/chunk-5OMSYKQP.mjs.map +1 -0
  8. package/dist/{chunk-BL5NBKPH.mjs → chunk-67GXK3ZE.mjs} +18 -8
  9. package/dist/chunk-67GXK3ZE.mjs.map +1 -0
  10. package/dist/{chunk-PKUEOSZ3.mjs → chunk-CGNXGVHV.mjs} +46 -4
  11. package/dist/chunk-CGNXGVHV.mjs.map +1 -0
  12. package/dist/{chunk-VCPIWJ7R.mjs → chunk-ERDTHXQA.mjs} +21 -3
  13. package/dist/chunk-ERDTHXQA.mjs.map +1 -0
  14. package/dist/chunk-OYSZQVPY.mjs +10 -0
  15. package/dist/chunk-OYSZQVPY.mjs.map +1 -0
  16. package/dist/{chunk-QJF4QCBJ.mjs → chunk-WKTH3JPA.mjs} +202 -36
  17. package/dist/chunk-WKTH3JPA.mjs.map +1 -0
  18. package/dist/{conceptEscalator-YfMKIKCZ.d.ts → conceptEscalator-BCsw0ys_.d.ts} +10 -1
  19. package/dist/{conceptEscalator-ltRLtPhf.d.cts → conceptEscalator-DPrs4PwC.d.cts} +10 -1
  20. package/dist/concepts/index.d.cts +1 -1
  21. package/dist/concepts/index.d.ts +1 -1
  22. package/dist/{curriculumFeedSchema-B66H3gEV.d.ts → curriculumFeedSchema-DYayelAA.d.cts} +212 -1
  23. package/dist/{curriculumFeedSchema-B66H3gEV.d.cts → curriculumFeedSchema-DYayelAA.d.ts} +212 -1
  24. package/dist/detector/index.cjs +21 -6
  25. package/dist/detector/index.cjs.map +1 -1
  26. package/dist/detector/index.mjs +2 -1
  27. package/dist/feed/index.cjs +243 -33
  28. package/dist/feed/index.cjs.map +1 -1
  29. package/dist/feed/index.d.cts +32 -2
  30. package/dist/feed/index.d.ts +32 -2
  31. package/dist/feed/index.mjs +2 -2
  32. package/dist/graph/index.cjs +163 -28
  33. package/dist/graph/index.cjs.map +1 -1
  34. package/dist/graph/index.d.cts +14 -5
  35. package/dist/graph/index.d.ts +14 -5
  36. package/dist/graph/index.mjs +2 -1
  37. package/dist/{hybridGraphPipeline-DXQDbBEj.d.ts → hybridGraphPipeline-9dRKJj7r.d.ts} +6 -2
  38. package/dist/{hybridGraphPipeline-dT-bio-3.d.cts → hybridGraphPipeline-CocfYe4d.d.cts} +6 -2
  39. package/dist/{hybridGraphSchema-nrUeSwpU.d.ts → hybridGraphSchema-C37qxdTM.d.cts} +16 -3
  40. package/dist/{hybridGraphSchema-nrUeSwpU.d.cts → hybridGraphSchema-C37qxdTM.d.ts} +16 -3
  41. package/dist/index.cjs +3742 -3362
  42. package/dist/index.cjs.map +1 -1
  43. package/dist/index.d.cts +6 -6
  44. package/dist/index.d.ts +6 -6
  45. package/dist/index.mjs +7 -6
  46. package/dist/llmClient-CX5uUiQ1.d.cts +60 -0
  47. package/dist/llmClient-CX5uUiQ1.d.ts +60 -0
  48. package/dist/pipeline/index.cjs +1556 -1202
  49. package/dist/pipeline/index.cjs.map +1 -1
  50. package/dist/pipeline/index.d.cts +13 -5
  51. package/dist/pipeline/index.d.ts +13 -5
  52. package/dist/pipeline/index.mjs +6 -5
  53. package/dist/schemas/index.cjs +64 -2
  54. package/dist/schemas/index.cjs.map +1 -1
  55. package/dist/schemas/index.d.cts +2 -2
  56. package/dist/schemas/index.d.ts +2 -2
  57. package/dist/schemas/index.mjs +2 -2
  58. package/package.json +1 -1
  59. package/dist/chunk-BL5NBKPH.mjs.map +0 -1
  60. package/dist/chunk-HILSUZOF.mjs.map +0 -1
  61. package/dist/chunk-LZQLLAYP.mjs.map +0 -1
  62. package/dist/chunk-PKUEOSZ3.mjs.map +0 -1
  63. package/dist/chunk-QJF4QCBJ.mjs.map +0 -1
  64. package/dist/chunk-VCPIWJ7R.mjs.map +0 -1
  65. package/dist/curriculumFeedEmitter-6IK6PFDH.mjs +0 -4
  66. package/dist/curriculumFeedEmitter-6IK6PFDH.mjs.map +0 -1
  67. package/dist/llmClient-ysPhLjcH.d.cts +0 -16
  68. package/dist/llmClient-ysPhLjcH.d.ts +0 -16
@@ -1,936 +1,316 @@
1
1
  'use strict';
2
2
 
3
- var zod = require('zod');
4
3
  var fs = require('fs');
5
4
  var path = require('path');
6
5
  var child_process = require('child_process');
7
6
  var os = require('os');
7
+ var zod = require('zod');
8
8
 
9
- var __defProp = Object.defineProperty;
10
- var __getOwnPropNames = Object.getOwnPropertyNames;
11
- var __esm = (fn, res) => function __init() {
12
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
13
- };
14
- var __export = (target, all) => {
15
- for (var name in all)
16
- __defProp(target, name, { get: all[name], enumerable: true });
17
- };
18
- function validateCurriculumFeed(feed) {
19
- const parsed = CurriculumFeedSchema.parse(feed);
20
- const ids = new Set(parsed.learning_nodes.map((n) => n.id));
21
- if (ids.size !== parsed.learning_nodes.length) {
22
- throw new Error("curriculum feed contains duplicate node ids");
23
- }
24
- for (const e of parsed.dependency_edges) {
25
- if (!ids.has(e.from) || !ids.has(e.to)) {
26
- throw new Error("curriculum feed edge " + e.from + " -> " + e.to + " references unknown node");
27
- }
28
- if (e.from === e.to) throw new Error("curriculum feed self-loop edge on " + e.from);
9
+ // src/parsers/swift.ts
10
+ var IMPORT_RE = /^import\s+(\w+)/gm;
11
+ var TYPE_RE = /\b(class|struct|enum|protocol)\s+(\w+)(?:\s*:\s*([^{]+))?/g;
12
+ var FUNC_RE = /\bfunc\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*([^{]+))?/g;
13
+ var WRAPPER_RE = /@(State|Binding|ObservedObject|StateObject|Published|Environment|EnvironmentObject|AppStorage|SceneStorage|FocusState|Observable)\b/g;
14
+ var FRAMEWORK_USAGE_RE = /\b(URLSession|URLRequest|JSONSerialization|JSONDecoder|JSONEncoder|Task|DispatchQueue|Timer|NotificationCenter|UserDefaults|FileManager|Bundle)\b/g;
15
+ function parseSwiftFile(filepath, content) {
16
+ const result = {
17
+ imports: [],
18
+ types: [],
19
+ functions: [],
20
+ property_wrappers: [],
21
+ error_handling: [],
22
+ frameworks_used: []
23
+ };
24
+ let match;
25
+ while ((match = IMPORT_RE.exec(content)) !== null) {
26
+ result.imports.push(match[1]);
29
27
  }
30
- for (const g of parsed.suggested_groupings) {
31
- for (const id of g.node_ids) {
32
- if (!ids.has(id)) throw new Error("grouping " + g.id + " references unknown node " + id);
28
+ const fwSeen = /* @__PURE__ */ new Set();
29
+ while ((match = FRAMEWORK_USAGE_RE.exec(content)) !== null) {
30
+ if (!fwSeen.has(match[1])) {
31
+ fwSeen.add(match[1]);
32
+ result.frameworks_used.push(match[1]);
33
33
  }
34
34
  }
35
- for (const ph of parsed.phases) {
36
- for (const id of ph.node_ids) {
37
- if (!ids.has(id)) throw new Error("phase " + ph.id + " references unknown node " + id);
38
- }
35
+ while ((match = TYPE_RE.exec(content)) !== null) {
36
+ const conforms = (match[3] || "").split(",").map((p) => p.trim()).filter(Boolean);
37
+ result.types.push({
38
+ kind: match[1],
39
+ name: match[2],
40
+ conforms_to: conforms,
41
+ file: filepath
42
+ });
39
43
  }
40
- const phaseIds = parsed.phases.map((p) => p.id);
41
- if (new Set(phaseIds).size !== phaseIds.length) throw new Error("curriculum feed contains duplicate phase ids");
42
- if (parsed.phases.length > 0) {
43
- const phaseIndexOf = /* @__PURE__ */ new Map();
44
- parsed.phases.forEach((p, i) => {
45
- for (const id of p.node_ids) phaseIndexOf.set(id, i);
44
+ while ((match = FUNC_RE.exec(content)) !== null) {
45
+ const name = match[1];
46
+ if (name.startsWith("_")) continue;
47
+ result.functions.push({
48
+ name,
49
+ params: (match[2] || "").trim(),
50
+ returns: (match[3] || "").trim(),
51
+ file: filepath
46
52
  });
47
- for (const e of parsed.dependency_edges) {
48
- const fromIdx = phaseIndexOf.get(e.from);
49
- const toIdx = phaseIndexOf.get(e.to);
50
- if (fromIdx === void 0 || toIdx === void 0) continue;
51
- if (fromIdx > toIdx) {
52
- throw new Error(
53
- "curriculum feed teaching-order violation: prerequisite " + e.from + " (phase " + (fromIdx + 1) + ") is scheduled AFTER its consumer " + e.to + " (phase " + (toIdx + 1) + ")"
54
- );
55
- }
53
+ }
54
+ const wrapperSet = /* @__PURE__ */ new Set();
55
+ while ((match = WRAPPER_RE.exec(content)) !== null) {
56
+ wrapperSet.add(match[1]);
57
+ }
58
+ result.property_wrappers = Array.from(wrapperSet);
59
+ if (content.includes("do {") || content.includes("do{")) {
60
+ result.error_handling.push("do-catch");
61
+ }
62
+ if (content.includes("throws")) {
63
+ result.error_handling.push("throws");
64
+ }
65
+ if (content.includes("Result<")) {
66
+ result.error_handling.push("Result type");
67
+ }
68
+ if (content.includes("try?")) {
69
+ result.error_handling.push("try?");
70
+ }
71
+ if (content.includes("try!")) {
72
+ result.error_handling.push("try!");
73
+ }
74
+ return result;
75
+ }
76
+ function mergeSwiftResults(results) {
77
+ const merged = {
78
+ imports: [],
79
+ types: [],
80
+ functions: [],
81
+ property_wrappers: [],
82
+ error_handling: [],
83
+ frameworks_used: []
84
+ };
85
+ const importCounts = /* @__PURE__ */ new Map();
86
+ const wrapperCounts = /* @__PURE__ */ new Map();
87
+ const errorCounts = /* @__PURE__ */ new Map();
88
+ const frameworkSet = /* @__PURE__ */ new Set();
89
+ for (const r of results) {
90
+ for (const imp of r.imports) {
91
+ importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
92
+ }
93
+ merged.types.push(...r.types);
94
+ merged.functions.push(...r.functions);
95
+ for (const w of r.property_wrappers) {
96
+ wrapperCounts.set(w, (wrapperCounts.get(w) || 0) + 1);
97
+ }
98
+ for (const e of r.error_handling) {
99
+ errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
100
+ }
101
+ for (const fw of r.frameworks_used) {
102
+ frameworkSet.add(fw);
56
103
  }
57
104
  }
58
- return parsed;
105
+ merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod, count]) => mod);
106
+ merged.property_wrappers = Array.from(wrapperCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([w]) => w);
107
+ merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
108
+ merged.frameworks_used = Array.from(frameworkSet).sort();
109
+ merged.types = merged.types.slice(0, 100);
110
+ merged.functions = merged.functions.slice(0, 100);
111
+ return merged;
59
112
  }
60
- var FeedKeywordsSchema, FeedScaffoldCandidateSchema, FeedNodeSchema, FeedEdgeSchema, FeedGroupingSchema, FeedPhaseSchema, CurriculumFeedSchema;
61
- var init_curriculumFeedSchema = __esm({
62
- "src/schemas/curriculumFeedSchema.ts"() {
63
- FeedKeywordsSchema = zod.z.object({
64
- all: zod.z.array(zod.z.string()).default([]),
65
- new: zod.z.array(zod.z.string()).default([]),
66
- prerequisite: zod.z.array(zod.z.string()).default([])
67
- });
68
- FeedScaffoldCandidateSchema = zod.z.object({
69
- item: zod.z.string(),
70
- reason: zod.z.string().default(""),
71
- minutes_saved: zod.z.number().int().nonnegative().default(0),
72
- suggest: zod.z.enum(["provide_full", "provide_partial", "provide_reference"]).default("provide_full")
73
- });
74
- FeedNodeSchema = zod.z.object({
75
- id: zod.z.string().min(1),
76
- kind: zod.z.enum(["skill", "concept", "product_step"]),
77
- name: zod.z.string().min(1),
78
- concept_codes: zod.z.array(zod.z.string()).default([]),
79
- keywords: FeedKeywordsSchema,
80
- estimated_minutes: zod.z.number().int().nonnegative(),
81
- bloom_hint: zod.z.enum(["Remember", "Understand", "Apply", "Analyze", "Evaluate", "Create"]).nullable().default(null),
82
- depth_hint: zod.z.enum(["ulo", "cio", "sio"]).nullable().default(null),
83
- completion_level: zod.z.enum(["mvp", "standard", "stretch"]).default("standard"),
84
- scaffolding: zod.z.object({
85
- scaffold_candidates: zod.z.array(FeedScaffoldCandidateSchema).default([]),
86
- core_value_minutes: zod.z.number().int().nonnegative().default(0)
87
- }).default({ scaffold_candidates: [], core_value_minutes: 0 }),
88
- references: zod.z.array(zod.z.object({ file: zod.z.string().min(1), evidence: zod.z.string().default("") })).default([]),
89
- feature_id: zod.z.string().nullable().default(null),
90
- concept_ref: zod.z.string().nullable().default(null),
91
- // P52/T2.1: outcome.user_visible projected from the ProjectGraph step — the
92
- // tangible checkpoint the session-cutting planner aims for (empty when the
93
- // step declares no user-visible result). Concepts carry '' by default.
94
- user_visible_deliverable: zod.z.string().default(""),
95
- // P52/T3.1 — depth-reconcile inputs (optional/defaulted so old feeds
96
- // validate unchanged):
97
- // • depth_variants: minutes to teach this node at each depth level.
98
- // ULO (know-it-exists) ≤ CIO (mechanism) ≤ SIO (implementation). All 3
99
- // required so a downgrade never turns an anchor teach into a zero.
100
- // • depth_scaffold_candidates: parallel to scaffold_candidates but
101
- // acts on DEPTH (e.g. SIO→CIO) instead of lesson time.
102
- // • is_core: concept is a Master-Tree core concept — reconciler must
103
- // never downgrade it; escalate instead.
104
- depth_variants: zod.z.object({ ulo: zod.z.number().int().nonnegative(), cio: zod.z.number().int().nonnegative(), sio: zod.z.number().int().nonnegative() }).optional(),
105
- depth_scaffold_candidates: zod.z.array(zod.z.object({
106
- from_depth: zod.z.enum(["ulo", "cio", "sio"]),
107
- to_depth: zod.z.enum(["ulo", "cio", "sio"]),
108
- minutes_saved: zod.z.number().int().nonnegative(),
109
- reason: zod.z.string().default("")
110
- })).optional(),
111
- is_core: zod.z.boolean().default(false),
112
- // Progressive-completion bookkeeping (feed v2, additive):
113
- // phase_id = development phase that introduces/uses this node;
114
- // introduce_aspect = how the phase teaches a concept node (first intro, advanced
115
- // revisit, or preview — a light ULO awareness pass that is NOT the introduction).
116
- phase_id: zod.z.string().default(""),
117
- introduce_aspect: zod.z.enum(["new", "advanced", "preview"]).nullable().default(null)
118
- });
119
- FeedEdgeSchema = zod.z.object({
120
- from: zod.z.string().min(1),
121
- to: zod.z.string().min(1),
122
- kind: zod.z.enum(["knowledge", "task"]),
123
- reason: zod.z.string().default("")
124
- });
125
- FeedGroupingSchema = zod.z.object({
126
- id: zod.z.string().min(1),
127
- name: zod.z.string().min(1),
128
- node_ids: zod.z.array(zod.z.string()).min(1),
129
- rationale: zod.z.string().default("")
113
+
114
+ // src/parsers/typescript.ts
115
+ var IMPORT_RE2 = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
116
+ var TYPE_RE2 = /\b(interface|type|class|enum)\s+(\w+)/g;
117
+ var FUNC_RE2 = /\bfunction\s+(\w+)\s*\(([^)]*)\)/g;
118
+ var ARROW_RE = /\bconst\s+(\w+)\s*=\s*\([^)]*\)\s*(?::\s*\w+)?\s*=>/g;
119
+ function parseTsFile(filepath, content) {
120
+ const result = {
121
+ imports: [],
122
+ types: [],
123
+ functions: [],
124
+ error_handling: []
125
+ };
126
+ let match;
127
+ while ((match = IMPORT_RE2.exec(content)) !== null) {
128
+ result.imports.push(match[1]);
129
+ }
130
+ while ((match = TYPE_RE2.exec(content)) !== null) {
131
+ result.types.push({
132
+ kind: match[1],
133
+ name: match[2],
134
+ file: filepath
130
135
  });
131
- FeedPhaseSchema = zod.z.object({
132
- id: zod.z.string().min(1),
133
- order: zod.z.number().int().positive(),
134
- name: zod.z.string().min(1),
135
- product_completion: zod.z.string().min(1),
136
- node_ids: zod.z.array(zod.z.string()).min(1)
136
+ }
137
+ while ((match = FUNC_RE2.exec(content)) !== null) {
138
+ result.functions.push({
139
+ name: match[1],
140
+ params: (match[2] || "").trim(),
141
+ file: filepath
137
142
  });
138
- CurriculumFeedSchema = zod.z.object({
139
- schema_version: zod.z.literal(2),
140
- source: zod.z.object({
141
- graph_type: zod.z.enum(["project_graph", "knowledge_graph", "hybrid_graph"]),
142
- warnings: zod.z.array(zod.z.string()).default([]),
143
- hallucination_count: zod.z.number().int().nonnegative().default(0)
144
- }),
145
- learning_nodes: zod.z.array(FeedNodeSchema).min(1, "curriculum feed must contain at least one learning node"),
146
- dependency_edges: zod.z.array(FeedEdgeSchema).default([]),
147
- suggested_groupings: zod.z.array(FeedGroupingSchema).default([]),
148
- // Progressive completion phases (cross-feature). When non-empty, these — not
149
- // suggested_groupings — define the teaching sequence and unit structure.
150
- phases: zod.z.array(FeedPhaseSchema).default([])
143
+ }
144
+ while ((match = ARROW_RE.exec(content)) !== null) {
145
+ result.functions.push({
146
+ name: match[1],
147
+ params: "",
148
+ file: filepath
151
149
  });
152
150
  }
153
- });
154
-
155
- // src/feed/curriculumFeedEmitter.ts
156
- var curriculumFeedEmitter_exports = {};
157
- __export(curriculumFeedEmitter_exports, {
158
- emitCurriculumFeed: () => emitCurriculumFeed
159
- });
160
- function stepMinutes(step) {
161
- const effort = step.effort ?? {};
162
- return Math.max(0, Math.round(num(effort.estimated_minutes, num(step.estimated_minutes, 45))));
163
- }
164
- function pushWarning(opts, message) {
165
- (opts.warnings ?? (opts.warnings = [])).push(message);
151
+ if (content.includes("try {") || content.includes("try{")) {
152
+ result.error_handling.push("try-catch");
153
+ }
154
+ if (content.includes("Promise")) {
155
+ result.error_handling.push("Promise");
156
+ }
157
+ if (content.includes(".catch(")) {
158
+ result.error_handling.push(".catch()");
159
+ }
160
+ return result;
166
161
  }
167
- function newKeywordsInOrder(nodes) {
168
- const seen = /* @__PURE__ */ new Set();
169
- for (const n of nodes) {
170
- const all = n.keywords.all;
171
- n.keywords.new = [];
172
- n.keywords.prerequisite = [];
173
- for (const k of all) {
174
- if (seen.has(k)) n.keywords.prerequisite.push(k);
175
- else n.keywords.new.push(k);
176
- seen.add(k);
162
+ function mergeTsResults(results) {
163
+ const merged = {
164
+ imports: [],
165
+ types: [],
166
+ functions: [],
167
+ error_handling: []
168
+ };
169
+ const importCounts = /* @__PURE__ */ new Map();
170
+ const errorCounts = /* @__PURE__ */ new Map();
171
+ for (const r of results) {
172
+ for (const imp of r.imports) {
173
+ importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
174
+ }
175
+ merged.types.push(...r.types);
176
+ merged.functions.push(...r.functions);
177
+ for (const e of r.error_handling) {
178
+ errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
177
179
  }
178
180
  }
181
+ merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
182
+ merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
183
+ merged.types = merged.types.slice(0, 100);
184
+ merged.functions = merged.functions.slice(0, 100);
185
+ return merged;
179
186
  }
180
- function feedNodeFromStep(step, featureId) {
181
- const id = str(step.id);
182
- return {
183
- id,
184
- kind: "product_step",
185
- name: str(step.name, id),
186
- concept_codes: strs(step.concept_codes),
187
- keywords: { all: strs(step.keywords), new: [], prerequisite: [] },
188
- estimated_minutes: stepMinutes(step),
189
- bloom_hint: "Apply",
190
- depth_hint: null,
191
- completion_level: "standard",
192
- scaffolding: { scaffold_candidates: [], core_value_minutes: stepMinutes(step) },
193
- references: arr(step.files).map((f) => ({ file: str(f), evidence: "step file" })).filter((f) => f.file),
194
- feature_id: featureId,
195
- concept_ref: null,
196
- // P52/T2.1: carry the step's tangible outcome so the planner can cut
197
- // sessions at user-visible checkpoints instead of raw minute counts.
198
- user_visible_deliverable: str(step.outcome?.user_visible),
199
- is_core: false,
200
- phase_id: "",
201
- introduce_aspect: null
187
+
188
+ // src/parsers/python.ts
189
+ var IMPORT_RE3 = /^(?:import|from)\s+(\w+)/gm;
190
+ var CLASS_RE = /\bclass\s+(\w+)(?:\s*\(([^)]+)\))?/g;
191
+ var FUNC_RE3 = /\bdef\s+(\w+)\s*\(([^)]*)\)/g;
192
+ var DOCSTRING_RE = /"""([^"]{20,300})"""/g;
193
+ function parsePythonFile(filepath, content) {
194
+ const result = {
195
+ imports: [],
196
+ types: [],
197
+ functions: [],
198
+ docstrings: [],
199
+ error_handling: []
202
200
  };
203
- }
204
- function deriveConceptDepthHint(concept) {
205
- const explicit = str(concept.depth_hint);
206
- if (explicit === "ulo" || explicit === "cio" || explicit === "sio") return explicit;
207
- const sio = arr(concept.sio);
208
- const prereqs = strs(concept.prerequisites);
209
- const pts = arr(concept.problem_types);
210
- const hasHighBloom = pts.some((p) => {
211
- const b = str(p.bloom_level);
212
- return b === "Apply" || b === "Analyze" || b === "Evaluate" || b === "Create";
213
- });
214
- if (sio.length >= 2 || hasHighBloom) return "sio";
215
- if (str(concept.cio) || prereqs.length >= 1) return "cio";
216
- return "ulo";
217
- }
218
- function deriveDepthInputs(minutes, depth) {
219
- const ulo = Math.max(5, Math.round(minutes * 0.35));
220
- const cio = Math.max(ulo + 5, Math.round(minutes * 0.65));
221
- const sio = Math.max(cio + 5, minutes);
222
- const variants = { ulo, cio, sio };
223
- const candidates = [];
224
- if (depth === "sio") {
225
- candidates.push({ from_depth: "sio", to_depth: "cio", minutes_saved: sio - cio, reason: "SIO implementation practice deferred \u2014 CIO mechanism suffices for downstream consumers" });
226
- }
227
- if (depth === "cio" || depth === "sio") {
228
- candidates.push({ from_depth: depth, to_depth: "ulo", minutes_saved: (depth === "sio" ? sio : cio) - ulo, reason: "Mechanism deferred \u2014 ULO awareness suffices (know-it-exists); never below the ULO floor" });
201
+ let match;
202
+ while ((match = IMPORT_RE3.exec(content)) !== null) {
203
+ result.imports.push(match[1]);
229
204
  }
230
- return { depth_variants: variants, depth_scaffold_candidates: candidates };
231
- }
232
- function feedNodeFromConcept(concept) {
233
- const id = str(concept.id);
234
- const pts = arr(concept.problem_types).map((p) => str(p.bloom_level, "Understand")).filter((b) => b in BLOOM_RANK).sort((a, b) => BLOOM_RANK[a] - BLOOM_RANK[b]);
235
- const topBloom = pts[pts.length - 1] ?? "Understand";
236
- const rawRefs = arr(concept.references);
237
- const references = rawRefs.length > 0 ? rawRefs.map((r) => ({ file: str(r.file, "syllabus.md"), evidence: str(r.evidence, str(concept.name)) })).filter((r) => r.file) : [{ file: "syllabus.md", evidence: str(concept.category ? `Topic under category ${str(concept.category)}` : str(concept.name), id) }];
238
- const problemScaffolds = arr(concept.problem_types).map((pt) => ({
239
- item: str(pt.name, "Problem type"),
240
- reason: str(pt.description, "Formative practice check"),
241
- minutes_saved: num(pt.estimated_minutes, 10),
242
- suggest: "provide_partial"
243
- }));
244
- return {
245
- id,
246
- kind: "concept",
247
- name: str(concept.name, id),
248
- concept_codes: [str(concept.code) || id].filter(Boolean),
249
- keywords: { all: strs(concept.keywords), new: [], prerequisite: [] },
250
- estimated_minutes: Math.max(0, Math.round(num(concept.estimated_minutes, 15))),
251
- bloom_hint: topBloom,
252
- depth_hint: deriveConceptDepthHint(concept),
253
- completion_level: "standard",
254
- scaffolding: {
255
- scaffold_candidates: problemScaffolds,
256
- core_value_minutes: Math.max(0, Math.round(num(concept.estimated_minutes, 15)))
257
- },
258
- references,
259
- feature_id: null,
260
- concept_ref: id,
261
- user_visible_deliverable: "",
262
- // concepts are taught, not shipped — checkpoints live on product steps
263
- ...deriveDepthInputs(Math.max(0, Math.round(num(concept.estimated_minutes, 15))), deriveConceptDepthHint(concept)),
264
- is_core: false,
265
- phase_id: "",
266
- introduce_aspect: null
267
- };
268
- }
269
- function addFeatureGroup(f, fid, nodes, edges, groupings) {
270
- const steps = arr(f.steps);
271
- const stepIds = [];
272
- let prev = "";
273
- for (let i = 0; i < steps.length; i++) {
274
- const stepNode = feedNodeFromStep(steps[i], fid);
275
- if (!stepNode.id) continue;
276
- stepIds.push(stepNode.id);
277
- nodes.push(stepNode);
278
- if (prev) edges.push({ from: prev, to: stepNode.id, kind: "task", reason: "feature build order" });
279
- prev = stepNode.id;
205
+ while ((match = DOCSTRING_RE.exec(content)) !== null) {
206
+ const firstSentence = match[1].trim().split("\n")[0].slice(0, 120);
207
+ if (firstSentence.length > 15) {
208
+ result.docstrings.push(firstSentence);
209
+ }
280
210
  }
281
- for (const dep of strs(f.depends_on)) {
282
- const depFirst = nodes.find((n) => n.feature_id === dep);
283
- if (depFirst && stepIds[0]) edges.push({ from: depFirst.id, to: stepIds[0], kind: "task", reason: "feature depends_on " + dep });
211
+ while ((match = CLASS_RE.exec(content)) !== null) {
212
+ const bases = (match[2] || "").split(",").map((b) => b.trim()).filter(Boolean);
213
+ result.types.push({
214
+ kind: "class",
215
+ name: match[1],
216
+ bases,
217
+ file: filepath
218
+ });
284
219
  }
285
- if (stepIds.length > 0) groupings.push({ id: "G-" + fid, name: str(f.name, fid), node_ids: stepIds, rationale: str(f.description, "") });
286
- return stepIds;
287
- }
288
- function emitFromHybrid(hybrid, opts) {
289
- const nodes = [];
290
- const edges = [];
291
- const groupings = [];
292
- const pg = hybrid.project_graph ?? {};
293
- const kg = hybrid.knowledge_graph ?? {};
294
- const rawPhases = arr(hybrid.phases).filter((p) => str(p.id));
295
- const features = arr(pg.features);
296
- const conceptRecs = arr(kg.concepts);
297
- const conceptById = new Map(conceptRecs.map((c) => [str(c.id), c]));
298
- if (rawPhases.length === 0) {
299
- for (const c of conceptRecs) {
300
- const node = feedNodeFromConcept(c);
301
- nodes.push(node);
302
- for (const p of strs(c.prerequisites)) edges.push({ from: p, to: node.id, kind: "knowledge", reason: "concept prerequisite" });
303
- }
304
- features.forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad22(fi + 1)), nodes, edges, groupings));
305
- linkHybridKnowledgeEdges(hybrid, nodes, edges);
306
- newKeywordsInOrder(nodes);
307
- return validateCurriculumFeed({
308
- schema_version: 2,
309
- source: { graph_type: "hybrid_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
310
- learning_nodes: nodes,
311
- dependency_edges: edges,
312
- suggested_groupings: groupings,
313
- phases: []
220
+ while ((match = FUNC_RE3.exec(content)) !== null) {
221
+ const name = match[1];
222
+ if (name.startsWith("__") && name.endsWith("__")) continue;
223
+ result.functions.push({
224
+ name,
225
+ params: (match[2] || "").trim(),
226
+ file: filepath
314
227
  });
315
228
  }
316
- const featureOrder = /* @__PURE__ */ new Map();
317
- features.forEach((f, fi) => featureOrder.set(str(f.id, "F" + pad22(fi + 1)), fi));
318
- const stepIdsByFeature = /* @__PURE__ */ new Map();
319
- for (const f of features) {
320
- const fid = str(f.id);
321
- stepIdsByFeature.set(fid, arr(f.steps).map((s) => str(s.id)).filter(Boolean));
229
+ if (content.includes("try:")) {
230
+ result.error_handling.push("try-except");
322
231
  }
323
- const emitCtx = [];
324
- const emittedConcepts = /* @__PURE__ */ new Set();
325
- const previewsEmitted = /* @__PURE__ */ new Set();
326
- const linkList = arr(hybrid.links);
327
- for (const rp of rawPhases) {
328
- const pid = str(rp.id);
329
- const order = num(rp.order, emitCtx.length + 1);
330
- const conceptOrder = [];
331
- const intros = arr(rp.introduces);
332
- const news = intros.filter((i) => str(i.aspect, "new") === "new");
333
- const advanced = intros.filter((i) => str(i.aspect) === "advanced");
334
- const previews = intros.filter((i) => str(i.aspect) === "preview");
335
- for (const intro of [...news, ...advanced, ...previews]) {
336
- const cid = str(intro.concept_id);
337
- const c = conceptById.get(cid);
338
- if (!c) {
339
- throw new Error("Phase " + pid + " introduces unknown concept " + cid + " - fail-closed");
340
- }
341
- if (str(intro.aspect, "new") === "advanced" && !emittedConcepts.has(cid)) {
342
- throw new Error("Phase " + pid + " advanced-revisits concept " + cid + " that was never emitted - fail-closed");
343
- }
344
- if (str(intro.aspect, "new") === "preview") {
345
- if (emittedConcepts.has(cid)) {
346
- throw new Error("Phase " + pid + " previews concept " + cid + " that was already introduced - preview must precede the anchor teach - fail-closed");
347
- }
348
- if (previewsEmitted.has(cid)) {
349
- throw new Error("Phase " + pid + " duplicate preview of concept " + cid + " - fail-closed");
350
- }
351
- const pidIdx = rawPhases.findIndex((rp2) => str(rp2.id) === pid);
352
- const anchorIdx = rawPhases.findIndex((rp2) => arr(rp2.introduces).some((i2) => str(i2.concept_id) === cid && str(i2.aspect, "new") !== "preview"));
353
- if (anchorIdx < 0) {
354
- throw new Error("Phase " + pid + " previews concept " + cid + " that is never fully introduced in any phase - fail-closed");
355
- }
356
- if (pidIdx >= anchorIdx) {
357
- throw new Error("Phase " + pid + " previews concept " + cid + " whose anchor introduction is not in a LATER phase - fail-closed");
358
- }
359
- previewsEmitted.add(cid);
360
- const prevNode = feedNodeFromConcept(c);
361
- prevNode.id = cid + "__PREV" + pad22(order);
362
- prevNode.name = str(c.name, cid) + " (preview)";
363
- prevNode.phase_id = pid;
364
- prevNode.introduce_aspect = "preview";
365
- prevNode.estimated_minutes = Math.max(5, Math.round(prevNode.estimated_minutes * 0.25));
366
- prevNode.depth_hint = "ulo";
367
- prevNode.bloom_hint = "Understand";
368
- prevNode.concept_codes = [str(c.code) || cid];
369
- prevNode.keywords = { all: strs(c.keywords), new: [], prerequisite: [] };
370
- prevNode.scaffolding = { scaffold_candidates: [], core_value_minutes: 0 };
371
- prevNode.depth_variants = { ulo: prevNode.estimated_minutes, cio: prevNode.estimated_minutes, sio: prevNode.estimated_minutes };
372
- prevNode.depth_scaffold_candidates = [];
373
- nodes.push(prevNode);
374
- conceptOrder.push(prevNode.id);
375
- continue;
376
- }
377
- if (!emittedConcepts.has(cid)) {
378
- const node = feedNodeFromConcept(c);
379
- node.phase_id = pid;
380
- node.introduce_aspect = "new";
381
- nodes.push(node);
382
- for (const p of strs(c.prerequisites)) {
383
- if (!emittedConcepts.has(p)) {
384
- throw new Error("Concept " + cid + " (phase " + pid + ") has prerequisite " + p + " that is not introduced in an earlier phase - fail-closed");
385
- }
386
- edges.push({ from: p, to: cid, kind: "knowledge", reason: "concept prerequisite" });
387
- }
388
- emittedConcepts.add(cid);
389
- conceptOrder.push(cid);
390
- } else {
391
- const revisitId = cid + "__ADV" + pad22(order);
392
- const node = feedNodeFromConcept(c);
393
- node.id = revisitId;
394
- node.name = str(c.name, cid) + " (advanced)";
395
- node.phase_id = pid;
396
- node.introduce_aspect = "advanced";
397
- node.depth_scaffold_candidates = [{ from_depth: "sio", to_depth: "cio", minutes_saved: Math.round(node.estimated_minutes * 0.3), reason: "advanced revisit may drop to CIO recap without breaking first-teach chain" }];
398
- node.estimated_minutes = Math.max(15, Math.round(node.estimated_minutes * 0.5));
399
- node.concept_codes = [str(c.code) || cid];
400
- edges.push({ from: cid, to: revisitId, kind: "knowledge", reason: "advanced revisit of " + cid + " at phase " + pid });
401
- nodes.push(node);
402
- conceptOrder.push(revisitId);
403
- }
404
- }
405
- const featureIds = strs(rp.feature_ids).filter((fid) => featureOrder.has(fid)).sort((a, b) => featureOrder.get(a) - featureOrder.get(b));
406
- emitCtx.push({ id: pid, order, name: str(rp.name, pid), productCompletion: str(rp.product_completion), conceptOrder, featureIds });
232
+ if (content.includes("raise")) {
233
+ result.error_handling.push("raise");
407
234
  }
408
- for (const l of linkList) {
409
- const cid = str(l.concept_id);
410
- if (cid && conceptById.has(cid) && !emittedConcepts.has(cid)) {
411
- throw new Error("Concept " + cid + " is consumed by links but never introduced in any phase - fail-closed");
235
+ return result;
236
+ }
237
+ function mergePythonResults(results) {
238
+ const merged = {
239
+ imports: [],
240
+ types: [],
241
+ functions: [],
242
+ docstrings: [],
243
+ error_handling: []
244
+ };
245
+ const importCounts = /* @__PURE__ */ new Map();
246
+ const errorCounts = /* @__PURE__ */ new Map();
247
+ for (const r of results) {
248
+ for (const imp of r.imports) {
249
+ importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
412
250
  }
413
- }
414
- const seenFeatures = new Set(emitCtx.flatMap((e) => e.featureIds));
415
- const missingFeatures = features.map((f) => str(f.id)).filter((fid) => fid && !seenFeatures.has(fid));
416
- if (missingFeatures.length > 0) {
417
- throw new Error("Features not covered by any phase: " + missingFeatures.join(", ") + " - fail-closed");
418
- }
419
- const phaseNodeIds = [];
420
- for (const ctx of emitCtx) {
421
- const ids = [...ctx.conceptOrder];
422
- for (const fid of ctx.featureIds) {
423
- const f = features.find((x) => str(x.id) === fid);
424
- const stepIds = addFeatureGroup(f, fid, nodes, edges, groupings);
425
- for (const sid of stepIds) {
426
- const node = nodes.find((n) => n.id === sid);
427
- node.phase_id = ctx.id;
428
- }
429
- ids.push(...stepIds);
251
+ merged.types.push(...r.types);
252
+ merged.functions.push(...r.functions);
253
+ merged.docstrings.push(...r.docstrings);
254
+ for (const e of r.error_handling) {
255
+ errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
430
256
  }
431
- phaseNodeIds.push(ids);
432
257
  }
433
- const placed = new Set(phaseNodeIds.flat());
434
- const orderedNodes = [];
435
- for (const ids of phaseNodeIds) {
436
- for (const id of ids) {
437
- const n = nodes.find((x) => x.id === id);
438
- if (n) orderedNodes.push(n);
439
- }
440
- }
441
- for (const n of nodes) if (!placed.has(n.id)) orderedNodes.push(n);
442
- nodes.length = 0;
443
- nodes.push(...orderedNodes);
444
- const edgeSeen = new Set(edges.map((e) => e.from + ">" + e.to));
445
- const pushEdge = (from, to, reason) => {
446
- const key = from + ">" + to;
447
- if (edgeSeen.has(key)) return;
448
- edgeSeen.add(key);
449
- edges.push({ from, to, kind: "knowledge", reason });
450
- };
451
- for (const l of linkList) {
452
- const cid = str(l.concept_id);
453
- const fid = str(l.feature_id);
454
- if (!conceptById.has(cid)) continue;
455
- const firstStep = (stepIdsByFeature.get(fid) || [])[0];
456
- if (!firstStep) continue;
457
- const depth = str(l.depth, "cio");
458
- const anchorNode = nodes.find((n2) => n2.kind === "concept" && n2.concept_ref === cid && n2.introduce_aspect === "new");
459
- const stepNode = nodes.find((n2) => n2.id === firstStep);
460
- const anchorPhaseIdx = anchorNode ? emitCtx.findIndex((c2) => c2.id === anchorNode.phase_id) : -1;
461
- const stepPhaseIdx = stepNode ? emitCtx.findIndex((c2) => c2.id === stepNode.phase_id) : -1;
462
- if (emitCtx.length > 0 && anchorPhaseIdx > stepPhaseIdx) {
463
- pushWarning(opts, "hybrid link drift: concept " + cid + " is fully taught in a later phase than consumer " + firstStep + " (a preview covers the dependency)");
464
- continue;
465
- }
466
- pushEdge(cid, firstStep, str(l.relationship, "hybrid link") + " [" + depth + "]");
467
- for (const n of nodes) {
468
- if (n.kind === "concept" && n.concept_ref === cid && n.introduce_aspect === "advanced" && n.phase_id === str(l.phase_id)) {
469
- pushEdge(n.id, firstStep, "advanced application [" + depth + "]");
470
- }
471
- }
472
- }
473
- newKeywordsInOrder(nodes);
474
- const feedOrder = new Map(nodes.map((n, i) => [n.id, i]));
475
- const introducer = /* @__PURE__ */ new Map();
476
- for (const n of nodes) for (const k of n.keywords?.new ?? []) {
477
- const key = k.toLowerCase();
478
- if (!introducer.has(key)) introducer.set(key, n.id);
479
- }
480
- for (const n of nodes) {
481
- for (const k of n.keywords?.prerequisite ?? []) {
482
- const src = introducer.get(k.toLowerCase());
483
- if (!src || src === n.id) continue;
484
- const srcNode = nodes.find((x) => x.id === src);
485
- const phaseOf = (id) => {
486
- const n2 = nodes.find((x) => x.id === id);
487
- return n2 ? emitCtx.findIndex((c) => c.id === n2.phase_id) : -1;
488
- };
489
- const conceptOvertaken = n.kind === "concept" && srcNode?.kind === "product_step";
490
- const fromPhase = phaseOf(src);
491
- const toPhase = phaseOf(n.id);
492
- if (conceptOvertaken || emitCtx.length > 0 && fromPhase > toPhase) {
493
- pushWarning(
494
- opts,
495
- "keyword-order drift: introducer " + src + " sits in a later phase than consumer " + n.id + ' (keyword "' + k + '") \u2014 host should move the introducer earlier'
496
- );
497
- continue;
498
- }
499
- if (feedOrder.get(src) < feedOrder.get(n.id)) pushEdge(src, n.id, "keyword prerequisite [" + k + "]");
500
- }
501
- }
502
- const phases = emitCtx.map((ctx, i) => ({
503
- id: ctx.id,
504
- order: ctx.order,
505
- name: ctx.name,
506
- product_completion: ctx.productCompletion || "Phase " + (i + 1) + " deliverable",
507
- node_ids: phaseNodeIds[i]
508
- }));
509
- return validateCurriculumFeed({
510
- schema_version: 2,
511
- source: { graph_type: "hybrid_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
512
- learning_nodes: nodes,
513
- dependency_edges: edges,
514
- suggested_groupings: groupings,
515
- phases
516
- });
517
- }
518
- function linkHybridKnowledgeEdges(hybrid, nodes, edges, setStepHints) {
519
- const conceptIds = new Set(nodes.filter((n) => n.kind === "concept").map((n) => n.concept_ref || n.id));
520
- for (const l of arr(hybrid.links)) {
521
- const conceptId = str(l.concept_id);
522
- const featureId = str(l.feature_id);
523
- if (!conceptIds.has(conceptId)) continue;
524
- const depth = str(l.depth, "cio");
525
- const firstStep = nodes.find((n) => n.feature_id === featureId);
526
- if (firstStep) {
527
- edges.push({ from: conceptId, to: firstStep.id, kind: "knowledge", reason: str(l.relationship, "hybrid link") + " [" + depth + "]" });
528
- if (!firstStep.depth_hint) {
529
- firstStep.depth_hint = ["ulo", "cio", "sio"].includes(depth) ? depth : "cio";
530
- }
531
- }
532
- }
533
- }
534
- function emitFromKnowledge(kg, opts) {
535
- const nodes = [];
536
- const edges = [];
537
- const groupings = [];
538
- const conceptIds = new Set(arr(kg.concepts).map((c) => str(c.id)));
539
- for (const c of arr(kg.concepts)) {
540
- const node = feedNodeFromConcept(c);
541
- nodes.push(node);
542
- for (const p of strs(c.prerequisites)) {
543
- if (p !== node.id && conceptIds.has(p)) {
544
- edges.push({
545
- from: p,
546
- to: node.id,
547
- kind: "knowledge",
548
- reason: `concept prerequisite: ${p} must be understood before ${node.id}`
549
- });
550
- }
551
- }
552
- }
553
- if (arr(kg.learning_path).length > 0) {
554
- const orderMap = /* @__PURE__ */ new Map();
555
- arr(kg.learning_path).forEach((s, idx) => {
556
- const cid = str(s.concept_id);
557
- if (cid) orderMap.set(cid, num(s.order, idx + 1));
558
- });
559
- nodes.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
560
- }
561
- let ci = 0;
562
- for (const cat of arr(kg.categories)) {
563
- ci += 1;
564
- const ids = strs(cat.concept_ids).filter((id) => nodes.some((n) => n.id === id));
565
- if (ids.length > 0) {
566
- groupings.push({
567
- id: str(cat.id, "cat" + ci),
568
- name: str(cat.name, "Category " + ci),
569
- node_ids: ids,
570
- rationale: str(cat.description, "")
571
- });
572
- }
573
- }
574
- newKeywordsInOrder(nodes);
575
- return validateCurriculumFeed({
576
- schema_version: 2,
577
- source: { graph_type: "knowledge_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
578
- learning_nodes: nodes,
579
- dependency_edges: edges,
580
- suggested_groupings: groupings,
581
- phases: []
582
- });
583
- }
584
- function emitFromProject(pg, opts) {
585
- const nodes = [];
586
- const edges = [];
587
- const groupings = [];
588
- arr(pg.features).forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad22(fi + 1)), nodes, edges, groupings));
589
- if (nodes.length === 0 && arr(pg.features).length > 0) {
590
- const hasTasks = arr(pg.implementation ?? {}).length > 0 || arr(pg.implementation?.tasks).length > 0;
591
- throw new Error(
592
- "Project graph has " + arr(pg.features).length + " features but NO features[].steps - the curriculum feed cannot emit planning nodes." + (hasTasks ? " This looks like a legacy knowledge-tree graph (implementation.tasks). Regenerate it with the domain-kit v3 pipeline (runProjectGraphPipeline / hybridGraphPipeline), then re-run." : " Every feature must carry at least one step with id/name/keywords/effort for curriculum planning.")
593
- );
594
- }
595
- newKeywordsInOrder(nodes);
596
- return validateCurriculumFeed({
597
- schema_version: 2,
598
- source: { graph_type: "project_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
599
- learning_nodes: nodes,
600
- dependency_edges: edges,
601
- suggested_groupings: groupings,
602
- phases: []
603
- });
604
- }
605
- function emitCurriculumFeed(graph, opts = {}) {
606
- const g = graph;
607
- if (arr(g.learning_nodes).length > 0) {
608
- return validateCurriculumFeed(g);
609
- }
610
- if (g.project_graph || g.knowledge_graph || arr(g.links).length > 0 || arr(g.phases).length > 0) {
611
- return emitFromHybrid(g, opts);
612
- }
613
- if (arr(g.concepts).length > 0 || str(g.type) === "knowledge_graph") return emitFromKnowledge(g, opts);
614
- return emitFromProject(g, opts);
258
+ merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
259
+ merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
260
+ merged.types = merged.types.slice(0, 100);
261
+ merged.functions = merged.functions.slice(0, 100);
262
+ merged.docstrings = merged.docstrings.slice(0, 20);
263
+ return merged;
615
264
  }
616
- var BLOOM_RANK, pad22, arr, str, num, strs;
617
- var init_curriculumFeedEmitter = __esm({
618
- "src/feed/curriculumFeedEmitter.ts"() {
619
- init_curriculumFeedSchema();
620
- BLOOM_RANK = { Remember: 0, Understand: 1, Apply: 2, Analyze: 3, Evaluate: 4, Create: 5 };
621
- pad22 = (n) => String(n).padStart(2, "0");
622
- arr = (v) => Array.isArray(v) ? v : [];
623
- str = (v, d = "") => typeof v === "string" ? v : d;
624
- num = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
625
- strs = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
626
- }
627
- });
628
265
 
629
- // src/parsers/swift.ts
630
- var IMPORT_RE = /^import\s+(\w+)/gm;
631
- var TYPE_RE = /\b(class|struct|enum|protocol)\s+(\w+)(?:\s*:\s*([^{]+))?/g;
632
- var FUNC_RE = /\bfunc\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*([^{]+))?/g;
633
- var WRAPPER_RE = /@(State|Binding|ObservedObject|StateObject|Published|Environment|EnvironmentObject|AppStorage|SceneStorage|FocusState|Observable)\b/g;
634
- var FRAMEWORK_USAGE_RE = /\b(URLSession|URLRequest|JSONSerialization|JSONDecoder|JSONEncoder|Task|DispatchQueue|Timer|NotificationCenter|UserDefaults|FileManager|Bundle)\b/g;
635
- function parseSwiftFile(filepath, content) {
266
+ // src/parsers/cpp.ts
267
+ var INCLUDE_RE = /#include\s*[<"]([^>"]+)[>"]/g;
268
+ var TYPE_RE3 = /\b(?:class|struct|enum)\s+(\w+)/g;
269
+ var FUNC_RE4 = /\b(?:void|int|float|double|char|bool|String|uint8_t|uint16_t|uint32_t)\s+(\w+)\s*\(/g;
270
+ function parseCppFile(filepath, content) {
636
271
  const result = {
637
272
  imports: [],
638
273
  types: [],
639
274
  functions: [],
640
- property_wrappers: [],
641
- error_handling: [],
642
- frameworks_used: []
275
+ error_handling: []
643
276
  };
644
277
  let match;
645
- while ((match = IMPORT_RE.exec(content)) !== null) {
278
+ while ((match = INCLUDE_RE.exec(content)) !== null) {
646
279
  result.imports.push(match[1]);
647
280
  }
648
- const fwSeen = /* @__PURE__ */ new Set();
649
- while ((match = FRAMEWORK_USAGE_RE.exec(content)) !== null) {
650
- if (!fwSeen.has(match[1])) {
651
- fwSeen.add(match[1]);
652
- result.frameworks_used.push(match[1]);
653
- }
654
- }
655
- while ((match = TYPE_RE.exec(content)) !== null) {
656
- const conforms = (match[3] || "").split(",").map((p) => p.trim()).filter(Boolean);
281
+ while ((match = TYPE_RE3.exec(content)) !== null) {
657
282
  result.types.push({
658
- kind: match[1],
659
- name: match[2],
660
- conforms_to: conforms,
283
+ kind: "cpp",
284
+ name: match[1],
661
285
  file: filepath
662
286
  });
663
287
  }
664
- while ((match = FUNC_RE.exec(content)) !== null) {
665
- const name = match[1];
666
- if (name.startsWith("_")) continue;
288
+ while ((match = FUNC_RE4.exec(content)) !== null) {
667
289
  result.functions.push({
668
- name,
669
- params: (match[2] || "").trim(),
670
- returns: (match[3] || "").trim(),
290
+ name: match[1],
671
291
  file: filepath
672
292
  });
673
293
  }
674
- const wrapperSet = /* @__PURE__ */ new Set();
675
- while ((match = WRAPPER_RE.exec(content)) !== null) {
676
- wrapperSet.add(match[1]);
677
- }
678
- result.property_wrappers = Array.from(wrapperSet);
679
- if (content.includes("do {") || content.includes("do{")) {
680
- result.error_handling.push("do-catch");
681
- }
682
- if (content.includes("throws")) {
683
- result.error_handling.push("throws");
684
- }
685
- if (content.includes("Result<")) {
686
- result.error_handling.push("Result type");
687
- }
688
- if (content.includes("try?")) {
689
- result.error_handling.push("try?");
690
- }
691
- if (content.includes("try!")) {
692
- result.error_handling.push("try!");
294
+ if (/\btry\b|\bthrow\b|\bcatch\b/.test(content)) {
295
+ result.error_handling.push("try/throw/catch");
693
296
  }
694
297
  return result;
695
298
  }
696
- function mergeSwiftResults(results) {
299
+ function mergeCppResults(results) {
697
300
  const merged = {
698
301
  imports: [],
699
302
  types: [],
700
303
  functions: [],
701
- property_wrappers: [],
702
- error_handling: [],
703
- frameworks_used: []
304
+ error_handling: []
704
305
  };
705
306
  const importCounts = /* @__PURE__ */ new Map();
706
- const wrapperCounts = /* @__PURE__ */ new Map();
707
- const errorCounts = /* @__PURE__ */ new Map();
708
- const frameworkSet = /* @__PURE__ */ new Set();
709
307
  for (const r of results) {
710
308
  for (const imp of r.imports) {
711
309
  importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
712
310
  }
713
311
  merged.types.push(...r.types);
714
312
  merged.functions.push(...r.functions);
715
- for (const w of r.property_wrappers) {
716
- wrapperCounts.set(w, (wrapperCounts.get(w) || 0) + 1);
717
- }
718
- for (const e of r.error_handling) {
719
- errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
720
- }
721
- for (const fw of r.frameworks_used) {
722
- frameworkSet.add(fw);
723
- }
724
- }
725
- merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod, count]) => mod);
726
- merged.property_wrappers = Array.from(wrapperCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([w]) => w);
727
- merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
728
- merged.frameworks_used = Array.from(frameworkSet).sort();
729
- merged.types = merged.types.slice(0, 100);
730
- merged.functions = merged.functions.slice(0, 100);
731
- return merged;
732
- }
733
-
734
- // src/parsers/typescript.ts
735
- var IMPORT_RE2 = /import\s+.*?from\s+['"]([^'"]+)['"]/g;
736
- var TYPE_RE2 = /\b(interface|type|class|enum)\s+(\w+)/g;
737
- var FUNC_RE2 = /\bfunction\s+(\w+)\s*\(([^)]*)\)/g;
738
- var ARROW_RE = /\bconst\s+(\w+)\s*=\s*\([^)]*\)\s*(?::\s*\w+)?\s*=>/g;
739
- function parseTsFile(filepath, content) {
740
- const result = {
741
- imports: [],
742
- types: [],
743
- functions: [],
744
- error_handling: []
745
- };
746
- let match;
747
- while ((match = IMPORT_RE2.exec(content)) !== null) {
748
- result.imports.push(match[1]);
749
- }
750
- while ((match = TYPE_RE2.exec(content)) !== null) {
751
- result.types.push({
752
- kind: match[1],
753
- name: match[2],
754
- file: filepath
755
- });
756
- }
757
- while ((match = FUNC_RE2.exec(content)) !== null) {
758
- result.functions.push({
759
- name: match[1],
760
- params: (match[2] || "").trim(),
761
- file: filepath
762
- });
763
- }
764
- while ((match = ARROW_RE.exec(content)) !== null) {
765
- result.functions.push({
766
- name: match[1],
767
- params: "",
768
- file: filepath
769
- });
770
- }
771
- if (content.includes("try {") || content.includes("try{")) {
772
- result.error_handling.push("try-catch");
773
- }
774
- if (content.includes("Promise")) {
775
- result.error_handling.push("Promise");
776
- }
777
- if (content.includes(".catch(")) {
778
- result.error_handling.push(".catch()");
779
- }
780
- return result;
781
- }
782
- function mergeTsResults(results) {
783
- const merged = {
784
- imports: [],
785
- types: [],
786
- functions: [],
787
- error_handling: []
788
- };
789
- const importCounts = /* @__PURE__ */ new Map();
790
- const errorCounts = /* @__PURE__ */ new Map();
791
- for (const r of results) {
792
- for (const imp of r.imports) {
793
- importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
794
- }
795
- merged.types.push(...r.types);
796
- merged.functions.push(...r.functions);
797
- for (const e of r.error_handling) {
798
- errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
799
- }
800
- }
801
- merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
802
- merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
803
- merged.types = merged.types.slice(0, 100);
804
- merged.functions = merged.functions.slice(0, 100);
805
- return merged;
806
- }
807
-
808
- // src/parsers/python.ts
809
- var IMPORT_RE3 = /^(?:import|from)\s+(\w+)/gm;
810
- var CLASS_RE = /\bclass\s+(\w+)(?:\s*\(([^)]+)\))?/g;
811
- var FUNC_RE3 = /\bdef\s+(\w+)\s*\(([^)]*)\)/g;
812
- var DOCSTRING_RE = /"""([^"]{20,300})"""/g;
813
- function parsePythonFile(filepath, content) {
814
- const result = {
815
- imports: [],
816
- types: [],
817
- functions: [],
818
- docstrings: [],
819
- error_handling: []
820
- };
821
- let match;
822
- while ((match = IMPORT_RE3.exec(content)) !== null) {
823
- result.imports.push(match[1]);
824
- }
825
- while ((match = DOCSTRING_RE.exec(content)) !== null) {
826
- const firstSentence = match[1].trim().split("\n")[0].slice(0, 120);
827
- if (firstSentence.length > 15) {
828
- result.docstrings.push(firstSentence);
829
- }
830
- }
831
- while ((match = CLASS_RE.exec(content)) !== null) {
832
- const bases = (match[2] || "").split(",").map((b) => b.trim()).filter(Boolean);
833
- result.types.push({
834
- kind: "class",
835
- name: match[1],
836
- bases,
837
- file: filepath
838
- });
839
- }
840
- while ((match = FUNC_RE3.exec(content)) !== null) {
841
- const name = match[1];
842
- if (name.startsWith("__") && name.endsWith("__")) continue;
843
- result.functions.push({
844
- name,
845
- params: (match[2] || "").trim(),
846
- file: filepath
847
- });
848
- }
849
- if (content.includes("try:")) {
850
- result.error_handling.push("try-except");
851
- }
852
- if (content.includes("raise")) {
853
- result.error_handling.push("raise");
854
- }
855
- return result;
856
- }
857
- function mergePythonResults(results) {
858
- const merged = {
859
- imports: [],
860
- types: [],
861
- functions: [],
862
- docstrings: [],
863
- error_handling: []
864
- };
865
- const importCounts = /* @__PURE__ */ new Map();
866
- const errorCounts = /* @__PURE__ */ new Map();
867
- for (const r of results) {
868
- for (const imp of r.imports) {
869
- importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
870
- }
871
- merged.types.push(...r.types);
872
- merged.functions.push(...r.functions);
873
- merged.docstrings.push(...r.docstrings);
874
- for (const e of r.error_handling) {
875
- errorCounts.set(e, (errorCounts.get(e) || 0) + 1);
876
- }
877
- }
878
- merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
879
- merged.error_handling = Array.from(errorCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([p]) => p);
880
- merged.types = merged.types.slice(0, 100);
881
- merged.functions = merged.functions.slice(0, 100);
882
- merged.docstrings = merged.docstrings.slice(0, 20);
883
- return merged;
884
- }
885
-
886
- // src/parsers/cpp.ts
887
- var INCLUDE_RE = /#include\s*[<"]([^>"]+)[>"]/g;
888
- var TYPE_RE3 = /\b(?:class|struct|enum)\s+(\w+)/g;
889
- var FUNC_RE4 = /\b(?:void|int|float|double|char|bool|String|uint8_t|uint16_t|uint32_t)\s+(\w+)\s*\(/g;
890
- function parseCppFile(filepath, content) {
891
- const result = {
892
- imports: [],
893
- types: [],
894
- functions: [],
895
- error_handling: []
896
- };
897
- let match;
898
- while ((match = INCLUDE_RE.exec(content)) !== null) {
899
- result.imports.push(match[1]);
900
- }
901
- while ((match = TYPE_RE3.exec(content)) !== null) {
902
- result.types.push({
903
- kind: "cpp",
904
- name: match[1],
905
- file: filepath
906
- });
907
- }
908
- while ((match = FUNC_RE4.exec(content)) !== null) {
909
- result.functions.push({
910
- name: match[1],
911
- file: filepath
912
- });
913
- }
914
- if (/\btry\b|\bthrow\b|\bcatch\b/.test(content)) {
915
- result.error_handling.push("try/throw/catch");
916
- }
917
- return result;
918
- }
919
- function mergeCppResults(results) {
920
- const merged = {
921
- imports: [],
922
- types: [],
923
- functions: [],
924
- error_handling: []
925
- };
926
- const importCounts = /* @__PURE__ */ new Map();
927
- for (const r of results) {
928
- for (const imp of r.imports) {
929
- importCounts.set(imp, (importCounts.get(imp) || 0) + 1);
930
- }
931
- merged.types.push(...r.types);
932
- merged.functions.push(...r.functions);
933
- merged.error_handling.push(...r.error_handling);
313
+ merged.error_handling.push(...r.error_handling);
934
314
  }
935
315
  merged.imports = Array.from(importCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([mod]) => mod);
936
316
  merged.error_handling = [...new Set(merged.error_handling)];
@@ -1147,6 +527,17 @@ function extractKeywords(sourceContext, basicAnalysis, repoDir, options) {
1147
527
  }
1148
528
 
1149
529
  // src/utils/llmClient.ts
530
+ function summarizeProvenance(callLog) {
531
+ const providers = [];
532
+ const models = [];
533
+ let failed = 0;
534
+ for (const r of callLog) {
535
+ if (!providers.includes(r.provider)) providers.push(r.provider);
536
+ if (!models.includes(r.model)) models.push(r.model);
537
+ if (!r.ok) failed++;
538
+ }
539
+ return { providers, models, calls: callLog.length, failed_calls: failed, degraded: failed > 0 };
540
+ }
1150
541
  var LlmClientError = class extends Error {
1151
542
  constructor(message, status, cause) {
1152
543
  super(message);
@@ -1179,9 +570,10 @@ function resolveProviderChain(config) {
1179
570
  return chain;
1180
571
  }
1181
572
  var MIN_REQUEST_INTERVAL_MS = 2500;
573
+ var requestIntervalMs = Number(process.env.LLM_MIN_REQUEST_INTERVAL_MS || MIN_REQUEST_INTERVAL_MS);
1182
574
  var lastRequestAt = 0;
1183
575
  async function pacedDelay() {
1184
- const wait = lastRequestAt + MIN_REQUEST_INTERVAL_MS - Date.now();
576
+ const wait = lastRequestAt + requestIntervalMs - Date.now();
1185
577
  if (wait > 0) await new Promise((resolve2) => setTimeout(resolve2, wait));
1186
578
  lastRequestAt = Date.now();
1187
579
  }
@@ -1196,6 +588,10 @@ function createLlmClient(config) {
1196
588
  primary.apiKey;
1197
589
  primary.baseUrl;
1198
590
  const model = primary.model;
591
+ if (config?.minRequestIntervalMs !== void 0) {
592
+ requestIntervalMs = Math.max(0, config.minRequestIntervalMs);
593
+ }
594
+ const callLog = [];
1199
595
  async function chat(messages, options) {
1200
596
  const temperature = options?.temperature ?? config?.temperature ?? 0.1;
1201
597
  const maxTokens = options?.maxTokens ?? config?.maxTokens ?? parseInt(process.env.LLM_MAX_TOKENS || "65536", 10);
@@ -1210,6 +606,7 @@ function createLlmClient(config) {
1210
606
  await new Promise((resolve2) => setTimeout(resolve2, 2e4));
1211
607
  }
1212
608
  for (const provider of chain) {
609
+ const recIdx = callLog.push({ provider: provider.label, model: provider.model, ok: false }) - 1;
1213
610
  try {
1214
611
  const headers = {
1215
612
  "Content-Type": "application/json",
@@ -1270,6 +667,7 @@ function createLlmClient(config) {
1270
667
  const retryData = await retry.json();
1271
668
  const retryContent2 = retryData.choices?.[0]?.message?.content || "";
1272
669
  if (!retryContent2.trim()) throw err;
670
+ callLog[recIdx] = { ...callLog[recIdx], ok: true };
1273
671
  return {
1274
672
  content: retryContent2,
1275
673
  model: retryData.model || provider.model,
@@ -1298,6 +696,7 @@ function createLlmClient(config) {
1298
696
  if (!retry.ok) throw err;
1299
697
  const retryData = await retry.json();
1300
698
  const retryContent = retryData.choices?.[0]?.message?.content || "";
699
+ callLog[recIdx] = { ...callLog[recIdx], ok: true };
1301
700
  return {
1302
701
  content: retryContent,
1303
702
  model: retryData.model || provider.model,
@@ -1328,6 +727,7 @@ function createLlmClient(config) {
1328
727
  };
1329
728
  } catch (err) {
1330
729
  const msg = err instanceof Error ? err.message : String(err);
730
+ callLog[recIdx] = { ...callLog[recIdx], ok: false, error: msg.slice(0, 200) };
1331
731
  attempts.push(`${provider.label}: ${msg}`);
1332
732
  lastError = err instanceof LlmClientError ? err : new LlmClientError(`[${provider.label}] ${msg}`);
1333
733
  }
@@ -1337,7 +737,7 @@ function createLlmClient(config) {
1337
737
  }
1338
738
  throw lastError ?? new LlmClientError("All LLM providers failed after " + MAX_CHAIN_ROUNDS + " rounds.");
1339
739
  }
1340
- return { chat, model };
740
+ return { chat, model, callLog };
1341
741
  }
1342
742
  function parseJsonFromLlm(text) {
1343
743
  let cleaned = text.trim();
@@ -1401,8 +801,8 @@ async function llmChatJson(client, systemPrompt, userPrompt, options) {
1401
801
 
1402
802
  // src/graph/scaffoldExtractor.ts
1403
803
  async function extractScaffold(options) {
1404
- const { goal, techStack, fileList, llmConfig } = options;
1405
- const client = createLlmClient(llmConfig);
804
+ const { goal, techStack, fileList, llmConfig, sharedClient } = options;
805
+ const client = sharedClient ?? createLlmClient(llmConfig);
1406
806
  const fileListStr = fileList.length > 0 ? fileList.join("\n") : "(empty)";
1407
807
  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.";
1408
808
  const userPrompt = `App goal: ${goal}
@@ -1487,8 +887,8 @@ ${content}`);
1487
887
  return blocks.join("\n\n");
1488
888
  }
1489
889
  async function extractProjectOverview(options) {
1490
- const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig } = options;
1491
- const client = createLlmClient(llmConfig);
890
+ const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig, sharedClient } = options;
891
+ const client = sharedClient ?? createLlmClient(llmConfig);
1492
892
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
1493
893
  const allFilesStr = buildSourceText(fileContentsMap);
1494
894
  let keywordsAnchor = "";
@@ -1557,8 +957,8 @@ ${content}`);
1557
957
  return blocks.join("\n\n");
1558
958
  }
1559
959
  async function extractFeatureSteps(options) {
1560
- const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig } = options;
1561
- const client = createLlmClient(llmConfig);
960
+ const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig, sharedClient } = options;
961
+ const client = sharedClient ?? createLlmClient(llmConfig);
1562
962
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
1563
963
  const allFilesStr = buildSourceText2(fileContentsMap);
1564
964
  const fid = feature.id || "F_UNK";
@@ -1625,8 +1025,8 @@ Split this feature into 2-6 action steps. Return JSON:
1625
1025
  concept_codes: []
1626
1026
  }));
1627
1027
  }
1628
- async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig) {
1629
- const client = createLlmClient(llmConfig);
1028
+ async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig, sharedClient) {
1029
+ const client = sharedClient ?? createLlmClient(llmConfig);
1630
1030
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
1631
1031
  const sourceText = buildSourceText2(fileContentsMap);
1632
1032
  const featuresListing = featuresMeta.map(
@@ -1893,7 +1293,7 @@ function inferDepthFromContext(keyword, fileContentsMap) {
1893
1293
  return { depth: "sio", rationale: `Default: treating '${keyword}' as SIO (implementation-level)` };
1894
1294
  }
1895
1295
  async function escalateAndMapConcepts(options) {
1896
- const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig } = options;
1296
+ const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig, sharedClient } = options;
1897
1297
  const features = projectGraph.features || [];
1898
1298
  const allTerms = /* @__PURE__ */ new Set();
1899
1299
  for (const feature of features) {
@@ -1909,7 +1309,7 @@ async function escalateAndMapConcepts(options) {
1909
1309
  llmResults = conceptMapOverride;
1910
1310
  } else {
1911
1311
  try {
1912
- const client = createLlmClient(llmConfig);
1312
+ const client = sharedClient ?? createLlmClient(llmConfig);
1913
1313
  const conceptBank = (availableConcepts || []).sort().join(", ") || "(empty)";
1914
1314
  const termEntries = termList.map((term) => {
1915
1315
  const snippets = extractTermSnippets(term, fileContentsMap);
@@ -1962,13 +1362,16 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
1962
1362
  const cName = info.concept_name || kw;
1963
1363
  let depth = "sio";
1964
1364
  let depthRationale = "Default: SIO";
1365
+ let depthSource = "override";
1965
1366
  if (info.depth && ["ulo", "cio", "sio"].includes(info.depth)) {
1966
1367
  depth = info.depth;
1967
1368
  depthRationale = `LLM classified as ${depth}`;
1369
+ depthSource = "llm";
1968
1370
  } else {
1969
1371
  const inferred = inferDepthFromContext(kw, fileContentsMap);
1970
1372
  depth = inferred.depth;
1971
1373
  depthRationale = inferred.rationale;
1374
+ depthSource = "heuristic";
1972
1375
  }
1973
1376
  const kwKey = `${cCode}::${kw}`;
1974
1377
  if (seen.has(kwKey)) continue;
@@ -1986,6 +1389,7 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
1986
1389
  keyword: kw,
1987
1390
  depth,
1988
1391
  depth_rationale: depthRationale,
1392
+ depth_source: depthSource,
1989
1393
  evidence_files: evidenceFiles
1990
1394
  });
1991
1395
  }
@@ -2353,11 +1757,11 @@ function assembleRoadmap(options) {
2353
1757
  }
2354
1758
 
2355
1759
  // src/extractors/fileSelector.ts
2356
- async function selectFilesWithLlm(filePaths, goal, techStack, llmConfig) {
1760
+ async function selectFilesWithLlm(filePaths, goal, techStack, llmConfig, sharedClient) {
2357
1761
  if (filePaths.length <= 15) {
2358
1762
  return { selected: filePaths, reasoning: "Too few files to filter" };
2359
1763
  }
2360
- const client = createLlmClient(llmConfig);
1764
+ const client = sharedClient ?? createLlmClient(llmConfig);
2361
1765
  const treeLines = buildFileTree(filePaths);
2362
1766
  const systemPrompt = 'You are a software architect analyzing a project repository.\nGiven a file tree and a project goal, select which files are MOST IMPORTANT to read for understanding the project architecture and implementation.\n\nRULES:\n1. Prioritize source code files (Swift, TS, Python, C++) over config/docs\n2. Include entry points (App.swift, main.py, index.ts)\n3. Include core domain files (models, services, views, controllers)\n4. EXCLUDE test files, mock files, generated files\n5. EXCLUDE config files (package.json, Podfile, etc.) unless they reveal architecture\n6. Include files that directly implement the features mentioned in the goal\n7. Return 20-40 files maximum (fewer is better if the project is small)\n\nReturn JSON: {"selected": ["path/to/file1", "path/to/file2"], "reasoning": "brief explanation"}';
2363
1767
  const userPrompt = `Project goal: ${goal}
@@ -2519,6 +1923,7 @@ function detectPrimaryLanguage(filePaths) {
2519
1923
  }
2520
1924
  async function runProjectGraphPipeline(options) {
2521
1925
  const { repoDir, goal, techStack, llmConfig, embeddings, onProgress } = options;
1926
+ const client = createLlmClient(llmConfig);
2522
1927
  const log = (step, msg) => {
2523
1928
  console.log(`[${step}] ${msg}`);
2524
1929
  onProgress?.(step, msg);
@@ -2532,7 +1937,8 @@ async function runProjectGraphPipeline(options) {
2532
1937
  allRelPaths,
2533
1938
  goal,
2534
1939
  techStack,
2535
- llmConfig
1940
+ llmConfig,
1941
+ client
2536
1942
  );
2537
1943
  log("STEP_1", `LLM selected ${selectedPaths.length}/${allRelPaths.length} files: ${reasoning.slice(0, 100)}`);
2538
1944
  log("STEP_1", "Reading content for selected files...");
@@ -2581,7 +1987,8 @@ async function runProjectGraphPipeline(options) {
2581
1987
  goal,
2582
1988
  techStack,
2583
1989
  fileList: filePaths,
2584
- llmConfig
1990
+ llmConfig,
1991
+ sharedClient: client
2585
1992
  });
2586
1993
  log("STEP_4_C1", "Extracting project overview (features meta)...");
2587
1994
  const overview = await extractProjectOverview({
@@ -2590,7 +1997,8 @@ async function runProjectGraphPipeline(options) {
2590
1997
  sdkApiIndex,
2591
1998
  fileContentsMap,
2592
1999
  astKeywords,
2593
- llmConfig
2000
+ llmConfig,
2001
+ sharedClient: client
2594
2002
  });
2595
2003
  const product = overview.product;
2596
2004
  const pblStep = {
@@ -2643,7 +2051,8 @@ async function runProjectGraphPipeline(options) {
2643
2051
  sdkApiIndex,
2644
2052
  fileContentsMap,
2645
2053
  nonF0Features,
2646
- llmConfig
2054
+ llmConfig,
2055
+ client
2647
2056
  );
2648
2057
  for (const feature of nonF0Features) {
2649
2058
  const steps = batchedSteps.get(feature.id || "");
@@ -2659,7 +2068,8 @@ async function runProjectGraphPipeline(options) {
2659
2068
  sdkApiIndex,
2660
2069
  fileContentsMap,
2661
2070
  feature,
2662
- llmConfig
2071
+ llmConfig,
2072
+ sharedClient: client
2663
2073
  });
2664
2074
  feature.steps = steps;
2665
2075
  } catch (e) {
@@ -2712,7 +2122,8 @@ async function runProjectGraphPipeline(options) {
2712
2122
  const featureConcepts = await escalateAndMapConcepts({
2713
2123
  projectGraph: verifiedGraph,
2714
2124
  fileContentsMap,
2715
- llmConfig
2125
+ llmConfig,
2126
+ sharedClient: client
2716
2127
  });
2717
2128
  log("STEP_6", `Concept mapping complete: ${featureConcepts.size} features mapped`);
2718
2129
  for (const feature of verifiedGraph.features || []) {
@@ -2779,7 +2190,8 @@ async function runProjectGraphPipeline(options) {
2779
2190
  roadmap,
2780
2191
  hallucinations,
2781
2192
  keywords: astKeywords,
2782
- featureConcepts
2193
+ featureConcepts,
2194
+ provenance: client.callLog
2783
2195
  };
2784
2196
  }
2785
2197
 
@@ -2899,6 +2311,13 @@ var ULO_CODE_RE = /^ULO-[A-Z][A-Z0-9_]*-\d{2}$/;
2899
2311
  var CIO_CODE_RE = /^CIO-[A-Z][A-Z0-9_]*-\d{2}-[A-Z][A-Z0-9_]*$/;
2900
2312
  var SIO_CODE_RE = /^SIO-[A-Z0-9]+-[A-Z][A-Z0-9_]*-\d{2}$/;
2901
2313
 
2314
+ // src/utils/diacritics.ts
2315
+ var COMBINING_MARKS = /[\u0300-\u036f]/g;
2316
+ var D_WITH_STROKE = /đ/gi;
2317
+ function stripDiacritics(s) {
2318
+ return s.normalize("NFD").replace(COMBINING_MARKS, "").replace(D_WITH_STROKE, "d").toLowerCase();
2319
+ }
2320
+
2902
2321
  // src/parsers/syllabusParser.ts
2903
2322
  var STOP_WORDS = /* @__PURE__ */ new Set([
2904
2323
  "and",
@@ -3661,7 +3080,7 @@ function loadKnowledgeTreeCatalog(tsvPath) {
3661
3080
  }
3662
3081
  function tokens(s) {
3663
3082
  return new Set(
3664
- s.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
3083
+ stripDiacritics(s).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
3665
3084
  );
3666
3085
  }
3667
3086
  function filterBySelection(catalog, selectedCategories, selectedTopics, analysisConcepts, maxConcepts = 120) {
@@ -3799,326 +3218,1221 @@ async function standardizeConcepts(opts) {
3799
3218
  if (tc.keywords.length > 0 && adopted.length === 0) {
3800
3219
  throw new Error("Concept " + c.id + " standardized to " + tc.code + " without adopted_keywords \u2014 fail-closed");
3801
3220
  }
3802
- finalDecisions.push({ ...d, id: c.id, tree_code: tc.code, adopted_keywords: adopted });
3803
- warnings.push("Concept " + c.id + " standardized as " + tc.code + " (" + tc.name + "), adopted keywords: " + adopted.join(", ") + (d.note ? " \u2014 " + d.note : ""));
3804
- } else {
3805
- const code = (d.proposed_code || "").toUpperCase().replace(/[^A-Z0-9_]/g, "_");
3806
- if (!/^[A-Z][A-Z0-9_]*$/.test(code)) {
3807
- throw new Error("Concept " + c.id + ' proposed invalid code "' + d.proposed_code + '" \u2014 fail-closed');
3221
+ finalDecisions.push({ ...d, id: c.id, tree_code: tc.code, adopted_keywords: adopted });
3222
+ warnings.push("Concept " + c.id + " standardized as " + tc.code + " (" + tc.name + "), adopted keywords: " + adopted.join(", ") + (d.note ? " \u2014 " + d.note : ""));
3223
+ } else {
3224
+ const code = (d.proposed_code || "").toUpperCase().replace(/[^A-Z0-9_]/g, "_");
3225
+ if (!/^[A-Z][A-Z0-9_]*$/.test(code)) {
3226
+ throw new Error("Concept " + c.id + ' proposed invalid code "' + d.proposed_code + '" \u2014 fail-closed');
3227
+ }
3228
+ finalDecisions.push({ ...d, id: c.id, proposed_code: code });
3229
+ proposals.push({
3230
+ code,
3231
+ name: c.name,
3232
+ description: d.proposed_definition || c.description.slice(0, 300),
3233
+ keywords: c.keywords,
3234
+ subject_codes: "PY",
3235
+ category_codes: "",
3236
+ topic_codes: "",
3237
+ prerequisite_concept_codes: [],
3238
+ status: "proposed",
3239
+ source_concept_id: c.id,
3240
+ possible_tree_matches: (d.possible_tree_matches || []).filter(Boolean)
3241
+ });
3242
+ warnings.push("Concept " + c.id + " proposed as NEW: " + code + (d.possible_tree_matches?.length ? " (near matches: " + d.possible_tree_matches.join(", ") + ")" : ""));
3243
+ }
3244
+ }
3245
+ const outPath = proposalsPath || path.join(os.tmpdir(), "domain-kit-debug", "concept-proposals.json");
3246
+ try {
3247
+ fs.mkdirSync(outPath.slice(0, outPath.lastIndexOf("/")), { recursive: true });
3248
+ fs.writeFileSync(outPath, JSON.stringify({ generated_at: (/* @__PURE__ */ new Date()).toISOString(), proposals }, null, 2));
3249
+ log("STEP_2_5", "Proposals written: " + proposals.length + " -> " + outPath);
3250
+ } catch (e) {
3251
+ warnings.push("Could not write proposals artifact: " + (e instanceof Error ? e.message : String(e)));
3252
+ }
3253
+ return { decisions: finalDecisions, proposals, warnings, shortlistSize: shortlist.length, catalogSize: catalog.concepts.length };
3254
+ }
3255
+
3256
+ // src/graph/knowledgeGraphPipeline.ts
3257
+ var MIN_CALIBRATION_MINUTES = 15;
3258
+ var MAX_CALIBRATION_MINUTES = 60;
3259
+ var DEFAULT_CONCEPT_MINUTES = 30;
3260
+ var MIN_CALIBRATION_DIVERGENCE = 0.5;
3261
+ function calibrateConceptMinutes(sioCount, problemTypeCount, prereqCount, hasHighBloom) {
3262
+ const ratio = (value, cap) => Math.min(1, Math.max(0, value) / cap);
3263
+ const score = 0.3 * ratio(sioCount, 4) + 0.25 * ratio(problemTypeCount, 4) + 0.2 * ratio(prereqCount, 4) + 0.25 * (hasHighBloom ? 1 : 0);
3264
+ return Math.round(MIN_CALIBRATION_MINUTES + score * (MAX_CALIBRATION_MINUTES - MIN_CALIBRATION_MINUTES));
3265
+ }
3266
+ function topologicalSort(concepts) {
3267
+ const inDegree = /* @__PURE__ */ new Map();
3268
+ const adjacency = /* @__PURE__ */ new Map();
3269
+ for (const concept of concepts) {
3270
+ inDegree.set(concept.id, 0);
3271
+ adjacency.set(concept.id, []);
3272
+ }
3273
+ for (const concept of concepts) {
3274
+ for (const prereq of concept.prerequisites || []) {
3275
+ adjacency.get(prereq)?.push(concept.id);
3276
+ inDegree.set(concept.id, (inDegree.get(concept.id) || 0) + 1);
3277
+ }
3278
+ }
3279
+ const queue = [];
3280
+ for (const [id, degree] of inDegree) {
3281
+ if (degree === 0) queue.push(id);
3282
+ }
3283
+ const sorted = [];
3284
+ while (queue.length > 0) {
3285
+ const current = queue.shift();
3286
+ sorted.push(current);
3287
+ for (const neighbor of adjacency.get(current) || []) {
3288
+ const newDegree = (inDegree.get(neighbor) || 1) - 1;
3289
+ inDegree.set(neighbor, newDegree);
3290
+ if (newDegree === 0) queue.push(neighbor);
3291
+ }
3292
+ }
3293
+ if (sorted.length !== concepts.length) {
3294
+ console.warn(`[WARN] Topological sort incomplete: ${sorted.length}/${concepts.length} concepts sorted`);
3295
+ }
3296
+ return sorted;
3297
+ }
3298
+ async function generateKnowledgeGraph(options) {
3299
+ const { subject, description, syllabusText, gradeBand, domain, techStack, llmConfig, onProgress } = options;
3300
+ const warnings = [];
3301
+ const log = (step, msg) => {
3302
+ console.log(`[KG-${step}] ${msg}`);
3303
+ onProgress?.(step, msg);
3304
+ };
3305
+ log("STEP_1", "Decomposing subject into concepts...");
3306
+ let parsedSyllabus = options.parsedSyllabus;
3307
+ if (!parsedSyllabus && syllabusText) {
3308
+ parsedSyllabus = parseSyllabus(syllabusText);
3309
+ log("STEP_1", `Parsed syllabus: ${parsedSyllabus.units.length} units, ${parsedSyllabus.allTopics.length} topics`);
3310
+ }
3311
+ const client = createLlmClient(llmConfig);
3312
+ const maxSyllabusChars = parseInt(process.env.KG_MAX_SYLLABUS_CHARS || "16000", 10);
3313
+ let truncatedSyllabus = "";
3314
+ if (syllabusText) {
3315
+ if (syllabusText.length > maxSyllabusChars) {
3316
+ truncatedSyllabus = syllabusText.slice(0, maxSyllabusChars);
3317
+ warnings.push(`Syllabus truncated from ${syllabusText.length} to ${maxSyllabusChars} chars \u2014 may miss some concepts`);
3318
+ } else {
3319
+ truncatedSyllabus = syllabusText;
3320
+ }
3321
+ }
3322
+ const structuredTopicsGuide = parsedSyllabus && parsedSyllabus.allTopics.length > 0 ? `
3323
+ STRUCTURED SYLLABUS TOPICS (Every topic MUST map to at least one concept to ensure 100% syllabus coverage):
3324
+ ` + parsedSyllabus.allTopics.map((t) => `- [${t.unitTitle}] ${t.id}: ${t.title} (Keywords: ${t.keywords.join(", ")})`).join("\n") + "\n" : "";
3325
+ const systemPrompt = `You are an expert curriculum designer. Decompose the given subject into a structured knowledge graph with concepts, prerequisites, and problem types.
3326
+
3327
+ CRITICAL RULES:
3328
+ 1. Each concept MUST have the three depth layers, authored against this CONTRACT:
3329
+ - ulo (WHAT + WHY): bound to the CONCEPT's intrinsic NATURE. It explains what the
3330
+ concept is and why it exists \u2014 the problem it solves in principle. It must be
3331
+ TECHNOLOGY-AGNOSTIC: never name a concrete library, API, function, file format,
3332
+ tool or language feature. Typical assessment level: Remember / Understand.
3333
+ - cio (HOW): the MECHANISM \u2014 how the concept works and solves that problem, step
3334
+ by step. Still bound to the concept's nature: more concrete than the ULO, but
3335
+ NOT tied to one specific technology (no concrete API names). CIO capability is
3336
+ assessed INDIRECTLY, through completing contextual work, not in isolation.
3337
+ - sio (IMPLEMENTATION): an array of 2-4 items. EVERY item MUST reference concrete
3338
+ KEYWORDS/technologies (a real API, function, module, file, or named pattern) and
3339
+ describe a completable, context-specific implementation task. Application,
3340
+ Analysis and Creation skills are assessed INDIRECTLY through completing these
3341
+ items. There is NO fixed Bloom ceiling for a layer \u2014 but concreteness MUST
3342
+ strictly increase: SIO more technology-bound than CIO, CIO more concrete than ULO.
3343
+ - prerequisites: concept IDs that must be learned first (MUST NOT create cycles)
3344
+ - problem_types: 2-4 problem types with bloom_level and difficulty. Remember and
3345
+ Understand problem types test the ULO/CIO directly; Apply-and-above types are
3346
+ proxies for SIO completion in context.
3347
+ - techniques: methods, formulas, tools
3348
+ 2. Prerequisite chains MUST be acyclic (no concept can depend on itself or create a loop)
3349
+ 3. Concepts should be ordered by prerequisite dependency (topological sort)
3350
+ 4. Problem types should range from Remember to Apply (not all at same level)
3351
+ 5. Group concepts into categories (chapters/sections)
3352
+ 6. ALL text in ENGLISH (even if input is Vietnamese)
3353
+ 7. NEVER invent concepts not present in the syllabus
3354
+ 8. KEYWORDS: Each concept MUST include 5-10 domain-specific keywords that:
3355
+ - Are concrete terms a student encounters when studying THIS concept
3356
+ - Include at least 2 technology-specific terms (APIs, functions, types, tools)
3357
+ - Are DISTINCT from keywords of other concepts (minimal overlap)
3358
+ - NEVER generic English words (e.g. "view", "data", "code" are TOO generic)
3359
+ - Example for "SwiftUI Layout": ["VStack", "HStack", "ZStack", ".padding()", ".frame()", "alignment", "Spacer", "LazyVGrid"]
3360
+ 9. Return JSON matching the KnowledgeGraphSchema`;
3361
+ const userPrompt = `Subject: ${subject}
3362
+ ${description ? `Description: ${description}` : ""}
3363
+ ${structuredTopicsGuide}${truncatedSyllabus ? `Syllabus:
3364
+ ${truncatedSyllabus}` : ""}
3365
+ ${gradeBand ? `Grade band: ${gradeBand[0]}-${gradeBand[1]}` : ""}
3366
+ ${domain ? `Domain: ${domain}` : ""}
3367
+
3368
+ Return JSON:
3369
+ {
3370
+ "subject": {
3371
+ "id": "${subject.toLowerCase().replace(/\s+/g, "-").slice(0, 30)}",
3372
+ "name": "${subject}",
3373
+ "description": "...",
3374
+ "domain": "${domain || "other"}",
3375
+ "grade_band": ${gradeBand ? `[${gradeBand[0]}, ${gradeBand[1]}]` : "[6, 12]"}
3376
+ },
3377
+ "concepts": [
3378
+ {
3379
+ "id": "C1",
3380
+ "name": "Concept name",
3381
+ "description": "2-3 sentence description: what this concept covers, why a learner encounters it, and what mastering it enables. Must be specific enough that a student reading only this description understands the concept's scope.",
3382
+ "category": "category_id",
3383
+ "keywords": ["specific_term_1", "concrete_api_2", "method_call_3", "tool_or_type_4", "syntax_keyword_5", "concept_term_6"],
3384
+ "ulo": "WHAT + WHY in technology-agnostic language: what the concept is and the problem it exists to solve. No library/API/tool names.",
3385
+ "cio": "HOW the mechanism works, still technology-agnostic but more concrete than the ULO.",
3386
+ "sio": ["Concrete task naming a REAL API/function/module/file from the target technology, completable end to end", "Another technology-bound task using a different concrete keyword"],
3387
+ "prerequisites": [],
3388
+ "estimated_minutes": 30,
3389
+ "problem_types": [
3390
+ {
3391
+ "id": "PT-C1-01",
3392
+ "name": "Problem type name",
3393
+ "description": "What this problem type tests",
3394
+ "bloom_level": "Remember",
3395
+ "difficulty": "easy",
3396
+ "estimated_minutes": 10
3397
+ }
3398
+ ],
3399
+ "techniques": ["technique 1", "technique 2"],
3400
+ "common_mistakes": ["mistake 1"]
3401
+ }
3402
+ ],
3403
+ "categories": [
3404
+ {
3405
+ "id": "cat1",
3406
+ "name": "Category name",
3407
+ "description": "Category description",
3408
+ "concept_ids": ["C1", "C2"],
3409
+ "order": 1
3410
+ }
3411
+ ],
3412
+ "learning_path": [
3413
+ {"concept_id": "C1", "order": 1, "depends_on": []},
3414
+ {"concept_id": "C2", "order": 2, "depends_on": ["C1"]}
3415
+ ]
3416
+ }`;
3417
+ const result = await llmChatJson(client, systemPrompt, userPrompt, {
3418
+ temperature: 0.1,
3419
+ maxTokens: parseInt(process.env.KG_MAX_TOKENS || "65536", 10)
3420
+ });
3421
+ log("STEP_2", "Validating and cleaning knowledge graph...");
3422
+ const concepts = result.concepts || [];
3423
+ const categories = result.categories || [];
3424
+ for (const c of concepts) {
3425
+ if ((c.description || "").length < 50) {
3426
+ warnings.push("Concept " + c.id + ": description too short (" + (c.description || "").length + " chars) \u2014 may produce shallow KX narratives");
3427
+ }
3428
+ }
3429
+ {
3430
+ 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"]);
3431
+ const tokensFrom = (text) => stripDiacritics(text).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4 && !STOP.has(w));
3432
+ for (const c of concepts) {
3433
+ const existing = (c.keywords || []).map((k) => String(k).trim()).filter(Boolean);
3434
+ if (existing.length === 0) {
3435
+ const derived = [.../* @__PURE__ */ new Set([...tokensFrom(c.name), ...tokensFrom(c.description || "")])].slice(0, 8);
3436
+ if (derived.length === 0) {
3437
+ throw new Error("Concept " + c.id + " (" + c.name + ") has no keywords and none could be derived from name/description \u2014 fail-closed");
3438
+ }
3439
+ c.keywords = derived;
3440
+ warnings.push("Concept " + c.id + ": empty keyword set - derived " + derived.join(", ") + " from name/description (WARNING: LLM returned no keywords)");
3441
+ } else {
3442
+ c.keywords = existing;
3443
+ }
3444
+ }
3445
+ }
3446
+ const rawPath = result.learning_path;
3447
+ let learningPath = (rawPath || []).map((s) => ({
3448
+ concept_id: String(s.concept_id || ""),
3449
+ order: Number(s.order || 0),
3450
+ depends_on: Array.isArray(s.depends_on) ? s.depends_on.map(String) : [],
3451
+ milestone_name: String(s.milestone_name || "")
3452
+ }));
3453
+ const conceptIds = new Set(concepts.map((c) => c.id));
3454
+ for (const concept of concepts) {
3455
+ for (const prereq of concept.prerequisites || []) {
3456
+ if (!conceptIds.has(prereq)) {
3457
+ warnings.push(`Concept ${concept.id} references non-existent prerequisite ${prereq} \u2014 removed`);
3458
+ concept.prerequisites = (concept.prerequisites || []).filter((p) => p !== prereq);
3459
+ }
3460
+ }
3461
+ }
3462
+ if (options.knowledgeTreePath) {
3463
+ const catalog = loadKnowledgeTreeCatalog(options.knowledgeTreePath);
3464
+ log("STEP_2_5", "Knowledge tree loaded: " + catalog.concepts.length + " concepts from " + catalog.path.split("/").pop());
3465
+ const std = await standardizeConcepts({
3466
+ client,
3467
+ concepts: concepts.map((c) => ({
3468
+ id: c.id,
3469
+ name: c.name,
3470
+ description: c.description || "",
3471
+ keywords: c.keywords || []
3472
+ })),
3473
+ catalog,
3474
+ techStack,
3475
+ proposalsPath: options.proposalsPath,
3476
+ onProgress: log
3477
+ });
3478
+ warnings.push(...std.warnings);
3479
+ const codeByStd = new Map(std.decisions.map((d) => [d.id, d]));
3480
+ for (const c of concepts) {
3481
+ const d = codeByStd.get(c.id);
3482
+ if (!d) throw new Error("Concept " + c.id + " missing standardization decision \u2014 fail-closed");
3483
+ if (d.decision === "standardized") {
3484
+ c.standardization = "standardized";
3485
+ c.tree_code = d.tree_code;
3486
+ c.code = d.tree_code;
3487
+ const tc = catalog.concepts.find((x) => x.code === d.tree_code);
3488
+ if (tc) {
3489
+ for (const k of d.adopted_keywords || []) {
3490
+ if (!(c.keywords || []).some((x) => x.toLowerCase() === k.toLowerCase())) {
3491
+ c.keywords = [...c.keywords || [], k];
3492
+ }
3493
+ }
3494
+ c.tree_prerequisites = tc.prerequisiteCodes;
3495
+ }
3496
+ } else {
3497
+ c.standardization = "proposed_new";
3498
+ c.code = d.proposed_code;
3499
+ }
3500
+ }
3501
+ const idByTreeCode = new Map(concepts.filter((c) => c.tree_code).map((c) => [c.tree_code, c.id]));
3502
+ for (const c of concepts) {
3503
+ for (const tp of c.tree_prerequisites || []) {
3504
+ const target = idByTreeCode.get(tp);
3505
+ if (target && target !== c.id && !(c.prerequisites || []).includes(target)) {
3506
+ c.prerequisites = [...c.prerequisites || [], target];
3507
+ warnings.push("Prereq seeded from tree: " + c.id + " <- " + tp + " (" + target + ")");
3508
+ }
3509
+ }
3510
+ }
3511
+ log("STEP_2_5", "Standardized: " + std.decisions.filter((d) => d.decision === "standardized").length + ", proposed new: " + std.decisions.filter((d) => d.decision === "proposed_new").length);
3512
+ }
3513
+ log("STEP_3", "Checking for prerequisite cycles...");
3514
+ const cycles = detectPrerequisiteCycles(concepts);
3515
+ if (cycles.length > 0) {
3516
+ log("STEP_3", `Found ${cycles.length} cycle(s) in prerequisite chain \u2014 breaking cycles`);
3517
+ for (const cycle of cycles) {
3518
+ warnings.push(`Cycle detected: ${cycle.join(" \u2192 ")} \u2014 breaking at weakest edge`);
3519
+ }
3520
+ const { fixed, removed } = breakPrerequisiteCycles(concepts, cycles);
3521
+ for (let i = 0; i < concepts.length; i++) {
3522
+ const fixedConcept = fixed.find((f) => f.id === concepts[i].id);
3523
+ if (fixedConcept) {
3524
+ concepts[i] = { ...concepts[i], prerequisites: fixedConcept.prerequisites };
3525
+ }
3526
+ }
3527
+ for (const { from, to } of removed) {
3528
+ warnings.push(`Removed prerequisite edge: ${from} \u2192 ${to} (cycle breaker)`);
3529
+ }
3530
+ }
3531
+ log("STEP_4", "Topological sort of concepts...");
3532
+ const sortedIds = topologicalSort(concepts);
3533
+ learningPath = sortedIds.map((id, idx) => {
3534
+ const concept = concepts.find((c) => c.id === id);
3535
+ return {
3536
+ concept_id: id,
3537
+ order: idx + 1,
3538
+ depends_on: concept?.prerequisites || [],
3539
+ milestone_name: ""
3540
+ };
3541
+ });
3542
+ log("STEP_5", "Authoring ULO/CIO/SIO depth layers hierarchically...");
3543
+ {
3544
+ const lo = await authorDepthLayersHierarchically(
3545
+ client,
3546
+ concepts.map((c) => ({ id: c.id, name: c.name, description: c.description, keywords: c.keywords })),
3547
+ techStack || (domain === "software" || domain === "technology" ? "python" : domain || "general"),
3548
+ warnings
3549
+ );
3550
+ const codeByConceptId = assignConceptCodes(concepts.map((c) => ({ id: c.id, name: c.name })));
3551
+ const techTag = techTagFor(techStack || (domain === "software" || domain === "technology" ? "python" : domain || "general"));
3552
+ for (const concept of concepts) {
3553
+ concept.ulo = lo.uloByConcept[concept.id] || concept.ulo || "";
3554
+ concept.cio = lo.cioByConcept[concept.id] || concept.cio || "";
3555
+ if (lo.sioByConcept[concept.id] && lo.sioByConcept[concept.id].length > 0) {
3556
+ concept.sio = lo.sioByConcept[concept.id];
3557
+ }
3558
+ concept.code = concept.code || codeByConceptId[concept.id];
3559
+ concept.ulo_code = concept.ulo ? uloCode(concept.code) : concept.ulo_code;
3560
+ concept.cio_code = concept.cio ? cioCode(concept.code, 1, cioActionSlug(concept.cio)) : concept.cio_code;
3561
+ concept.sio_codes = concept.sio.length > 0 ? concept.sio.map((_, i) => sioCode(techTag, concept.code, i + 1)) : concept.sio_codes;
3562
+ }
3563
+ log("STEP_5", "Depth layers authored: " + concepts.length + " concepts, audit " + lo.audits.filter((a) => a.passed).length + "/" + concepts.length + " passed");
3564
+ }
3565
+ {
3566
+ for (const concept of concepts) {
3567
+ const sioText = (concept.sio || []).join(" ");
3568
+ const techTokens = [];
3569
+ let m;
3570
+ const techTokenRe = /`([^`]+)`/g;
3571
+ while ((m = techTokenRe.exec(sioText)) !== null) {
3572
+ if (m[1].length >= 3 && m[1].length <= 40) techTokens.push(m[1]);
3573
+ }
3574
+ const annotationRe = /(?:^|[\s(])(@[A-Za-z]\w*)/g;
3575
+ while ((m = annotationRe.exec(sioText)) !== null) techTokens.push(m[1]);
3576
+ const dotRe = /\b\w+\.\w+\(/g;
3577
+ while ((m = dotRe.exec(sioText)) !== null) techTokens.push(m[0].replace("(", ""));
3578
+ const modifierRe = /(?:^|[\s(])(\.[a-z]\w*)\b/g;
3579
+ while ((m = modifierRe.exec(sioText)) !== null) techTokens.push(m[1]);
3580
+ const camelRe = /\b[a-z]+[A-Z][A-Za-z]+\b/g;
3581
+ while ((m = camelRe.exec(sioText)) !== null) techTokens.push(m[0]);
3582
+ const existing = new Set((concept.keywords || []).map((k) => k.toLowerCase()));
3583
+ const newTokens = [...new Set(techTokens)].filter((t) => !existing.has(t.toLowerCase())).slice(0, 8);
3584
+ if (newTokens.length > 0) {
3585
+ concept.keywords = [...concept.keywords || [], ...newTokens];
3586
+ warnings.push("Concept " + concept.id + ": enriched keywords with " + newTokens.length + " SIO-derived tokens");
3587
+ }
3588
+ }
3589
+ log("STEP_5b", "SIO-keyword enrichment complete");
3590
+ }
3591
+ {
3592
+ for (const concept of concepts) {
3593
+ const sioCount = (concept.sio || []).length;
3594
+ const ptCount = (concept.problem_types || []).length;
3595
+ const prereqCount = (concept.prerequisites || []).length;
3596
+ const hasHighBloom = (concept.problem_types || []).some(
3597
+ (pt) => ["Analyze", "Evaluate", "Create"].includes(pt.bloom_level || "")
3598
+ );
3599
+ const calibrated = calibrateConceptMinutes(sioCount, ptCount, prereqCount, hasHighBloom);
3600
+ const llmEstimate = concept.estimated_minutes || DEFAULT_CONCEPT_MINUTES;
3601
+ if (Math.abs(calibrated - llmEstimate) / llmEstimate > MIN_CALIBRATION_DIVERGENCE) {
3602
+ concept.estimated_minutes = calibrated;
3603
+ warnings.push("Concept " + concept.id + ": minutes calibrated " + llmEstimate + "m \u2192 " + calibrated + "m");
3604
+ }
3605
+ }
3606
+ log("STEP_5c", "Minute calibration complete");
3607
+ }
3608
+ for (const step of learningPath) {
3609
+ if (!conceptIds.has(step.concept_id)) {
3610
+ warnings.push(`Learning path references non-existent concept ${step.concept_id}`);
3611
+ }
3612
+ }
3613
+ log("STEP_2", `Validated ${concepts.length} concepts, ${categories.length} categories, ${learningPath.length} learning path steps`);
3614
+ const rawGraph = {
3615
+ schema_version: 1,
3616
+ type: "knowledge_graph",
3617
+ subject: result.subject,
3618
+ concepts,
3619
+ categories,
3620
+ learning_path: learningPath
3621
+ };
3622
+ log("STEP_6", "Running Knowledge Graph verification & reverse coverage audit...");
3623
+ const { verifiedGraph, report } = verifyKnowledgeGraph(rawGraph, parsedSyllabus);
3624
+ warnings.push(...report.warnings);
3625
+ return {
3626
+ knowledgeGraph: verifiedGraph,
3627
+ warnings,
3628
+ verificationReport: report,
3629
+ provenance: client.callLog
3630
+ };
3631
+ }
3632
+ var FeedKeywordsSchema = zod.z.object({
3633
+ all: zod.z.array(zod.z.string()).default([]),
3634
+ new: zod.z.array(zod.z.string()).default([]),
3635
+ prerequisite: zod.z.array(zod.z.string()).default([])
3636
+ });
3637
+ var FeedScaffoldCandidateSchema = zod.z.object({
3638
+ item: zod.z.string(),
3639
+ reason: zod.z.string().default(""),
3640
+ minutes_saved: zod.z.number().int().nonnegative().default(0),
3641
+ suggest: zod.z.enum(["provide_full", "provide_partial", "provide_reference"]).default("provide_full")
3642
+ });
3643
+ var FeedNodeSchema = zod.z.object({
3644
+ id: zod.z.string().min(1),
3645
+ kind: zod.z.enum(["skill", "concept", "product_step"]),
3646
+ name: zod.z.string().min(1),
3647
+ concept_codes: zod.z.array(zod.z.string()).default([]),
3648
+ keywords: FeedKeywordsSchema,
3649
+ estimated_minutes: zod.z.number().int().nonnegative(),
3650
+ bloom_hint: zod.z.enum(["Remember", "Understand", "Apply", "Analyze", "Evaluate", "Create"]).nullable().default(null),
3651
+ depth_hint: zod.z.enum(["ulo", "cio", "sio"]).nullable().default(null),
3652
+ completion_level: zod.z.enum(["mvp", "standard", "stretch"]).default("standard"),
3653
+ scaffolding: zod.z.object({
3654
+ scaffold_candidates: zod.z.array(FeedScaffoldCandidateSchema).default([]),
3655
+ core_value_minutes: zod.z.number().int().nonnegative().default(0)
3656
+ }).default({ scaffold_candidates: [], core_value_minutes: 0 }),
3657
+ references: zod.z.array(zod.z.object({ file: zod.z.string().min(1), evidence: zod.z.string().default("") })).default([]),
3658
+ feature_id: zod.z.string().nullable().default(null),
3659
+ concept_ref: zod.z.string().nullable().default(null),
3660
+ // P52/T2.1: outcome.user_visible projected from the ProjectGraph step — the
3661
+ // tangible checkpoint the session-cutting planner aims for (empty when the
3662
+ // step declares no user-visible result). Concepts carry '' by default.
3663
+ user_visible_deliverable: zod.z.string().default(""),
3664
+ // P52/T3.1 — depth-reconcile inputs (optional/defaulted so old feeds
3665
+ // validate unchanged):
3666
+ // • depth_variants: minutes to teach this node at each depth level.
3667
+ // ULO (know-it-exists) ≤ CIO (mechanism) ≤ SIO (implementation). All 3
3668
+ // required so a downgrade never turns an anchor teach into a zero.
3669
+ // • depth_scaffold_candidates: parallel to scaffold_candidates but
3670
+ // acts on DEPTH (e.g. SIO→CIO) instead of lesson time.
3671
+ // • is_core: concept is a Master-Tree core concept — reconciler must
3672
+ // never downgrade it; escalate instead.
3673
+ depth_variants: zod.z.object({ ulo: zod.z.number().int().nonnegative(), cio: zod.z.number().int().nonnegative(), sio: zod.z.number().int().nonnegative() }).optional(),
3674
+ depth_scaffold_candidates: zod.z.array(zod.z.object({
3675
+ from_depth: zod.z.enum(["ulo", "cio", "sio"]),
3676
+ to_depth: zod.z.enum(["ulo", "cio", "sio"]),
3677
+ minutes_saved: zod.z.number().int().nonnegative(),
3678
+ reason: zod.z.string().default("")
3679
+ })).optional(),
3680
+ is_core: zod.z.boolean().default(false),
3681
+ /**
3682
+ * Prerequisite concept ids this node needs that NO node in this feed provides —
3683
+ * i.e. the learner must already know them before starting. Previously these were
3684
+ * silently dropped (knowledge path) or crashed emission (both hybrid paths), so a
3685
+ * course silently lost its entry requirements. Kept on the node so the mapping
3686
+ * "concept X needs prior knowledge Y" survives; a course-level roll-up is derivable.
3687
+ */
3688
+ external_prerequisites: zod.z.array(zod.z.string()).default([]),
3689
+ // Depth layer text (optional, additive — old feeds validate unchanged).
3690
+ // Carries the ULO/CIO/SIO authored text from the KnowledgeGraph concept
3691
+ // so downstream consumers (KX generator) can inject depth-appropriate
3692
+ // material into their prompts without a second LLM call.
3693
+ depth_context: zod.z.object({
3694
+ ulo: zod.z.string().default(""),
3695
+ cio: zod.z.string().default(""),
3696
+ sio: zod.z.array(zod.z.string()).default([])
3697
+ }).optional(),
3698
+ // Progressive-completion bookkeeping (feed v2, additive):
3699
+ // phase_id = development phase that introduces/uses this node;
3700
+ // introduce_aspect = how the phase teaches a concept node (first intro, advanced
3701
+ // revisit, or preview — a light ULO awareness pass that is NOT the introduction).
3702
+ phase_id: zod.z.string().default(""),
3703
+ introduce_aspect: zod.z.enum(["new", "advanced", "preview"]).nullable().default(null)
3704
+ });
3705
+ var FeedEdgeSchema = zod.z.object({
3706
+ from: zod.z.string().min(1),
3707
+ to: zod.z.string().min(1),
3708
+ kind: zod.z.enum(["knowledge", "task"]),
3709
+ reason: zod.z.string().default("")
3710
+ });
3711
+ var FeedGroupingSchema = zod.z.object({
3712
+ id: zod.z.string().min(1),
3713
+ name: zod.z.string().min(1),
3714
+ node_ids: zod.z.array(zod.z.string()).min(1),
3715
+ rationale: zod.z.string().default("")
3716
+ });
3717
+ var FeedPhaseSchema = zod.z.object({
3718
+ id: zod.z.string().min(1),
3719
+ order: zod.z.number().int().positive(),
3720
+ name: zod.z.string().min(1),
3721
+ product_completion: zod.z.string().min(1),
3722
+ node_ids: zod.z.array(zod.z.string()).min(1)
3723
+ });
3724
+ var FeedDiagnosticSchema = zod.z.object({
3725
+ code: zod.z.string().min(1),
3726
+ severity: zod.z.enum(["info", "warning", "error"]),
3727
+ message: zod.z.string(),
3728
+ node_ids: zod.z.array(zod.z.string()).default([])
3729
+ });
3730
+ var FeedProvenanceSchema = zod.z.object({
3731
+ providers: zod.z.array(zod.z.string()).default([]),
3732
+ models: zod.z.array(zod.z.string()).default([]),
3733
+ calls: zod.z.number().int().nonnegative().default(0),
3734
+ failed_calls: zod.z.number().int().nonnegative().default(0),
3735
+ degraded: zod.z.boolean().default(false)
3736
+ });
3737
+ var CurriculumFeedSchema = zod.z.object({
3738
+ schema_version: zod.z.literal(2),
3739
+ source: zod.z.object({
3740
+ graph_type: zod.z.enum(["project_graph", "knowledge_graph", "hybrid_graph"]),
3741
+ /** DERIVED from diagnostics — do not write to it directly. */
3742
+ warnings: zod.z.array(zod.z.string()).default([]),
3743
+ hallucination_count: zod.z.number().int().nonnegative().default(0),
3744
+ diagnostics: zod.z.array(FeedDiagnosticSchema).default([]),
3745
+ provenance: FeedProvenanceSchema.optional()
3746
+ }),
3747
+ learning_nodes: zod.z.array(FeedNodeSchema).min(1, "curriculum feed must contain at least one learning node"),
3748
+ dependency_edges: zod.z.array(FeedEdgeSchema).default([]),
3749
+ suggested_groupings: zod.z.array(FeedGroupingSchema).default([]),
3750
+ // Progressive completion phases (cross-feature). When non-empty, these — not
3751
+ // suggested_groupings — define the teaching sequence and unit structure.
3752
+ phases: zod.z.array(FeedPhaseSchema).default([])
3753
+ });
3754
+ function validateCurriculumFeed(feed) {
3755
+ const parsed = CurriculumFeedSchema.parse(feed);
3756
+ const ids = new Set(parsed.learning_nodes.map((n) => n.id));
3757
+ if (ids.size !== parsed.learning_nodes.length) {
3758
+ throw new Error("curriculum feed contains duplicate node ids");
3759
+ }
3760
+ for (const e of parsed.dependency_edges) {
3761
+ if (!ids.has(e.from) || !ids.has(e.to)) {
3762
+ throw new Error("curriculum feed edge " + e.from + " -> " + e.to + " references unknown node");
3763
+ }
3764
+ if (e.from === e.to) throw new Error("curriculum feed self-loop edge on " + e.from);
3765
+ }
3766
+ for (const g of parsed.suggested_groupings) {
3767
+ for (const id of g.node_ids) {
3768
+ if (!ids.has(id)) throw new Error("grouping " + g.id + " references unknown node " + id);
3769
+ }
3770
+ }
3771
+ for (const ph of parsed.phases) {
3772
+ for (const id of ph.node_ids) {
3773
+ if (!ids.has(id)) throw new Error("phase " + ph.id + " references unknown node " + id);
3774
+ }
3775
+ }
3776
+ const phaseIds = parsed.phases.map((p) => p.id);
3777
+ if (new Set(phaseIds).size !== phaseIds.length) throw new Error("curriculum feed contains duplicate phase ids");
3778
+ const phaseOrders = parsed.phases.map((p) => p.order);
3779
+ if (new Set(phaseOrders).size !== phaseOrders.length) {
3780
+ throw new Error("curriculum feed contains duplicate phase orders: " + phaseOrders.join(", "));
3781
+ }
3782
+ for (let i = 1; i < phaseOrders.length; i++) {
3783
+ if (phaseOrders[i] <= phaseOrders[i - 1]) {
3784
+ throw new Error("curriculum feed phase orders are not ascending: " + phaseOrders.join(", "));
3785
+ }
3786
+ }
3787
+ if (parsed.phases.length > 0) {
3788
+ const phaseIndexOf = /* @__PURE__ */ new Map();
3789
+ parsed.phases.forEach((p, i) => {
3790
+ for (const id of p.node_ids) phaseIndexOf.set(id, i);
3791
+ });
3792
+ for (const e of parsed.dependency_edges) {
3793
+ const fromIdx = phaseIndexOf.get(e.from);
3794
+ const toIdx = phaseIndexOf.get(e.to);
3795
+ if (fromIdx === void 0 || toIdx === void 0) continue;
3796
+ if (fromIdx > toIdx) {
3797
+ throw new Error(
3798
+ "curriculum feed teaching-order violation: prerequisite " + e.from + " (phase " + (fromIdx + 1) + ") is scheduled AFTER its consumer " + e.to + " (phase " + (toIdx + 1) + ")"
3799
+ );
3800
+ }
3801
+ }
3802
+ }
3803
+ return parsed;
3804
+ }
3805
+
3806
+ // src/feed/curriculumFeedEmitter.ts
3807
+ var DEPTH_MINUTE_FRACTIONS = {
3808
+ /** Awareness-only pass before the anchor teach. */
3809
+ preview: 0.25,
3810
+ ulo: 0.35,
3811
+ cio: 0.65,
3812
+ /** An advanced revisit re-teaches the concept, not the whole thing again. */
3813
+ advancedRevisit: 0.5,
3814
+ /** Minutes saved when an advanced revisit drops from SIO practice to a CIO recap. */
3815
+ advancedRecapSave: 0.3,
3816
+ /** Floor so a tiny concept still gets a teachable awareness pass. */
3817
+ floor: 5,
3818
+ /** Floor for an advanced revisit — it is a real teach, not an awareness pass. */
3819
+ advancedFloor: 15
3820
+ };
3821
+ var DEFAULT_STEP_MINUTES = 15;
3822
+ var BLOOM_RANK = { Remember: 0, Understand: 1, Apply: 2, Analyze: 3, Evaluate: 4, Create: 5 };
3823
+ var pad22 = (n) => String(n).padStart(2, "0");
3824
+ var steplessStepIds = [];
3825
+ var takeSteplessStepIds = () => steplessStepIds.splice(0, steplessStepIds.length);
3826
+ var arr = (v) => Array.isArray(v) ? v : [];
3827
+ var str = (v, d = "") => typeof v === "string" ? v : d;
3828
+ var num = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
3829
+ var strs = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
3830
+ function stepMinutes(step) {
3831
+ const effort = step.effort ?? {};
3832
+ return Math.max(0, Math.round(num(effort.estimated_minutes, num(step.estimated_minutes, DEFAULT_STEP_MINUTES))));
3833
+ }
3834
+ function diagnosticsOf(opts) {
3835
+ const list = opts.diagnostics ?? (opts.diagnostics = []);
3836
+ for (const message of opts.warnings ?? []) {
3837
+ if (!list.some((d) => d.code === "upstream_warning" && d.message === message)) {
3838
+ list.push({ code: "upstream_warning", severity: "warning", message, node_ids: [] });
3839
+ }
3840
+ }
3841
+ return list;
3842
+ }
3843
+ function pushDiagnostic(opts, code, severity, message, nodeIds = []) {
3844
+ diagnosticsOf(opts).push({ code, severity, message, node_ids: nodeIds });
3845
+ }
3846
+ function splitPrerequisites(concept, known) {
3847
+ const internal = [];
3848
+ const external = [];
3849
+ for (const p of strs(concept.prerequisites)) {
3850
+ (known.has(p) ? internal : external).push(p);
3851
+ }
3852
+ return { internal, external };
3853
+ }
3854
+ function noteExternalPrerequisites(opts, byNode) {
3855
+ if (byNode.size === 0) return;
3856
+ const entries = [];
3857
+ for (const [nodeId, prereqs] of byNode) {
3858
+ for (const prereqId of prereqs) entries.push({ nodeId, prereqId });
3859
+ }
3860
+ entries.sort((a, b) => a.nodeId === b.nodeId ? a.prereqId.localeCompare(b.prereqId) : a.nodeId.localeCompare(b.nodeId));
3861
+ pushDiagnostic(
3862
+ opts,
3863
+ "external_prerequisite",
3864
+ "info",
3865
+ "Prerequisite(s) outside this analysis \u2014 the learner must already know them: " + entries.map((e) => e.nodeId + " \u2190 " + e.prereqId).join(", "),
3866
+ entries.map((e) => e.nodeId)
3867
+ );
3868
+ }
3869
+ function buildSource(opts, graphType) {
3870
+ const diagnostics = diagnosticsOf(opts);
3871
+ return {
3872
+ graph_type: graphType,
3873
+ // Derived view — 'info' diagnostics are deliberately not surfaced as warnings.
3874
+ warnings: diagnostics.filter((d) => d.severity !== "info").map((d) => d.message),
3875
+ hallucination_count: opts.hallucinationCount ?? 0,
3876
+ diagnostics,
3877
+ provenance: opts.provenance
3878
+ };
3879
+ }
3880
+ function keywordKey(k) {
3881
+ return k.normalize("NFC").toLowerCase();
3882
+ }
3883
+ function newKeywordsInOrder(nodes) {
3884
+ const seen = /* @__PURE__ */ new Set();
3885
+ for (const n of nodes) {
3886
+ const all = n.keywords.all;
3887
+ n.keywords.new = [];
3888
+ n.keywords.prerequisite = [];
3889
+ for (const k of all) {
3890
+ if (seen.has(keywordKey(k))) n.keywords.prerequisite.push(k);
3891
+ else n.keywords.new.push(k);
3892
+ seen.add(keywordKey(k));
3893
+ }
3894
+ }
3895
+ }
3896
+ function feedNodeFromStep(step, featureId) {
3897
+ const id = str(step.id);
3898
+ return {
3899
+ id,
3900
+ kind: "product_step",
3901
+ name: str(step.name, id),
3902
+ concept_codes: strs(step.concept_codes),
3903
+ keywords: { all: strs(step.keywords), new: [], prerequisite: [] },
3904
+ estimated_minutes: stepMinutes(step),
3905
+ bloom_hint: "Apply",
3906
+ depth_hint: null,
3907
+ completion_level: "standard",
3908
+ scaffolding: { scaffold_candidates: [], core_value_minutes: stepMinutes(step) },
3909
+ references: arr(step.files).map((f) => ({ file: str(f), evidence: "step file" })).filter((f) => f.file),
3910
+ feature_id: featureId,
3911
+ concept_ref: null,
3912
+ // P52/T2.1: carry the step's tangible outcome so the planner can cut
3913
+ // sessions at user-visible checkpoints instead of raw minute counts.
3914
+ user_visible_deliverable: str(step.outcome?.user_visible),
3915
+ is_core: false,
3916
+ external_prerequisites: [],
3917
+ phase_id: "",
3918
+ introduce_aspect: null
3919
+ };
3920
+ }
3921
+ function deriveConceptDepthHint(concept) {
3922
+ const explicit = str(concept.depth_hint);
3923
+ if (explicit === "ulo" || explicit === "cio" || explicit === "sio") return explicit;
3924
+ const sio = arr(concept.sio);
3925
+ const prereqs = strs(concept.prerequisites);
3926
+ const pts = arr(concept.problem_types);
3927
+ const hasHighBloom = pts.some((p) => {
3928
+ const b = str(p.bloom_level);
3929
+ return b === "Apply" || b === "Analyze" || b === "Evaluate" || b === "Create";
3930
+ });
3931
+ if (sio.length >= 2 || hasHighBloom) return "sio";
3932
+ if (str(concept.cio) || prereqs.length >= 1) return "cio";
3933
+ return "ulo";
3934
+ }
3935
+ function deriveDepthInputs(minutes, depth) {
3936
+ const ulo = Math.max(DEPTH_MINUTE_FRACTIONS.floor, Math.round(minutes * DEPTH_MINUTE_FRACTIONS.ulo));
3937
+ const cio = Math.max(ulo + DEPTH_MINUTE_FRACTIONS.floor, Math.round(minutes * DEPTH_MINUTE_FRACTIONS.cio));
3938
+ const sio = Math.max(cio + DEPTH_MINUTE_FRACTIONS.floor, minutes);
3939
+ const variants = { ulo, cio, sio };
3940
+ const candidates = [];
3941
+ if (depth === "sio") {
3942
+ candidates.push({ from_depth: "sio", to_depth: "cio", minutes_saved: sio - cio, reason: "SIO implementation practice deferred \u2014 CIO mechanism suffices for downstream consumers" });
3943
+ }
3944
+ if (depth === "cio" || depth === "sio") {
3945
+ candidates.push({ from_depth: depth, to_depth: "ulo", minutes_saved: (depth === "sio" ? sio : cio) - ulo, reason: "Mechanism deferred \u2014 ULO awareness suffices (know-it-exists); never below the ULO floor" });
3946
+ }
3947
+ return { depth_variants: variants, depth_scaffold_candidates: candidates };
3948
+ }
3949
+ function feedNodeFromConcept(concept) {
3950
+ const id = str(concept.id);
3951
+ const pts = arr(concept.problem_types).map((p) => str(p.bloom_level, "Understand")).filter((b) => b in BLOOM_RANK).sort((a, b) => BLOOM_RANK[a] - BLOOM_RANK[b]);
3952
+ const topBloom = pts[pts.length - 1] ?? "Understand";
3953
+ const rawRefs = arr(concept.references);
3954
+ const references = rawRefs.length > 0 ? rawRefs.map((r) => ({ file: str(r.file, "syllabus.md"), evidence: str(r.evidence, str(concept.name)) })).filter((r) => r.file) : [{ file: "syllabus.md", evidence: str(concept.category ? `Topic under category ${str(concept.category)}` : str(concept.name), id) }];
3955
+ const problemScaffolds = arr(concept.problem_types).map((pt) => ({
3956
+ item: str(pt.name, "Problem type"),
3957
+ reason: str(pt.description, "Formative practice check"),
3958
+ minutes_saved: num(pt.estimated_minutes, 10),
3959
+ suggest: "provide_partial"
3960
+ }));
3961
+ return {
3962
+ id,
3963
+ kind: "concept",
3964
+ name: str(concept.name, id),
3965
+ concept_codes: [str(concept.code) || id].filter(Boolean),
3966
+ keywords: { all: strs(concept.keywords), new: [], prerequisite: [] },
3967
+ estimated_minutes: Math.max(0, Math.round(num(concept.estimated_minutes, 15))),
3968
+ bloom_hint: topBloom,
3969
+ depth_hint: deriveConceptDepthHint(concept),
3970
+ completion_level: "standard",
3971
+ scaffolding: {
3972
+ scaffold_candidates: problemScaffolds,
3973
+ core_value_minutes: Math.max(0, Math.round(num(concept.estimated_minutes, 15)))
3974
+ },
3975
+ references,
3976
+ feature_id: null,
3977
+ concept_ref: id,
3978
+ user_visible_deliverable: "",
3979
+ // concepts are taught, not shipped — checkpoints live on product steps
3980
+ ...deriveDepthInputs(Math.max(0, Math.round(num(concept.estimated_minutes, 15))), deriveConceptDepthHint(concept)),
3981
+ // Knowledge-Tree concepts are the spine of the course — the reconciler must
3982
+ // escalate them, never downgrade them. The emitter hardcoded `false`, so the
3983
+ // flag the schema documents never reached the planner for any concept.
3984
+ is_core: concept.is_core === true || str(concept.standardization) === "standardized",
3985
+ external_prerequisites: [],
3986
+ depth_context: {
3987
+ ulo: str(concept.ulo),
3988
+ cio: str(concept.cio),
3989
+ sio: strs(concept.sio)
3990
+ },
3991
+ phase_id: "",
3992
+ introduce_aspect: null
3993
+ };
3994
+ }
3995
+ function addFeatureGroup(f, fid, nodes, edges, groupings) {
3996
+ const steps = arr(f.steps);
3997
+ const stepIds = [];
3998
+ let prev = "";
3999
+ for (let i = 0; i < steps.length; i++) {
4000
+ const stepNode = feedNodeFromStep(steps[i], fid);
4001
+ if (!stepNode.id) {
4002
+ steplessStepIds.push(fid + "#" + i);
4003
+ continue;
4004
+ }
4005
+ stepIds.push(stepNode.id);
4006
+ nodes.push(stepNode);
4007
+ if (prev) edges.push({ from: prev, to: stepNode.id, kind: "task", reason: "feature build order" });
4008
+ prev = stepNode.id;
4009
+ }
4010
+ for (const dep of strs(f.depends_on)) {
4011
+ const depFirst = nodes.find((n) => n.feature_id === dep);
4012
+ if (depFirst && stepIds[0]) edges.push({ from: depFirst.id, to: stepIds[0], kind: "task", reason: "feature depends_on " + dep });
4013
+ }
4014
+ if (stepIds.length > 0) groupings.push({ id: "G-" + fid, name: str(f.name, fid), node_ids: stepIds, rationale: str(f.description, "") });
4015
+ return stepIds;
4016
+ }
4017
+ function emitFromHybrid(hybrid, opts) {
4018
+ const nodes = [];
4019
+ const edges = [];
4020
+ const groupings = [];
4021
+ const pg = hybrid.project_graph ?? {};
4022
+ const kg = hybrid.knowledge_graph ?? {};
4023
+ const rawPhases = arr(hybrid.phases).filter((p) => str(p.id));
4024
+ const features = arr(pg.features);
4025
+ const conceptRecs = arr(kg.concepts);
4026
+ const conceptById = new Map(conceptRecs.map((c) => [str(c.id), c]));
4027
+ const knownConceptIds = new Set(conceptById.keys());
4028
+ if (rawPhases.length === 0) {
4029
+ const externalByNode2 = /* @__PURE__ */ new Map();
4030
+ for (const c of conceptRecs) {
4031
+ const node = feedNodeFromConcept(c);
4032
+ nodes.push(node);
4033
+ const { internal, external } = splitPrerequisites(c, knownConceptIds);
4034
+ if (external.length > 0) {
4035
+ node.external_prerequisites = external;
4036
+ externalByNode2.set(node.id, external);
4037
+ }
4038
+ for (const p of internal) edges.push({ from: p, to: node.id, kind: "knowledge", reason: "concept prerequisite" });
4039
+ }
4040
+ noteExternalPrerequisites(opts, externalByNode2);
4041
+ features.forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad22(fi + 1)), nodes, edges, groupings));
4042
+ linkHybridKnowledgeEdges(hybrid, nodes, edges);
4043
+ newKeywordsInOrder(nodes);
4044
+ return validateCurriculumFeed({
4045
+ schema_version: 2,
4046
+ source: buildSource(opts, "hybrid_graph"),
4047
+ learning_nodes: nodes,
4048
+ dependency_edges: edges,
4049
+ suggested_groupings: groupings,
4050
+ phases: []
4051
+ });
4052
+ }
4053
+ const featureOrder = /* @__PURE__ */ new Map();
4054
+ features.forEach((f, fi) => featureOrder.set(str(f.id, "F" + pad22(fi + 1)), fi));
4055
+ const stepIdsByFeature = /* @__PURE__ */ new Map();
4056
+ for (const f of features) {
4057
+ const fid = str(f.id);
4058
+ stepIdsByFeature.set(fid, arr(f.steps).map((s) => str(s.id)).filter(Boolean));
4059
+ }
4060
+ const emitCtx = [];
4061
+ const emittedConcepts = /* @__PURE__ */ new Set();
4062
+ const previewsEmitted = /* @__PURE__ */ new Set();
4063
+ const previewNodeIdByConcept = /* @__PURE__ */ new Map();
4064
+ const externalByNode = /* @__PURE__ */ new Map();
4065
+ const linkList = arr(hybrid.links);
4066
+ for (const rp of rawPhases) {
4067
+ const pid = str(rp.id);
4068
+ const order = num(rp.order, emitCtx.length + 1);
4069
+ const conceptOrder = [];
4070
+ const intros = arr(rp.introduces);
4071
+ const news = intros.filter((i) => str(i.aspect, "new") === "new");
4072
+ const advanced = intros.filter((i) => str(i.aspect) === "advanced");
4073
+ const previews = intros.filter((i) => str(i.aspect) === "preview");
4074
+ for (const intro of [...news, ...advanced, ...previews]) {
4075
+ const cid = str(intro.concept_id);
4076
+ const c = conceptById.get(cid);
4077
+ if (!c) {
4078
+ throw new Error("Phase " + pid + " introduces unknown concept " + cid + " - fail-closed");
4079
+ }
4080
+ if (str(intro.aspect, "new") === "advanced" && !emittedConcepts.has(cid)) {
4081
+ throw new Error("Phase " + pid + " advanced-revisits concept " + cid + " that was never emitted - fail-closed");
4082
+ }
4083
+ if (str(intro.aspect, "new") === "preview") {
4084
+ if (emittedConcepts.has(cid)) {
4085
+ throw new Error("Phase " + pid + " previews concept " + cid + " that was already introduced - preview must precede the anchor teach - fail-closed");
4086
+ }
4087
+ if (previewsEmitted.has(cid)) {
4088
+ throw new Error("Phase " + pid + " duplicate preview of concept " + cid + " - fail-closed");
4089
+ }
4090
+ const pidIdx = rawPhases.findIndex((rp2) => str(rp2.id) === pid);
4091
+ const anchorIdx = rawPhases.findIndex((rp2) => arr(rp2.introduces).some((i2) => str(i2.concept_id) === cid && str(i2.aspect, "new") !== "preview"));
4092
+ if (anchorIdx < 0) {
4093
+ throw new Error("Phase " + pid + " previews concept " + cid + " that is never fully introduced in any phase - fail-closed");
4094
+ }
4095
+ if (pidIdx >= anchorIdx) {
4096
+ throw new Error("Phase " + pid + " previews concept " + cid + " whose anchor introduction is not in a LATER phase - fail-closed");
4097
+ }
4098
+ previewsEmitted.add(cid);
4099
+ const prevNode = feedNodeFromConcept(c);
4100
+ prevNode.id = cid + "__PREV" + pad22(order);
4101
+ prevNode.name = str(c.name, cid) + " (preview)";
4102
+ prevNode.phase_id = pid;
4103
+ prevNode.introduce_aspect = "preview";
4104
+ prevNode.estimated_minutes = Math.max(DEPTH_MINUTE_FRACTIONS.floor, Math.round(prevNode.estimated_minutes * DEPTH_MINUTE_FRACTIONS.preview));
4105
+ prevNode.depth_hint = "ulo";
4106
+ prevNode.bloom_hint = "Understand";
4107
+ prevNode.concept_codes = [str(c.code) || cid];
4108
+ prevNode.keywords = { all: strs(c.keywords), new: [], prerequisite: [] };
4109
+ prevNode.scaffolding = { scaffold_candidates: [], core_value_minutes: 0 };
4110
+ prevNode.depth_variants = { ulo: prevNode.estimated_minutes, cio: prevNode.estimated_minutes, sio: prevNode.estimated_minutes };
4111
+ prevNode.depth_scaffold_candidates = [];
4112
+ nodes.push(prevNode);
4113
+ conceptOrder.push(prevNode.id);
4114
+ previewNodeIdByConcept.set(cid, prevNode.id);
4115
+ continue;
4116
+ }
4117
+ if (!emittedConcepts.has(cid)) {
4118
+ const node = feedNodeFromConcept(c);
4119
+ node.phase_id = pid;
4120
+ node.introduce_aspect = "new";
4121
+ nodes.push(node);
4122
+ for (const p of strs(c.prerequisites)) {
4123
+ if (!conceptById.has(p)) {
4124
+ if (!externalByNode.has(cid)) externalByNode.set(cid, []);
4125
+ externalByNode.get(cid).push(p);
4126
+ node.external_prerequisites.push(p);
4127
+ continue;
4128
+ }
4129
+ const previewOfPrereq = previewNodeIdByConcept.get(p);
4130
+ if (!emittedConcepts.has(p) && !previewOfPrereq) {
4131
+ throw new Error("Concept " + cid + " (phase " + pid + ") has prerequisite " + p + " that is neither introduced nor previewed in an EARLIER phase - fail-closed");
4132
+ }
4133
+ edges.push({ from: emittedConcepts.has(p) ? p : previewOfPrereq, to: cid, kind: "knowledge", reason: "concept prerequisite" });
4134
+ }
4135
+ emittedConcepts.add(cid);
4136
+ conceptOrder.push(cid);
4137
+ } else if (str(intro.aspect, "new") === "new") {
4138
+ throw new Error("Phase " + pid + " introduces " + cid + " as new but it was already introduced in an earlier phase - fail-closed");
4139
+ } else {
4140
+ const revisitId = cid + "__ADV" + pad22(order);
4141
+ const node = feedNodeFromConcept(c);
4142
+ node.id = revisitId;
4143
+ node.name = str(c.name, cid) + " (advanced)";
4144
+ node.phase_id = pid;
4145
+ node.introduce_aspect = "advanced";
4146
+ node.estimated_minutes = Math.max(
4147
+ DEPTH_MINUTE_FRACTIONS.advancedFloor,
4148
+ Math.round(node.estimated_minutes * DEPTH_MINUTE_FRACTIONS.advancedRevisit)
4149
+ );
4150
+ node.depth_scaffold_candidates = [{
4151
+ from_depth: "sio",
4152
+ to_depth: "cio",
4153
+ minutes_saved: Math.round(node.estimated_minutes * DEPTH_MINUTE_FRACTIONS.advancedRecapSave),
4154
+ reason: "advanced revisit may drop to CIO recap without breaking first-teach chain"
4155
+ }];
4156
+ node.concept_codes = [str(c.code) || cid];
4157
+ edges.push({ from: cid, to: revisitId, kind: "knowledge", reason: "advanced revisit of " + cid + " at phase " + pid });
4158
+ nodes.push(node);
4159
+ conceptOrder.push(revisitId);
3808
4160
  }
3809
- finalDecisions.push({ ...d, id: c.id, proposed_code: code });
3810
- proposals.push({
3811
- code,
3812
- name: c.name,
3813
- description: d.proposed_definition || c.description.slice(0, 300),
3814
- keywords: c.keywords,
3815
- subject_codes: "PY",
3816
- category_codes: "",
3817
- topic_codes: "",
3818
- prerequisite_concept_codes: [],
3819
- status: "proposed",
3820
- source_concept_id: c.id,
3821
- possible_tree_matches: (d.possible_tree_matches || []).filter(Boolean)
3822
- });
3823
- warnings.push("Concept " + c.id + " proposed as NEW: " + code + (d.possible_tree_matches?.length ? " (near matches: " + d.possible_tree_matches.join(", ") + ")" : ""));
3824
- }
3825
- }
3826
- const outPath = proposalsPath || path.join(os.tmpdir(), "domain-kit-debug", "concept-proposals.json");
3827
- try {
3828
- fs.mkdirSync(outPath.slice(0, outPath.lastIndexOf("/")), { recursive: true });
3829
- fs.writeFileSync(outPath, JSON.stringify({ generated_at: (/* @__PURE__ */ new Date()).toISOString(), proposals }, null, 2));
3830
- log("STEP_2_5", "Proposals written: " + proposals.length + " -> " + outPath);
3831
- } catch (e) {
3832
- warnings.push("Could not write proposals artifact: " + (e instanceof Error ? e.message : String(e)));
3833
- }
3834
- return { decisions: finalDecisions, proposals, warnings, shortlistSize: shortlist.length, catalogSize: catalog.concepts.length };
3835
- }
3836
-
3837
- // src/graph/knowledgeGraphPipeline.ts
3838
- function topologicalSort(concepts) {
3839
- const inDegree = /* @__PURE__ */ new Map();
3840
- const adjacency = /* @__PURE__ */ new Map();
3841
- for (const concept of concepts) {
3842
- inDegree.set(concept.id, 0);
3843
- adjacency.set(concept.id, []);
3844
- }
3845
- for (const concept of concepts) {
3846
- for (const prereq of concept.prerequisites || []) {
3847
- adjacency.get(prereq)?.push(concept.id);
3848
- inDegree.set(concept.id, (inDegree.get(concept.id) || 0) + 1);
3849
4161
  }
4162
+ const featureIds = strs(rp.feature_ids).filter((fid) => featureOrder.has(fid)).sort((a, b) => featureOrder.get(a) - featureOrder.get(b));
4163
+ emitCtx.push({ id: pid, order, name: str(rp.name, pid), productCompletion: str(rp.product_completion), conceptOrder, featureIds });
3850
4164
  }
3851
- const queue = [];
3852
- for (const [id, degree] of inDegree) {
3853
- if (degree === 0) queue.push(id);
3854
- }
3855
- const sorted = [];
3856
- while (queue.length > 0) {
3857
- const current = queue.shift();
3858
- sorted.push(current);
3859
- for (const neighbor of adjacency.get(current) || []) {
3860
- const newDegree = (inDegree.get(neighbor) || 1) - 1;
3861
- inDegree.set(neighbor, newDegree);
3862
- if (newDegree === 0) queue.push(neighbor);
4165
+ noteExternalPrerequisites(opts, externalByNode);
4166
+ for (const l of linkList) {
4167
+ const cid = str(l.concept_id);
4168
+ if (cid && conceptById.has(cid) && !emittedConcepts.has(cid)) {
4169
+ throw new Error("Concept " + cid + " is consumed by links but never introduced in any phase - fail-closed");
3863
4170
  }
3864
4171
  }
3865
- if (sorted.length !== concepts.length) {
3866
- console.warn(`[WARN] Topological sort incomplete: ${sorted.length}/${concepts.length} concepts sorted`);
3867
- }
3868
- return sorted;
3869
- }
3870
- async function generateKnowledgeGraph(options) {
3871
- const { subject, description, syllabusText, gradeBand, domain, techStack, llmConfig, onProgress } = options;
3872
- const warnings = [];
3873
- const log = (step, msg) => {
3874
- console.log(`[KG-${step}] ${msg}`);
3875
- onProgress?.(step, msg);
3876
- };
3877
- log("STEP_1", "Decomposing subject into concepts...");
3878
- let parsedSyllabus = options.parsedSyllabus;
3879
- if (!parsedSyllabus && syllabusText) {
3880
- parsedSyllabus = parseSyllabus(syllabusText);
3881
- log("STEP_1", `Parsed syllabus: ${parsedSyllabus.units.length} units, ${parsedSyllabus.allTopics.length} topics`);
4172
+ const seenFeatures = new Set(emitCtx.flatMap((e) => e.featureIds));
4173
+ const missingFeatures = features.map((f) => str(f.id)).filter((fid) => fid && !seenFeatures.has(fid));
4174
+ if (missingFeatures.length > 0) {
4175
+ throw new Error("Features not covered by any phase: " + missingFeatures.join(", ") + " - fail-closed");
3882
4176
  }
3883
- const client = createLlmClient(llmConfig);
3884
- const maxSyllabusChars = parseInt(process.env.KG_MAX_SYLLABUS_CHARS || "16000", 10);
3885
- let truncatedSyllabus = "";
3886
- if (syllabusText) {
3887
- if (syllabusText.length > maxSyllabusChars) {
3888
- truncatedSyllabus = syllabusText.slice(0, maxSyllabusChars);
3889
- warnings.push(`Syllabus truncated from ${syllabusText.length} to ${maxSyllabusChars} chars \u2014 may miss some concepts`);
3890
- } else {
3891
- truncatedSyllabus = syllabusText;
3892
- }
4177
+ const orphans = Array.from(conceptById.keys()).filter((cid) => !emittedConcepts.has(cid));
4178
+ if (orphans.length > 0) {
4179
+ pushDiagnostic(
4180
+ opts,
4181
+ "orphan_concept",
4182
+ "error",
4183
+ "Knowledge graph has " + orphans.length + " concept(s) no phase introduces and no link consumes \u2014 they will not be taught: " + orphans.join(", "),
4184
+ orphans
4185
+ );
3893
4186
  }
3894
- const structuredTopicsGuide = parsedSyllabus && parsedSyllabus.allTopics.length > 0 ? `
3895
- STRUCTURED SYLLABUS TOPICS (Every topic MUST map to at least one concept to ensure 100% syllabus coverage):
3896
- ` + parsedSyllabus.allTopics.map((t) => `- [${t.unitTitle}] ${t.id}: ${t.title} (Keywords: ${t.keywords.join(", ")})`).join("\n") + "\n" : "";
3897
- const systemPrompt = "You are an expert curriculum designer. Decompose the given subject into a structured knowledge graph with concepts, prerequisites, and problem types.\n\nCRITICAL RULES:\n1. Each concept MUST have the three depth layers, authored against this CONTRACT:\n - ulo (WHAT + WHY): bound to the CONCEPT's intrinsic NATURE. It explains what the\n concept is and why it exists \u2014 the problem it solves in principle. It must be\n TECHNOLOGY-AGNOSTIC: never name a concrete library, API, function, file format,\n tool or language feature. Typical assessment level: Remember / Understand.\n - cio (HOW): the MECHANISM \u2014 how the concept works and solves that problem, step\n by step. Still bound to the concept's nature: more concrete than the ULO, but\n NOT tied to one specific technology (no concrete API names). CIO capability is\n assessed INDIRECTLY, through completing contextual work, not in isolation.\n - sio (IMPLEMENTATION): an array of 2-4 items. EVERY item MUST reference concrete\n KEYWORDS/technologies (a real API, function, module, file, or named pattern) and\n describe a completable, context-specific implementation task. Application,\n Analysis and Creation skills are assessed INDIRECTLY through completing these\n items. There is NO fixed Bloom ceiling for a layer \u2014 but concreteness MUST\n strictly increase: SIO more technology-bound than CIO, CIO more concrete than ULO.\n - prerequisites: concept IDs that must be learned first (MUST NOT create cycles)\n - problem_types: 2-4 problem types with bloom_level and difficulty. Remember and\n Understand problem types test the ULO/CIO directly; Apply-and-above types are\n proxies for SIO completion in context.\n - techniques: methods, formulas, tools\n2. Prerequisite chains MUST be acyclic (no concept can depend on itself or create a loop)\n3. Concepts should be ordered by prerequisite dependency (topological sort)\n4. Problem types should range from Remember to Apply (not all at same level)\n5. Group concepts into categories (chapters/sections)\n6. ALL text in ENGLISH (even if input is Vietnamese)\n7. NEVER invent concepts not present in the syllabus\n8. Return JSON matching the KnowledgeGraphSchema";
3898
- const userPrompt = `Subject: ${subject}
3899
- ${description ? `Description: ${description}` : ""}
3900
- ${structuredTopicsGuide}${truncatedSyllabus ? `Syllabus:
3901
- ${truncatedSyllabus}` : ""}
3902
- ${gradeBand ? `Grade band: ${gradeBand[0]}-${gradeBand[1]}` : ""}
3903
- ${domain ? `Domain: ${domain}` : ""}
3904
-
3905
- Return JSON:
3906
- {
3907
- "subject": {
3908
- "id": "${subject.toLowerCase().replace(/\s+/g, "-").slice(0, 30)}",
3909
- "name": "${subject}",
3910
- "description": "...",
3911
- "domain": "${domain || "other"}",
3912
- "grade_band": ${gradeBand ? `[${gradeBand[0]}, ${gradeBand[1]}]` : "[6, 12]"}
3913
- },
3914
- "concepts": [
3915
- {
3916
- "id": "C1",
3917
- "name": "Concept name",
3918
- "description": "Brief description",
3919
- "category": "category_id",
3920
- "ulo": "WHAT + WHY in technology-agnostic language: what the concept is and the problem it exists to solve. No library/API/tool names.",
3921
- "cio": "HOW the mechanism works, still technology-agnostic but more concrete than the ULO.",
3922
- "sio": ["Concrete task naming a REAL API/function/module/file from the target technology, completable end to end", "Another technology-bound task using a different concrete keyword"],
3923
- "prerequisites": [],
3924
- "estimated_minutes": 30,
3925
- "problem_types": [
3926
- {
3927
- "id": "PT-C1-01",
3928
- "name": "Problem type name",
3929
- "description": "What this problem type tests",
3930
- "bloom_level": "Remember",
3931
- "difficulty": "easy",
3932
- "estimated_minutes": 10
3933
- }
3934
- ],
3935
- "techniques": ["technique 1", "technique 2"],
3936
- "common_mistakes": ["mistake 1"]
4187
+ const phaseNodeIds = [];
4188
+ for (const ctx of emitCtx) {
4189
+ const ids = [...ctx.conceptOrder];
4190
+ for (const fid of ctx.featureIds) {
4191
+ const f = features.find((x) => str(x.id) === fid);
4192
+ const stepIds = addFeatureGroup(f, fid, nodes, edges, groupings);
4193
+ for (const sid of stepIds) {
4194
+ const node = nodes.find((x) => x.id === sid);
4195
+ node.phase_id = ctx.id;
4196
+ }
4197
+ ids.push(...stepIds);
3937
4198
  }
3938
- ],
3939
- "categories": [
3940
- {
3941
- "id": "cat1",
3942
- "name": "Category name",
3943
- "description": "Category description",
3944
- "concept_ids": ["C1", "C2"],
3945
- "order": 1
4199
+ if (ids.length === 0) {
4200
+ throw new Error("Phase " + ctx.id + " contains no teaching nodes (no concepts introduced and no feature steps) - drop it or give it work - fail-closed");
3946
4201
  }
3947
- ],
3948
- "learning_path": [
3949
- {"concept_id": "C1", "order": 1, "depends_on": []},
3950
- {"concept_id": "C2", "order": 2, "depends_on": ["C1"]}
3951
- ]
3952
- }`;
3953
- const result = await llmChatJson(client, systemPrompt, userPrompt, {
3954
- temperature: 0.1,
3955
- maxTokens: parseInt(process.env.KG_MAX_TOKENS || "65536", 10)
3956
- });
3957
- log("STEP_2", "Validating and cleaning knowledge graph...");
3958
- const concepts = result.concepts || [];
3959
- const categories = result.categories || [];
3960
- {
3961
- 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"]);
3962
- const tokensFrom = (text) => text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4 && !STOP.has(w));
3963
- for (const c of concepts) {
3964
- const existing = (c.keywords || []).map((k) => String(k).trim()).filter(Boolean);
3965
- if (existing.length === 0) {
3966
- const derived = [.../* @__PURE__ */ new Set([...tokensFrom(c.name), ...tokensFrom(c.description || "")])].slice(0, 5);
3967
- if (derived.length === 0) {
3968
- throw new Error("Concept " + c.id + " (" + c.name + ") has no keywords and none could be derived from name/description \u2014 fail-closed");
3969
- }
3970
- c.keywords = derived;
3971
- warnings.push("Concept " + c.id + ": empty keyword set - derived " + derived.join(", ") + " from name/description");
3972
- } else {
3973
- c.keywords = existing;
3974
- }
4202
+ phaseNodeIds.push(ids);
4203
+ }
4204
+ const idlessInHybrid = takeSteplessStepIds();
4205
+ if (idlessInHybrid.length > 0) {
4206
+ pushDiagnostic(
4207
+ opts,
4208
+ "step_without_id",
4209
+ "warning",
4210
+ "Step(s) carrying no id cannot become planning nodes and were dropped (feature#index): " + idlessInHybrid.join(", "),
4211
+ idlessInHybrid
4212
+ );
4213
+ }
4214
+ const placed = new Set(phaseNodeIds.flat());
4215
+ const orderedNodes = [];
4216
+ for (const ids of phaseNodeIds) {
4217
+ for (const id of ids) {
4218
+ const n = nodes.find((x) => x.id === id);
4219
+ if (n) orderedNodes.push(n);
3975
4220
  }
3976
4221
  }
3977
- const rawPath = result.learning_path;
3978
- let learningPath = (rawPath || []).map((s) => ({
3979
- concept_id: String(s.concept_id || ""),
3980
- order: Number(s.order || 0),
3981
- depends_on: Array.isArray(s.depends_on) ? s.depends_on.map(String) : [],
3982
- milestone_name: String(s.milestone_name || "")
3983
- }));
3984
- const conceptIds = new Set(concepts.map((c) => c.id));
3985
- for (const concept of concepts) {
3986
- for (const prereq of concept.prerequisites || []) {
3987
- if (!conceptIds.has(prereq)) {
3988
- warnings.push(`Concept ${concept.id} references non-existent prerequisite ${prereq} \u2014 removed`);
3989
- concept.prerequisites = (concept.prerequisites || []).filter((p) => p !== prereq);
4222
+ for (const n of nodes) if (!placed.has(n.id)) orderedNodes.push(n);
4223
+ nodes.length = 0;
4224
+ nodes.push(...orderedNodes);
4225
+ const edgeSeen = new Set(edges.map((e) => e.from + ">" + e.to));
4226
+ const pushEdge = (from, to, reason) => {
4227
+ const key = from + ">" + to;
4228
+ if (edgeSeen.has(key)) return;
4229
+ edgeSeen.add(key);
4230
+ edges.push({ from, to, kind: "knowledge", reason });
4231
+ };
4232
+ for (const l of linkList) {
4233
+ const cid = str(l.concept_id);
4234
+ const fid = str(l.feature_id);
4235
+ if (!conceptById.has(cid)) continue;
4236
+ const firstStep = (stepIdsByFeature.get(fid) || [])[0];
4237
+ if (!firstStep) continue;
4238
+ const depth = str(l.depth, "cio");
4239
+ const anchorNode = nodes.find((n2) => n2.kind === "concept" && n2.concept_ref === cid && n2.introduce_aspect === "new");
4240
+ const stepNode = nodes.find((n2) => n2.id === firstStep);
4241
+ const anchorPhaseIdx = anchorNode ? emitCtx.findIndex((c2) => c2.id === anchorNode.phase_id) : -1;
4242
+ const stepPhaseIdx = stepNode ? emitCtx.findIndex((c2) => c2.id === stepNode.phase_id) : -1;
4243
+ if (emitCtx.length > 0 && anchorPhaseIdx > stepPhaseIdx) {
4244
+ pushDiagnostic(
4245
+ opts,
4246
+ "hybrid_link_drift",
4247
+ "warning",
4248
+ "hybrid link drift: concept " + cid + " is fully taught in a later phase than consumer " + firstStep + " (a preview covers the dependency)",
4249
+ [cid, firstStep]
4250
+ );
4251
+ continue;
4252
+ }
4253
+ pushEdge(cid, firstStep, str(l.relationship, "hybrid link") + " [" + depth + "]");
4254
+ for (const n of nodes) {
4255
+ if (n.kind === "concept" && n.concept_ref === cid && n.introduce_aspect === "advanced" && n.phase_id === str(l.phase_id)) {
4256
+ pushEdge(n.id, firstStep, "advanced application [" + depth + "]");
3990
4257
  }
3991
4258
  }
3992
4259
  }
3993
- if (options.knowledgeTreePath) {
3994
- const catalog = loadKnowledgeTreeCatalog(options.knowledgeTreePath);
3995
- log("STEP_2_5", "Knowledge tree loaded: " + catalog.concepts.length + " concepts from " + catalog.path.split("/").pop());
3996
- const std = await standardizeConcepts({
3997
- client,
3998
- concepts: concepts.map((c) => ({
3999
- id: c.id,
4000
- name: c.name,
4001
- description: c.description || "",
4002
- keywords: c.keywords || []
4003
- })),
4004
- catalog,
4005
- techStack,
4006
- proposalsPath: options.proposalsPath,
4007
- onProgress: log
4008
- });
4009
- warnings.push(...std.warnings);
4010
- const codeByStd = new Map(std.decisions.map((d) => [d.id, d]));
4011
- for (const c of concepts) {
4012
- const d = codeByStd.get(c.id);
4013
- if (!d) throw new Error("Concept " + c.id + " missing standardization decision \u2014 fail-closed");
4014
- if (d.decision === "standardized") {
4015
- c.standardization = "standardized";
4016
- c.tree_code = d.tree_code;
4017
- c.code = d.tree_code;
4018
- const tc = catalog.concepts.find((x) => x.code === d.tree_code);
4019
- if (tc) {
4020
- for (const k of d.adopted_keywords || []) {
4021
- if (!(c.keywords || []).some((x) => x.toLowerCase() === k.toLowerCase())) {
4022
- c.keywords = [...c.keywords || [], k];
4023
- }
4024
- }
4025
- c.tree_prerequisites = tc.prerequisiteCodes;
4026
- }
4027
- } else {
4028
- c.standardization = "proposed_new";
4029
- c.code = d.proposed_code;
4260
+ newKeywordsInOrder(nodes);
4261
+ const feedOrder = new Map(nodes.map((n, i) => [n.id, i]));
4262
+ const introducer = /* @__PURE__ */ new Map();
4263
+ for (const n of nodes) for (const k of n.keywords?.new ?? []) {
4264
+ const key = keywordKey(k);
4265
+ if (!introducer.has(key)) introducer.set(key, n.id);
4266
+ }
4267
+ for (const n of nodes) {
4268
+ for (const k of n.keywords?.prerequisite ?? []) {
4269
+ const src = introducer.get(keywordKey(k));
4270
+ if (!src || src === n.id) continue;
4271
+ const srcNode = nodes.find((x) => x.id === src);
4272
+ const phaseOf = (id) => {
4273
+ const n2 = nodes.find((x) => x.id === id);
4274
+ return n2 ? emitCtx.findIndex((c) => c.id === n2.phase_id) : -1;
4275
+ };
4276
+ const conceptOvertaken = n.kind === "concept" && srcNode?.kind === "product_step";
4277
+ const fromPhase = phaseOf(src);
4278
+ const toPhase = phaseOf(n.id);
4279
+ if (conceptOvertaken || emitCtx.length > 0 && fromPhase > toPhase) {
4280
+ pushDiagnostic(
4281
+ opts,
4282
+ conceptOvertaken ? "concept_overtaken_by_step" : "keyword_order_drift",
4283
+ "warning",
4284
+ "keyword-order drift: introducer " + src + " sits in a later phase than consumer " + n.id + ' (keyword "' + k + '") \u2014 host should move the introducer earlier',
4285
+ [src, n.id]
4286
+ );
4287
+ continue;
4030
4288
  }
4289
+ if (feedOrder.get(src) < feedOrder.get(n.id)) pushEdge(src, n.id, "keyword prerequisite [" + k + "]");
4031
4290
  }
4032
- const idByTreeCode = new Map(concepts.filter((c) => c.tree_code).map((c) => [c.tree_code, c.id]));
4033
- for (const c of concepts) {
4034
- for (const tp of c.tree_prerequisites || []) {
4035
- const target = idByTreeCode.get(tp);
4036
- if (target && target !== c.id && !(c.prerequisites || []).includes(target)) {
4037
- c.prerequisites = [...c.prerequisites || [], target];
4038
- warnings.push("Prereq seeded from tree: " + c.id + " <- " + tp + " (" + target + ")");
4039
- }
4291
+ }
4292
+ const phases = emitCtx.map((ctx, i) => ({
4293
+ id: ctx.id,
4294
+ order: ctx.order,
4295
+ name: ctx.name,
4296
+ product_completion: ctx.productCompletion || "Phase " + (i + 1) + " deliverable",
4297
+ node_ids: phaseNodeIds[i]
4298
+ }));
4299
+ return validateCurriculumFeed({
4300
+ schema_version: 2,
4301
+ source: buildSource(opts, "hybrid_graph"),
4302
+ learning_nodes: nodes,
4303
+ dependency_edges: edges,
4304
+ suggested_groupings: groupings,
4305
+ phases
4306
+ });
4307
+ }
4308
+ function linkHybridKnowledgeEdges(hybrid, nodes, edges, setStepHints) {
4309
+ const conceptIds = new Set(nodes.filter((n) => n.kind === "concept").map((n) => n.concept_ref || n.id));
4310
+ for (const l of arr(hybrid.links)) {
4311
+ const conceptId = str(l.concept_id);
4312
+ const featureId = str(l.feature_id);
4313
+ if (!conceptIds.has(conceptId)) continue;
4314
+ const depth = str(l.depth, "cio");
4315
+ const firstStep = nodes.find((n) => n.feature_id === featureId);
4316
+ if (firstStep) {
4317
+ edges.push({ from: conceptId, to: firstStep.id, kind: "knowledge", reason: str(l.relationship, "hybrid link") + " [" + depth + "]" });
4318
+ if (!firstStep.depth_hint) {
4319
+ firstStep.depth_hint = ["ulo", "cio", "sio"].includes(depth) ? depth : "cio";
4040
4320
  }
4041
4321
  }
4042
- log("STEP_2_5", "Standardized: " + std.decisions.filter((d) => d.decision === "standardized").length + ", proposed new: " + std.decisions.filter((d) => d.decision === "proposed_new").length);
4043
4322
  }
4044
- log("STEP_3", "Checking for prerequisite cycles...");
4045
- const cycles = detectPrerequisiteCycles(concepts);
4046
- if (cycles.length > 0) {
4047
- log("STEP_3", `Found ${cycles.length} cycle(s) in prerequisite chain \u2014 breaking cycles`);
4048
- for (const cycle of cycles) {
4049
- warnings.push(`Cycle detected: ${cycle.join(" \u2192 ")} \u2014 breaking at weakest edge`);
4323
+ }
4324
+ function emitFromKnowledge(kg, opts) {
4325
+ const nodes = [];
4326
+ const edges = [];
4327
+ const groupings = [];
4328
+ const conceptIds = new Set(arr(kg.concepts).map((c) => str(c.id)));
4329
+ const externalByNode = /* @__PURE__ */ new Map();
4330
+ for (const c of arr(kg.concepts)) {
4331
+ const node = feedNodeFromConcept(c);
4332
+ nodes.push(node);
4333
+ const { internal, external } = splitPrerequisites(c, conceptIds);
4334
+ if (external.length > 0) {
4335
+ node.external_prerequisites = external;
4336
+ externalByNode.set(node.id, external);
4050
4337
  }
4051
- const { fixed, removed } = breakPrerequisiteCycles(concepts, cycles);
4052
- for (let i = 0; i < concepts.length; i++) {
4053
- const fixedConcept = fixed.find((f) => f.id === concepts[i].id);
4054
- if (fixedConcept) {
4055
- concepts[i] = { ...concepts[i], prerequisites: fixedConcept.prerequisites };
4338
+ for (const p of internal) {
4339
+ if (p !== node.id) {
4340
+ edges.push({
4341
+ from: p,
4342
+ to: node.id,
4343
+ kind: "knowledge",
4344
+ reason: `concept prerequisite: ${p} must be understood before ${node.id}`
4345
+ });
4056
4346
  }
4057
4347
  }
4058
- for (const { from, to } of removed) {
4059
- warnings.push(`Removed prerequisite edge: ${from} \u2192 ${to} (cycle breaker)`);
4348
+ }
4349
+ noteExternalPrerequisites(opts, externalByNode);
4350
+ if (arr(kg.learning_path).length > 0) {
4351
+ const orderMap = /* @__PURE__ */ new Map();
4352
+ arr(kg.learning_path).forEach((s, idx) => {
4353
+ const cid = str(s.concept_id);
4354
+ if (cid) orderMap.set(cid, num(s.order, idx + 1));
4355
+ });
4356
+ nodes.sort((a, b) => (orderMap.get(a.id) ?? 999) - (orderMap.get(b.id) ?? 999));
4357
+ }
4358
+ let ci = 0;
4359
+ for (const cat of arr(kg.categories)) {
4360
+ ci += 1;
4361
+ const ids = strs(cat.concept_ids).filter((id) => nodes.some((n) => n.id === id));
4362
+ if (ids.length > 0) {
4363
+ groupings.push({
4364
+ id: str(cat.id, "cat" + ci),
4365
+ name: str(cat.name, "Category " + ci),
4366
+ node_ids: ids,
4367
+ rationale: str(cat.description, "")
4368
+ });
4060
4369
  }
4061
4370
  }
4062
- log("STEP_4", "Topological sort of concepts...");
4063
- const sortedIds = topologicalSort(concepts);
4064
- learningPath = sortedIds.map((id, idx) => {
4065
- const concept = concepts.find((c) => c.id === id);
4066
- return {
4067
- concept_id: id,
4068
- order: idx + 1,
4069
- depends_on: concept?.prerequisites || [],
4070
- milestone_name: ""
4071
- };
4371
+ newKeywordsInOrder(nodes);
4372
+ return validateCurriculumFeed({
4373
+ schema_version: 2,
4374
+ source: buildSource(opts, "knowledge_graph"),
4375
+ learning_nodes: nodes,
4376
+ dependency_edges: edges,
4377
+ suggested_groupings: groupings,
4378
+ phases: []
4072
4379
  });
4073
- log("STEP_5", "Authoring ULO/CIO/SIO depth layers hierarchically...");
4074
- {
4075
- const lo = await authorDepthLayersHierarchically(
4076
- client,
4077
- concepts.map((c) => ({ id: c.id, name: c.name, description: c.description, keywords: c.keywords })),
4078
- techStack || (domain === "software" || domain === "technology" ? "python" : domain || "general"),
4079
- warnings
4380
+ }
4381
+ function emitFromProject(pg, opts) {
4382
+ const nodes = [];
4383
+ const edges = [];
4384
+ const groupings = [];
4385
+ const allFeatures = arr(pg.features);
4386
+ allFeatures.forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad22(fi + 1)), nodes, edges, groupings));
4387
+ const stepless = allFeatures.filter((f) => arr(f.steps).length === 0).map((f) => str(f.id)).filter(Boolean);
4388
+ if (stepless.length > 0) {
4389
+ pushDiagnostic(
4390
+ opts,
4391
+ "feature_without_steps",
4392
+ "warning",
4393
+ "Feature(s) declared but carrying no steps \u2014 they will be absent from the feed: " + stepless.join(", "),
4394
+ stepless
4080
4395
  );
4081
- const codeByConceptId = assignConceptCodes(concepts.map((c) => ({ id: c.id, name: c.name })));
4082
- const techTag = techTagFor(techStack || (domain === "software" || domain === "technology" ? "python" : domain || "general"));
4083
- for (const concept of concepts) {
4084
- concept.ulo = lo.uloByConcept[concept.id] || concept.ulo || "";
4085
- concept.cio = lo.cioByConcept[concept.id] || concept.cio || "";
4086
- if (lo.sioByConcept[concept.id] && lo.sioByConcept[concept.id].length > 0) {
4087
- concept.sio = lo.sioByConcept[concept.id];
4088
- }
4089
- concept.code = concept.code || codeByConceptId[concept.id];
4090
- concept.ulo_code = concept.ulo ? uloCode(concept.code) : concept.ulo_code;
4091
- concept.cio_code = concept.cio ? cioCode(concept.code, 1, cioActionSlug(concept.cio)) : concept.cio_code;
4092
- concept.sio_codes = concept.sio.length > 0 ? concept.sio.map((_, i) => sioCode(techTag, concept.code, i + 1)) : concept.sio_codes;
4093
- }
4094
- log("STEP_5", "Depth layers authored: " + concepts.length + " concepts, audit " + lo.audits.filter((a) => a.passed).length + "/" + concepts.length + " passed");
4095
4396
  }
4096
- for (const step of learningPath) {
4097
- if (!conceptIds.has(step.concept_id)) {
4098
- warnings.push(`Learning path references non-existent concept ${step.concept_id}`);
4099
- }
4397
+ const idless = takeSteplessStepIds();
4398
+ if (idless.length > 0) {
4399
+ pushDiagnostic(
4400
+ opts,
4401
+ "step_without_id",
4402
+ "warning",
4403
+ "Step(s) carrying no id cannot become planning nodes and were dropped (feature#index): " + idless.join(", "),
4404
+ idless
4405
+ );
4100
4406
  }
4101
- log("STEP_2", `Validated ${concepts.length} concepts, ${categories.length} categories, ${learningPath.length} learning path steps`);
4102
- const rawGraph = {
4103
- schema_version: 1,
4104
- type: "knowledge_graph",
4105
- subject: result.subject,
4106
- concepts,
4107
- categories,
4108
- learning_path: learningPath
4109
- };
4110
- log("STEP_6", "Running Knowledge Graph verification & reverse coverage audit...");
4111
- const { verifiedGraph, report } = verifyKnowledgeGraph(rawGraph, parsedSyllabus);
4112
- warnings.push(...report.warnings);
4113
- return {
4114
- knowledgeGraph: verifiedGraph,
4115
- warnings,
4116
- verificationReport: report
4117
- };
4407
+ if (nodes.length === 0 && allFeatures.length > 0) {
4408
+ const hasTasks = arr(pg.implementation ?? {}).length > 0 || arr(pg.implementation?.tasks).length > 0;
4409
+ throw new Error(
4410
+ "Project graph has " + allFeatures.length + " features but NO features[].steps - the curriculum feed cannot emit planning nodes." + (hasTasks ? " This looks like a legacy knowledge-tree graph (implementation.tasks). Regenerate it with the domain-kit v3 pipeline (runProjectGraphPipeline / hybridGraphPipeline), then re-run." : " Every feature must carry at least one step with id/name/keywords/effort for curriculum planning.")
4411
+ );
4412
+ }
4413
+ newKeywordsInOrder(nodes);
4414
+ return validateCurriculumFeed({
4415
+ schema_version: 2,
4416
+ source: buildSource(opts, "project_graph"),
4417
+ learning_nodes: nodes,
4418
+ dependency_edges: edges,
4419
+ suggested_groupings: groupings,
4420
+ phases: []
4421
+ });
4422
+ }
4423
+ function emitCurriculumFeed(graph, opts = {}) {
4424
+ const g = graph;
4425
+ if (arr(g.learning_nodes).length > 0) {
4426
+ return validateCurriculumFeed(g);
4427
+ }
4428
+ if (g.project_graph || g.knowledge_graph || arr(g.links).length > 0 || arr(g.phases).length > 0) {
4429
+ return emitFromHybrid(g, opts);
4430
+ }
4431
+ if (arr(g.concepts).length > 0 || str(g.type) === "knowledge_graph") return emitFromKnowledge(g, opts);
4432
+ return emitFromProject(g, opts);
4118
4433
  }
4119
4434
 
4120
4435
  // src/pipeline/knowledgeGraphPipeline.ts
4121
- init_curriculumFeedEmitter();
4122
4436
  async function runKnowledgeGraphPipeline(options) {
4123
4437
  const warnings = [];
4124
4438
  let parsedSyllabus = options.parsedSyllabus;
@@ -4139,7 +4453,8 @@ async function runKnowledgeGraphPipeline(options) {
4139
4453
  });
4140
4454
  warnings.push(...result.warnings);
4141
4455
  const curriculumFeed = emitCurriculumFeed(result.knowledgeGraph, {
4142
- warnings
4456
+ warnings,
4457
+ provenance: summarizeProvenance(result.provenance)
4143
4458
  });
4144
4459
  return {
4145
4460
  knowledgeGraph: result.knowledgeGraph,
@@ -4173,9 +4488,10 @@ async function generateHybridGraph(options) {
4173
4488
  description: "",
4174
4489
  total_project_minutes: 0,
4175
4490
  total_concept_minutes: 0,
4176
- estimated_total_minutes: 0
4491
+ linked_prereq_minutes: 0
4177
4492
  },
4178
- warnings
4493
+ warnings,
4494
+ provenance: []
4179
4495
  };
4180
4496
  }
4181
4497
  const client = createLlmClient(llmConfig);
@@ -4256,9 +4572,14 @@ async function generateHybridGraph(options) {
4256
4572
  description: "Progressive completion path: " + phases.length + " phases, " + features.length + " features, " + concepts.length + " concepts",
4257
4573
  total_project_minutes: totalProjectMinutes,
4258
4574
  total_concept_minutes: totalConceptMinutes,
4259
- estimated_total_minutes: totalProjectMinutes + totalPrereqMinutes
4575
+ // HG-1 (2026-10-05): renamed from estimated_total_minutes. It counts only
4576
+ // concepts that HAVE a feature link (max per concept, not summed), so it
4577
+ // is NOT the course total — see HybridGraphSchema.linked_prereq_minutes.
4578
+ // The phase planner below commits to totalProjectMinutes + totalConceptMinutes.
4579
+ linked_prereq_minutes: totalProjectMinutes + totalPrereqMinutes
4260
4580
  },
4261
- warnings
4581
+ warnings,
4582
+ provenance: client.callLog
4262
4583
  };
4263
4584
  }
4264
4585
  function auditPhasePlan(plan, features, concepts, links, warnings, log) {
@@ -4334,12 +4655,20 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
4334
4655
  } else {
4335
4656
  newIntroPhase.set(cid, order);
4336
4657
  }
4658
+ introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
4659
+ }
4660
+ for (const intro of introductions) {
4661
+ const cid = intro.concept_id;
4662
+ if (intro.aspect === "advanced") continue;
4663
+ const introOrder = newIntroPhase.get(cid);
4664
+ if (introOrder === void 0 || introOrder !== order) continue;
4337
4665
  for (const pre of prereqOf.get(cid) || []) {
4338
- if (!newIntroPhase.has(pre)) {
4339
- errors.push("Phase " + id + ": concept " + cid + " introduced before its prerequisite " + pre + " is introduced - violates the prerequisite chain");
4666
+ const previewOrder = previewPhase.get(pre);
4667
+ const covered = newIntroPhase.has(pre) || previewOrder !== void 0 && previewOrder < order;
4668
+ if (!covered) {
4669
+ errors.push("Phase " + id + ": concept " + cid + " introduced before its prerequisite " + pre + " is introduced (or previewed in an earlier phase) - violates the prerequisite chain");
4340
4670
  }
4341
4671
  }
4342
- introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
4343
4672
  }
4344
4673
  phases.push({
4345
4674
  id,
@@ -4377,6 +4706,9 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
4377
4706
  if (anchor !== void 0 && anchor <= pOrder) {
4378
4707
  errors.push("Concept " + cid + " previewed in phase " + pOrder + " but fully introduced in phase " + anchor + " - preview must precede the anchor introduction");
4379
4708
  }
4709
+ if (anchor === void 0) {
4710
+ 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");
4711
+ }
4380
4712
  }
4381
4713
  return { phases, errors };
4382
4714
  }
@@ -4391,8 +4723,12 @@ async function decomposePhases(args) {
4391
4723
  const linkList = links.map(
4392
4724
  (l) => "- " + l.feature_id + " -> " + l.concept_id + " (" + l.depth + ")"
4393
4725
  ).join("\n") || "(none)";
4394
- const systemPrompt = 'You decompose a software project build into PROGRESSIVE COMPLETION PHASES for learning purposes.\n\nA phase is NOT a feature and NOT a milestone list: it is a cross-feature slice of the\nproduct whose completion yields a DEMONSTRABLY more complete product. Build up gradually:\nthe product grows feature by feature across phases, and each phase adds its features to\nwhat previous phases already built.\n\nRULES:\n1. Every feature must be assigned to EXACTLY ONE phase (no feature reused, none skipped).\n2. Phases are ordered; later phases depend on earlier ones and yield a more complete product.\n3. product_completion describes concretely what the product can DO once the phase is done.\n4. introduces lists which concepts the phase must teach BEFORE its feature work starts:\n - aspect new: first time the concept appears in the course\n - aspect advanced: a concept introduced in an EARLIER phase, revisited here at a\n higher depth (an advanced aspect of the same concept, e.g. validation rules on top\n of basic data modeling). Use the note field to say which aspect.\n - aspect preview: a light awareness pass ("know it exists", ULO depth only, ~5 min)\n placed in an EARLIER phase when its feature work needs the vocabulary before the\n full teach. The anchor phase still owns the full new introduction.\n5. A concept MUST be introduced (new) no later than the first phase that consumes it.\n6. A concept must not be introduced before its own prerequisites are introduced.\n7. Typically 3-6 phases for a small project; keep phases balanced.\n8. Phase 1 usually covers project setup/foundation features plus the first minimal slice.\n9. Return JSON only.';
4395
- const baseUserPrompt = "FEATURES:\n" + featureList + "\n\nCONCEPTS:\n" + conceptList + "\n\nFEATURE-CONCEPT LINKS:\n" + linkList + "\n\nReturn JSON with a phases array. Each phase: { id: PH1, name, product_completion, feature_ids: [F0, F1], introduces: [{ concept_id: C1, aspect: new or advanced, note }] }.\n";
4726
+ const systemPrompt = 'You decompose a software project build into PROGRESSIVE COMPLETION PHASES for learning purposes.\n\nA phase is NOT a feature and NOT a milestone list: it is a cross-feature slice of the\nproduct whose completion yields a DEMONSTRABLY more complete product. Build up gradually:\nthe product grows feature by feature across phases, and each phase adds its features to\nwhat previous phases already built.\n\nRULES:\n1. Every feature must be assigned to EXACTLY ONE phase (no feature reused, none skipped).\n2. Phases are ordered; later phases depend on earlier ones and yield a more complete product.\n3. product_completion describes concretely what the product can DO once the phase is done.\n4. introduces lists which concepts the phase must teach BEFORE its feature work starts:\n - aspect new: first time the concept appears in the course\n - aspect advanced: a concept introduced in an EARLIER phase, revisited here at a\n higher depth (an advanced aspect of the same concept, e.g. validation rules on top\n of basic data modeling). Use the note field to say which aspect.\n - aspect preview: a light awareness pass ("know it exists", ULO depth only, ~5 min)\n placed in an EARLIER phase when its feature work needs the vocabulary before the\n full teach. The anchor phase still owns the full new introduction.\n5. A concept MUST be introduced (new) no later than the first phase that consumes it.\n6. A concept must not be introduced before its own prerequisites are introduced.\n7. Typically 3-6 phases for a small project; keep phases balanced.\n8. MINUTE BALANCE: Each phase should contain approximately 120-240 total minutes of\n concept + feature work (about 3-5 sessions of 90 minutes each). If a phase\n would contain < 60 minutes (too thin) or > 400 minutes (too heavy), rebalance.\n9. Phase 1 usually covers project setup/foundation features plus the first minimal slice.\n10. Return JSON only.';
4727
+ const totalFeatureMinutes = features.reduce((s, f) => s + (f.steps || []).reduce((ss, st) => ss + (st.effort?.estimated_minutes || 15), 0), 0);
4728
+ const totalConceptMinutes = concepts.reduce((s, c) => s + (c.estimated_minutes || 30), 0);
4729
+ const totalMinutes = totalFeatureMinutes + totalConceptMinutes;
4730
+ const targetPhaseCount = Math.max(3, Math.round(totalMinutes / 180));
4731
+ const baseUserPrompt = "FEATURES:\n" + featureList + "\n\nCONCEPTS:\n" + conceptList + "\n\nFEATURE-CONCEPT LINKS:\n" + linkList + "\n\nTOTAL MINUTES: ~" + totalMinutes + "m (" + totalFeatureMinutes + "m features + " + totalConceptMinutes + "m concepts)\nTARGET: ~" + targetPhaseCount + " phases of ~180m each\n\nReturn JSON with a phases array. Each phase: { id: PH1, name, product_completion, feature_ids: [F0, F1], introduces: [{ concept_id: C1, aspect: new or advanced, note }] }.\n";
4396
4732
  const MAX_DECOMPOSE_ATTEMPTS = 3;
4397
4733
  let auditErrors = [];
4398
4734
  let phases = [];
@@ -4430,13 +4766,13 @@ async function decomposePhases(args) {
4430
4766
  }
4431
4767
 
4432
4768
  // src/pipeline/hybridGraphPipeline.ts
4433
- init_curriculumFeedEmitter();
4434
4769
  async function runHybridGraphPipeline(options) {
4435
4770
  const warnings = [];
4436
4771
  const result = await generateHybridGraph(options);
4437
4772
  warnings.push(...result.warnings);
4438
4773
  const curriculumFeed = emitCurriculumFeed(result.hybridGraph, {
4439
- warnings
4774
+ warnings,
4775
+ provenance: summarizeProvenance(result.provenance)
4440
4776
  });
4441
4777
  return {
4442
4778
  hybridGraph: result.hybridGraph,
@@ -5019,6 +5355,9 @@ function detectSyllabusLanguage(text) {
5019
5355
  }
5020
5356
  return "en";
5021
5357
  }
5358
+ function normalizeText(s) {
5359
+ return s.normalize("NFC").toLowerCase();
5360
+ }
5022
5361
  function detectDomain(options) {
5023
5362
  const evidence = [];
5024
5363
  const detectedTechStack = [];
@@ -5075,15 +5414,19 @@ function detectDomain(options) {
5075
5414
  }
5076
5415
  }
5077
5416
  }
5078
- const textContent = [
5417
+ const textContent = normalizeText([
5079
5418
  options.description || "",
5080
5419
  options.goal || "",
5081
5420
  options.syllabusText || ""
5082
- ].join(" ").toLowerCase();
5421
+ ].join(" "));
5422
+ const textNoMarks = stripDiacritics(textContent);
5083
5423
  for (const [domain, keywords] of Object.entries(DOMAIN_KEYWORDS)) {
5084
5424
  const cat = domain;
5085
5425
  for (const kw of keywords) {
5086
- if (textContent.includes(kw.toLowerCase())) {
5426
+ const kwNorm = normalizeText(kw);
5427
+ const kwPlain = stripDiacritics(kw);
5428
+ const matched = textContent.includes(kwNorm) || kwPlain !== kwNorm && textNoMarks.includes(kwPlain);
5429
+ if (matched) {
5087
5430
  scores[cat] += 0.1;
5088
5431
  if (!evidence.some((e) => e.includes(`"${kw}"`))) {
5089
5432
  evidence.push(`Keyword "${kw}" \u2192 ${cat}`);
@@ -5122,11 +5465,12 @@ function detectDomain(options) {
5122
5465
  }
5123
5466
  if (options.syllabusText) {
5124
5467
  const syllabusLang = detectSyllabusLanguage(options.syllabusText);
5125
- const syllabusLower = options.syllabusText.toLowerCase();
5126
- if (syllabusLower.includes("h\xECnh h\u1ECDc") || syllabusLower.includes("\u0111\u1EA1i s\u1ED1") || syllabusLower.includes("geometry") || syllabusLower.includes("algebra") || syllabusLower.includes("\u51E0\u4F55") || syllabusLower.includes("\u4EE3\u6570") || syllabusLower.includes("\u5E7E\u4F55") || syllabusLower.includes("\u4EE3\u6570") || syllabusLower.includes("g\xE9om\xE9trie") || syllabusLower.includes("alg\xE8bre")) {
5468
+ const syllabusLower = normalizeText(options.syllabusText);
5469
+ const syllabusPlain = stripDiacritics(options.syllabusText);
5470
+ if (syllabusLower.includes("h\xECnh h\u1ECDc") || syllabusLower.includes("\u0111\u1EA1i s\u1ED1") || syllabusPlain.includes("hinh hoc") || syllabusPlain.includes("dai so") || syllabusLower.includes("geometry") || syllabusLower.includes("algebra") || syllabusLower.includes("\u51E0\u4F55") || syllabusLower.includes("\u4EE3\u6570") || syllabusLower.includes("\u5E7E\u4F55") || syllabusLower.includes("\u4EE3\u6570") || syllabusLower.includes("g\xE9om\xE9trie") || syllabusLower.includes("alg\xE8bre")) {
5127
5471
  scores.math += 0.5;
5128
5472
  detectedSubject = "Mathematics";
5129
- } else if (syllabusLower.includes("v\u1EADt l\xFD") || syllabusLower.includes("h\xF3a") || syllabusLower.includes("sinh") || syllabusLower.includes("physics") || syllabusLower.includes("chemistry") || syllabusLower.includes("biology") || syllabusLower.includes("\u7269\u7406") || syllabusLower.includes("\u5316\u5B66") || syllabusLower.includes("\u7269\u7406") || syllabusLower.includes("\u5316\u5B66") || syllabusLower.includes("physique") || syllabusLower.includes("chimie")) {
5473
+ } else if (syllabusLower.includes("v\u1EADt l\xFD") || syllabusLower.includes("h\xF3a") || syllabusPlain.includes("vat ly") || syllabusPlain.includes("hoa") || syllabusLower.includes("sinh") || syllabusLower.includes("physics") || syllabusLower.includes("chemistry") || syllabusLower.includes("biology") || syllabusLower.includes("\u7269\u7406") || syllabusLower.includes("\u5316\u5B66") || syllabusLower.includes("\u7269\u7406") || syllabusLower.includes("\u5316\u5B66") || syllabusLower.includes("physique") || syllabusLower.includes("chimie")) {
5130
5474
  scores.science += 0.5;
5131
5475
  detectedSubject = "Natural Science";
5132
5476
  } else if (syllabusLower.includes("ti\u1EBFng anh") || syllabusLower.includes("english") || syllabusLower.includes("grammar") || syllabusLower.includes("\u82F1\u8BED") || syllabusLower.includes("\u82F1\u8A9E") || syllabusLower.includes("anglais")) {
@@ -5367,6 +5711,9 @@ async function runDomainPipeline(options) {
5367
5711
  }
5368
5712
  if (effectiveMode === "hybrid") {
5369
5713
  log("LAYER_3", "Executing Hybrid Graph Pipeline...");
5714
+ if (!options.repoDir && options.forcedLearningMode === "hybrid") {
5715
+ throw new Error('forcedLearningMode "hybrid" requires repoDir \u2014 refusing to silently degrade to a concept-only feed (fail-closed)');
5716
+ }
5370
5717
  if (options.repoDir) {
5371
5718
  const projectResult2 = await runProjectGraphPipeline({
5372
5719
  repoDir: options.repoDir,
@@ -5384,7 +5731,9 @@ async function runDomainPipeline(options) {
5384
5731
  domain: domainProfile.domainCategory,
5385
5732
  techStack: options.techStack,
5386
5733
  llmConfig: options.llmConfig,
5387
- onProgress: options.onProgress
5734
+ onProgress: options.onProgress,
5735
+ knowledgeTreePath: options.knowledgeTreePath,
5736
+ proposalsPath: options.proposalsPath
5388
5737
  });
5389
5738
  const hybridResult = await runHybridGraphPipeline({
5390
5739
  projectGraph: projectResult2.projectGraph,
@@ -5403,7 +5752,8 @@ async function runDomainPipeline(options) {
5403
5752
  warnings
5404
5753
  };
5405
5754
  } else {
5406
- warnings.push("Hybrid learning mode requested but repoDir was not provided \u2014 falling back to concept-driven knowledge graph pipeline");
5755
+ const downgradeReason = "Hybrid learning mode requested but repoDir was not provided \u2014 falling back to concept-driven knowledge graph pipeline";
5756
+ warnings.push(downgradeReason);
5407
5757
  const knowledgeResult = await runKnowledgeGraphPipeline({
5408
5758
  subject: options.subject || domainProfile.detectedSubject || options.goal || "Subject Theory",
5409
5759
  description: options.description,
@@ -5412,7 +5762,9 @@ async function runDomainPipeline(options) {
5412
5762
  domain: domainProfile.domainCategory,
5413
5763
  techStack: options.techStack,
5414
5764
  llmConfig: options.llmConfig,
5415
- onProgress: options.onProgress
5765
+ onProgress: options.onProgress,
5766
+ knowledgeTreePath: options.knowledgeTreePath,
5767
+ proposalsPath: options.proposalsPath
5416
5768
  });
5417
5769
  warnings.push(...knowledgeResult.warnings);
5418
5770
  return {
@@ -5420,7 +5772,8 @@ async function runDomainPipeline(options) {
5420
5772
  graphType: "knowledge_graph",
5421
5773
  curriculumFeed: knowledgeResult.curriculumFeed,
5422
5774
  knowledgeResult,
5423
- warnings
5775
+ warnings,
5776
+ modeDowngrade: { from: "hybrid_graph", to: "knowledge_graph", reason: downgradeReason }
5424
5777
  };
5425
5778
  }
5426
5779
  }
@@ -5436,9 +5789,10 @@ async function runDomainPipeline(options) {
5436
5789
  embeddings: options.embeddings,
5437
5790
  onProgress: options.onProgress
5438
5791
  });
5439
- const { emitCurriculumFeed: emitCurriculumFeed2 } = await Promise.resolve().then(() => (init_curriculumFeedEmitter(), curriculumFeedEmitter_exports));
5440
- const curriculumFeed = emitCurriculumFeed2(projectResult.projectGraph, {
5441
- warnings
5792
+ const curriculumFeed = emitCurriculumFeed(projectResult.projectGraph, {
5793
+ warnings,
5794
+ hallucinationCount: projectResult.hallucinations.length,
5795
+ provenance: summarizeProvenance(projectResult.provenance)
5442
5796
  });
5443
5797
  return {
5444
5798
  domainProfile,