@thanh01.pmt/domain-kit 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +682 -168
  2. package/dist/assembly/index.d.cts +3 -2
  3. package/dist/assembly/index.d.ts +3 -2
  4. package/dist/{chunk-TYXSJUQU.mjs → chunk-4UCXYRM3.mjs} +208 -310
  5. package/dist/chunk-4UCXYRM3.mjs.map +1 -0
  6. package/dist/chunk-7A2SG466.mjs +325 -0
  7. package/dist/chunk-7A2SG466.mjs.map +1 -0
  8. package/dist/{chunk-E4GCCWJO.mjs → chunk-CGNXGVHV.mjs} +37 -4
  9. package/dist/chunk-CGNXGVHV.mjs.map +1 -0
  10. package/dist/{chunk-DRVOV5ZD.mjs → chunk-DSEJLTUO.mjs} +2 -2
  11. package/dist/chunk-DSEJLTUO.mjs.map +1 -0
  12. package/dist/{chunk-VCPIWJ7R.mjs → chunk-H4W3YJLK.mjs} +29 -4
  13. package/dist/chunk-H4W3YJLK.mjs.map +1 -0
  14. package/dist/chunk-IBVXXVGE.mjs +14 -0
  15. package/dist/chunk-IBVXXVGE.mjs.map +1 -0
  16. package/dist/{chunk-G7HLFMD2.mjs → chunk-KHCZYLKM.mjs} +68 -33
  17. package/dist/chunk-KHCZYLKM.mjs.map +1 -0
  18. package/dist/chunk-OYSZQVPY.mjs +10 -0
  19. package/dist/chunk-OYSZQVPY.mjs.map +1 -0
  20. package/dist/{chunk-BL5NBKPH.mjs → chunk-WD3P5V3B.mjs} +19 -8
  21. package/dist/chunk-WD3P5V3B.mjs.map +1 -0
  22. package/dist/{chunk-TWKIVUTQ.mjs → chunk-WKTH3JPA.mjs} +197 -36
  23. package/dist/chunk-WKTH3JPA.mjs.map +1 -0
  24. package/dist/{conceptEscalator-ltRLtPhf.d.cts → conceptEscalator-BmMJD8C5.d.cts} +10 -1
  25. package/dist/{conceptEscalator-YfMKIKCZ.d.ts → conceptEscalator-DMsehscs.d.ts} +10 -1
  26. package/dist/concepts/index.d.cts +4 -3
  27. package/dist/concepts/index.d.ts +4 -3
  28. package/dist/{cpp-CZUPqJt9.d.cts → cpp-vlDl6pCL.d.cts} +4 -4
  29. package/dist/{cpp-CZUPqJt9.d.ts → cpp-vlDl6pCL.d.ts} +4 -4
  30. package/dist/{curriculumFeedSchema-DUFwFyr2.d.ts → curriculumFeedSchema-DK-TUAJZ.d.cts} +164 -9
  31. package/dist/{curriculumFeedSchema-DUFwFyr2.d.cts → curriculumFeedSchema-DK-TUAJZ.d.ts} +164 -9
  32. package/dist/detector/index.cjs +22 -6
  33. package/dist/detector/index.cjs.map +1 -1
  34. package/dist/detector/index.d.cts +2 -2
  35. package/dist/detector/index.d.ts +2 -2
  36. package/dist/detector/index.mjs +2 -1
  37. package/dist/{domainProfileDetector-DHb1T0zB.d.ts → domainProfileDetector-6pjgL-6_.d.ts} +1 -1
  38. package/dist/{domainProfileDetector-bzugdQmS.d.cts → domainProfileDetector-BsADsuRV.d.cts} +1 -1
  39. package/dist/{domainProfileSchema-CwT3Ffsw.d.ts → domainProfileSchema-779FGk14.d.cts} +38 -1
  40. package/dist/{domainProfileSchema-CwT3Ffsw.d.cts → domainProfileSchema-779FGk14.d.ts} +38 -1
  41. package/dist/extractors/index.d.cts +3 -3
  42. package/dist/extractors/index.d.ts +3 -3
  43. package/dist/feed/index.cjs +229 -33
  44. package/dist/feed/index.cjs.map +1 -1
  45. package/dist/feed/index.d.cts +32 -2
  46. package/dist/feed/index.d.ts +32 -2
  47. package/dist/feed/index.mjs +2 -2
  48. package/dist/graph/index.cjs +308 -86
  49. package/dist/graph/index.cjs.map +1 -1
  50. package/dist/graph/index.d.cts +20 -8
  51. package/dist/graph/index.d.ts +20 -8
  52. package/dist/graph/index.mjs +4 -1
  53. package/dist/{hybridGraphPipeline-dT-bio-3.d.cts → hybridGraphPipeline-B4mamLim.d.cts} +6 -2
  54. package/dist/{hybridGraphPipeline-DXQDbBEj.d.ts → hybridGraphPipeline-D2_1GUva.d.ts} +6 -2
  55. package/dist/{hybridGraphSchema-nrUeSwpU.d.ts → hybridGraphSchema-C37qxdTM.d.cts} +16 -3
  56. package/dist/{hybridGraphSchema-nrUeSwpU.d.cts → hybridGraphSchema-C37qxdTM.d.ts} +16 -3
  57. package/dist/index.cjs +3948 -3464
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.d.cts +11 -10
  60. package/dist/index.d.ts +11 -10
  61. package/dist/index.mjs +14 -11
  62. package/dist/{keywordExtractor-BxlGRMHC.d.cts → keywordExtractor-1YAFntsn.d.cts} +1 -1
  63. package/dist/{keywordExtractor-DU5XRN8-.d.ts → keywordExtractor-DWw8z_AF.d.ts} +1 -1
  64. package/dist/parsers/index.cjs.map +1 -1
  65. package/dist/parsers/index.d.cts +1 -1
  66. package/dist/parsers/index.d.ts +1 -1
  67. package/dist/parsers/index.mjs +1 -1
  68. package/dist/pipeline/index.cjs +2436 -1994
  69. package/dist/pipeline/index.cjs.map +1 -1
  70. package/dist/pipeline/index.d.cts +19 -10
  71. package/dist/pipeline/index.d.ts +19 -10
  72. package/dist/pipeline/index.mjs +11 -8
  73. package/dist/schemas/index.cjs +63 -2
  74. package/dist/schemas/index.cjs.map +1 -1
  75. package/dist/schemas/index.d.cts +3 -3
  76. package/dist/schemas/index.d.ts +3 -3
  77. package/dist/schemas/index.mjs +2 -2
  78. package/dist/utils/llmClient.cjs +344 -0
  79. package/dist/utils/llmClient.cjs.map +1 -0
  80. package/dist/utils/llmClient.d.cts +118 -0
  81. package/dist/utils/llmClient.d.ts +118 -0
  82. package/dist/utils/llmClient.mjs +4 -0
  83. package/dist/utils/llmClient.mjs.map +1 -0
  84. package/dist/utils/llmTransportResult.cjs +16 -0
  85. package/dist/utils/llmTransportResult.cjs.map +1 -0
  86. package/dist/utils/llmTransportResult.d.cts +36 -0
  87. package/dist/utils/llmTransportResult.d.ts +36 -0
  88. package/dist/utils/llmTransportResult.mjs +3 -0
  89. package/dist/utils/llmTransportResult.mjs.map +1 -0
  90. package/package.json +1 -1
  91. package/dist/chunk-BL5NBKPH.mjs.map +0 -1
  92. package/dist/chunk-DRVOV5ZD.mjs.map +0 -1
  93. package/dist/chunk-E4GCCWJO.mjs.map +0 -1
  94. package/dist/chunk-G7HLFMD2.mjs.map +0 -1
  95. package/dist/chunk-TWKIVUTQ.mjs.map +0 -1
  96. package/dist/chunk-TYXSJUQU.mjs.map +0 -1
  97. package/dist/chunk-VCPIWJ7R.mjs.map +0 -1
  98. package/dist/curriculumFeedEmitter-T7NUBLAM.mjs +0 -4
  99. package/dist/curriculumFeedEmitter-T7NUBLAM.mjs.map +0 -1
  100. package/dist/llmClient-ysPhLjcH.d.cts +0 -16
  101. package/dist/llmClient-ysPhLjcH.d.ts +0 -16
@@ -52,6 +52,14 @@ var FeedNodeSchema = zod.z.object({
52
52
  reason: zod.z.string().default("")
53
53
  })).optional(),
54
54
  is_core: zod.z.boolean().default(false),
55
+ /**
56
+ * Prerequisite concept ids this node needs that NO node in this feed provides —
57
+ * i.e. the learner must already know them before starting. Previously these were
58
+ * silently dropped (knowledge path) or crashed emission (both hybrid paths), so a
59
+ * course silently lost its entry requirements. Kept on the node so the mapping
60
+ * "concept X needs prior knowledge Y" survives; a course-level roll-up is derivable.
61
+ */
62
+ external_prerequisites: zod.z.array(zod.z.string()).default([]),
55
63
  // Depth layer text (optional, additive — old feeds validate unchanged).
56
64
  // Carries the ULO/CIO/SIO authored text from the KnowledgeGraph concept
57
65
  // so downstream consumers (KX generator) can inject depth-appropriate
@@ -87,12 +95,28 @@ var FeedPhaseSchema = zod.z.object({
87
95
  product_completion: zod.z.string().min(1),
88
96
  node_ids: zod.z.array(zod.z.string()).min(1)
89
97
  });
98
+ var FeedDiagnosticSchema = zod.z.object({
99
+ code: zod.z.string().min(1),
100
+ severity: zod.z.enum(["info", "warning", "error"]),
101
+ message: zod.z.string(),
102
+ node_ids: zod.z.array(zod.z.string()).default([])
103
+ });
104
+ var FeedProvenanceSchema = zod.z.object({
105
+ providers: zod.z.array(zod.z.string()).default([]),
106
+ models: zod.z.array(zod.z.string()).default([]),
107
+ calls: zod.z.number().int().nonnegative().default(0),
108
+ failed_calls: zod.z.number().int().nonnegative().default(0),
109
+ degraded: zod.z.boolean().default(false)
110
+ });
90
111
  var CurriculumFeedSchema = zod.z.object({
91
112
  schema_version: zod.z.literal(2),
92
113
  source: zod.z.object({
93
114
  graph_type: zod.z.enum(["project_graph", "knowledge_graph", "hybrid_graph"]),
115
+ /** DERIVED from diagnostics — do not write to it directly. */
94
116
  warnings: zod.z.array(zod.z.string()).default([]),
95
- hallucination_count: zod.z.number().int().nonnegative().default(0)
117
+ hallucination_count: zod.z.number().int().nonnegative().default(0),
118
+ diagnostics: zod.z.array(FeedDiagnosticSchema).default([]),
119
+ provenance: FeedProvenanceSchema.optional()
96
120
  }),
97
121
  learning_nodes: zod.z.array(FeedNodeSchema).min(1, "curriculum feed must contain at least one learning node"),
98
122
  dependency_edges: zod.z.array(FeedEdgeSchema).default([]),
@@ -125,6 +149,15 @@ function validateCurriculumFeed(feed) {
125
149
  }
126
150
  const phaseIds = parsed.phases.map((p) => p.id);
127
151
  if (new Set(phaseIds).size !== phaseIds.length) throw new Error("curriculum feed contains duplicate phase ids");
152
+ const phaseOrders = parsed.phases.map((p) => p.order);
153
+ if (new Set(phaseOrders).size !== phaseOrders.length) {
154
+ throw new Error("curriculum feed contains duplicate phase orders: " + phaseOrders.join(", "));
155
+ }
156
+ for (let i = 1; i < phaseOrders.length; i++) {
157
+ if (phaseOrders[i] <= phaseOrders[i - 1]) {
158
+ throw new Error("curriculum feed phase orders are not ascending: " + phaseOrders.join(", "));
159
+ }
160
+ }
128
161
  if (parsed.phases.length > 0) {
129
162
  const phaseIndexOf = /* @__PURE__ */ new Map();
130
163
  parsed.phases.forEach((p, i) => {
@@ -145,18 +178,82 @@ function validateCurriculumFeed(feed) {
145
178
  }
146
179
 
147
180
  // src/feed/curriculumFeedEmitter.ts
181
+ var DEPTH_MINUTE_FRACTIONS = {
182
+ /** Awareness-only pass before the anchor teach. */
183
+ preview: 0.25,
184
+ ulo: 0.35,
185
+ cio: 0.65,
186
+ sio: 1,
187
+ /** An advanced revisit re-teaches the concept, not the whole thing again. */
188
+ advancedRevisit: 0.5,
189
+ /** Minutes saved when an advanced revisit drops from SIO practice to a CIO recap. */
190
+ advancedRecapSave: 0.3,
191
+ /** Floor so a tiny concept still gets a teachable awareness pass. */
192
+ floor: 5,
193
+ /** Floor for an advanced revisit — it is a real teach, not an awareness pass. */
194
+ advancedFloor: 15
195
+ };
196
+ var DEFAULT_STEP_MINUTES = 15;
148
197
  var BLOOM_RANK = { Remember: 0, Understand: 1, Apply: 2, Analyze: 3, Evaluate: 4, Create: 5 };
149
198
  var pad2 = (n) => String(n).padStart(2, "0");
199
+ var steplessStepIds = [];
200
+ var takeSteplessStepIds = () => steplessStepIds.splice(0, steplessStepIds.length);
150
201
  var arr = (v) => Array.isArray(v) ? v : [];
151
202
  var str = (v, d = "") => typeof v === "string" ? v : d;
152
203
  var num = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
153
204
  var strs = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
154
205
  function stepMinutes(step) {
155
206
  const effort = step.effort ?? {};
156
- return Math.max(0, Math.round(num(effort.estimated_minutes, num(step.estimated_minutes, 45))));
207
+ return Math.max(0, Math.round(num(effort.estimated_minutes, num(step.estimated_minutes, DEFAULT_STEP_MINUTES))));
208
+ }
209
+ function diagnosticsOf(opts) {
210
+ const list = opts.diagnostics ?? (opts.diagnostics = []);
211
+ for (const message of opts.warnings ?? []) {
212
+ if (!list.some((d) => d.code === "upstream_warning" && d.message === message)) {
213
+ list.push({ code: "upstream_warning", severity: "warning", message, node_ids: [] });
214
+ }
215
+ }
216
+ return list;
217
+ }
218
+ function pushDiagnostic(opts, code, severity, message, nodeIds = []) {
219
+ diagnosticsOf(opts).push({ code, severity, message, node_ids: nodeIds });
220
+ }
221
+ function splitPrerequisites(concept, known) {
222
+ const internal = [];
223
+ const external = [];
224
+ for (const p of strs(concept.prerequisites)) {
225
+ (known.has(p) ? internal : external).push(p);
226
+ }
227
+ return { internal, external };
228
+ }
229
+ function noteExternalPrerequisites(opts, byNode) {
230
+ if (byNode.size === 0) return;
231
+ const entries = [];
232
+ for (const [nodeId, prereqs] of byNode) {
233
+ for (const prereqId of prereqs) entries.push({ nodeId, prereqId });
234
+ }
235
+ entries.sort((a, b) => a.nodeId === b.nodeId ? a.prereqId.localeCompare(b.prereqId) : a.nodeId.localeCompare(b.nodeId));
236
+ pushDiagnostic(
237
+ opts,
238
+ "external_prerequisite",
239
+ "info",
240
+ "Prerequisite(s) outside this analysis \u2014 the learner must already know them: " + entries.map((e) => e.nodeId + " \u2190 " + e.prereqId).join(", "),
241
+ entries.map((e) => e.nodeId)
242
+ );
243
+ }
244
+ function buildSource(opts, graphType) {
245
+ const diagnostics = diagnosticsOf(opts);
246
+ return {
247
+ graph_type: graphType,
248
+ // Derived view — 'info' diagnostics are deliberately not surfaced as warnings.
249
+ warnings: diagnostics.filter((d) => d.severity !== "info").map((d) => d.message),
250
+ hallucination_count: opts.hallucinationCount ?? 0,
251
+ diagnostics,
252
+ provenance: opts.provenance
253
+ };
157
254
  }
158
- function pushWarning(opts, message) {
159
- (opts.warnings ?? (opts.warnings = [])).push(message);
255
+ function keywordKey(k) {
256
+ return k.normalize("NFC").toLowerCase();
160
257
  }
161
258
  function newKeywordsInOrder(nodes) {
162
259
  const seen = /* @__PURE__ */ new Set();
@@ -165,9 +262,9 @@ function newKeywordsInOrder(nodes) {
165
262
  n.keywords.new = [];
166
263
  n.keywords.prerequisite = [];
167
264
  for (const k of all) {
168
- if (seen.has(k)) n.keywords.prerequisite.push(k);
265
+ if (seen.has(keywordKey(k))) n.keywords.prerequisite.push(k);
169
266
  else n.keywords.new.push(k);
170
- seen.add(k);
267
+ seen.add(keywordKey(k));
171
268
  }
172
269
  }
173
270
  }
@@ -191,6 +288,7 @@ function feedNodeFromStep(step, featureId) {
191
288
  // sessions at user-visible checkpoints instead of raw minute counts.
192
289
  user_visible_deliverable: str(step.outcome?.user_visible),
193
290
  is_core: false,
291
+ external_prerequisites: [],
194
292
  phase_id: "",
195
293
  introduce_aspect: null
196
294
  };
@@ -210,9 +308,9 @@ function deriveConceptDepthHint(concept) {
210
308
  return "ulo";
211
309
  }
212
310
  function deriveDepthInputs(minutes, depth) {
213
- const ulo = Math.max(5, Math.round(minutes * 0.35));
214
- const cio = Math.max(ulo + 5, Math.round(minutes * 0.65));
215
- const sio = Math.max(cio + 5, minutes);
311
+ const ulo = Math.max(DEPTH_MINUTE_FRACTIONS.floor, Math.round(minutes * DEPTH_MINUTE_FRACTIONS.ulo));
312
+ const cio = Math.max(ulo + DEPTH_MINUTE_FRACTIONS.floor, Math.round(minutes * DEPTH_MINUTE_FRACTIONS.cio));
313
+ const sio = Math.max(cio + DEPTH_MINUTE_FRACTIONS.floor, minutes);
216
314
  const variants = { ulo, cio, sio };
217
315
  const candidates = [];
218
316
  if (depth === "sio") {
@@ -255,7 +353,11 @@ function feedNodeFromConcept(concept) {
255
353
  user_visible_deliverable: "",
256
354
  // concepts are taught, not shipped — checkpoints live on product steps
257
355
  ...deriveDepthInputs(Math.max(0, Math.round(num(concept.estimated_minutes, 15))), deriveConceptDepthHint(concept)),
258
- is_core: false,
356
+ // Knowledge-Tree concepts are the spine of the course — the reconciler must
357
+ // escalate them, never downgrade them. The emitter hardcoded `false`, so the
358
+ // flag the schema documents never reached the planner for any concept.
359
+ is_core: concept.is_core === true || str(concept.standardization) === "standardized",
360
+ external_prerequisites: [],
259
361
  depth_context: {
260
362
  ulo: str(concept.ulo),
261
363
  cio: str(concept.cio),
@@ -271,7 +373,10 @@ function addFeatureGroup(f, fid, nodes, edges, groupings) {
271
373
  let prev = "";
272
374
  for (let i = 0; i < steps.length; i++) {
273
375
  const stepNode = feedNodeFromStep(steps[i], fid);
274
- if (!stepNode.id) continue;
376
+ if (!stepNode.id) {
377
+ steplessStepIds.push(fid + "#" + i);
378
+ continue;
379
+ }
275
380
  stepIds.push(stepNode.id);
276
381
  nodes.push(stepNode);
277
382
  if (prev) edges.push({ from: prev, to: stepNode.id, kind: "task", reason: "feature build order" });
@@ -294,18 +399,26 @@ function emitFromHybrid(hybrid, opts) {
294
399
  const features = arr(pg.features);
295
400
  const conceptRecs = arr(kg.concepts);
296
401
  const conceptById = new Map(conceptRecs.map((c) => [str(c.id), c]));
402
+ const knownConceptIds = new Set(conceptById.keys());
297
403
  if (rawPhases.length === 0) {
404
+ const externalByNode2 = /* @__PURE__ */ new Map();
298
405
  for (const c of conceptRecs) {
299
406
  const node = feedNodeFromConcept(c);
300
407
  nodes.push(node);
301
- for (const p of strs(c.prerequisites)) edges.push({ from: p, to: node.id, kind: "knowledge", reason: "concept prerequisite" });
408
+ const { internal, external } = splitPrerequisites(c, knownConceptIds);
409
+ if (external.length > 0) {
410
+ node.external_prerequisites = external;
411
+ externalByNode2.set(node.id, external);
412
+ }
413
+ for (const p of internal) edges.push({ from: p, to: node.id, kind: "knowledge", reason: "concept prerequisite" });
302
414
  }
415
+ noteExternalPrerequisites(opts, externalByNode2);
303
416
  features.forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad2(fi + 1)), nodes, edges, groupings));
304
417
  linkHybridKnowledgeEdges(hybrid, nodes, edges);
305
418
  newKeywordsInOrder(nodes);
306
419
  return validateCurriculumFeed({
307
420
  schema_version: 2,
308
- source: { graph_type: "hybrid_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
421
+ source: buildSource(opts, "hybrid_graph"),
309
422
  learning_nodes: nodes,
310
423
  dependency_edges: edges,
311
424
  suggested_groupings: groupings,
@@ -322,6 +435,8 @@ function emitFromHybrid(hybrid, opts) {
322
435
  const emitCtx = [];
323
436
  const emittedConcepts = /* @__PURE__ */ new Set();
324
437
  const previewsEmitted = /* @__PURE__ */ new Set();
438
+ const previewNodeIdByConcept = /* @__PURE__ */ new Map();
439
+ const externalByNode = /* @__PURE__ */ new Map();
325
440
  const linkList = arr(hybrid.links);
326
441
  for (const rp of rawPhases) {
327
442
  const pid = str(rp.id);
@@ -361,7 +476,7 @@ function emitFromHybrid(hybrid, opts) {
361
476
  prevNode.name = str(c.name, cid) + " (preview)";
362
477
  prevNode.phase_id = pid;
363
478
  prevNode.introduce_aspect = "preview";
364
- prevNode.estimated_minutes = Math.max(5, Math.round(prevNode.estimated_minutes * 0.25));
479
+ prevNode.estimated_minutes = Math.max(DEPTH_MINUTE_FRACTIONS.floor, Math.round(prevNode.estimated_minutes * DEPTH_MINUTE_FRACTIONS.preview));
365
480
  prevNode.depth_hint = "ulo";
366
481
  prevNode.bloom_hint = "Understand";
367
482
  prevNode.concept_codes = [str(c.code) || cid];
@@ -371,6 +486,7 @@ function emitFromHybrid(hybrid, opts) {
371
486
  prevNode.depth_scaffold_candidates = [];
372
487
  nodes.push(prevNode);
373
488
  conceptOrder.push(prevNode.id);
489
+ previewNodeIdByConcept.set(cid, prevNode.id);
374
490
  continue;
375
491
  }
376
492
  if (!emittedConcepts.has(cid)) {
@@ -379,13 +495,22 @@ function emitFromHybrid(hybrid, opts) {
379
495
  node.introduce_aspect = "new";
380
496
  nodes.push(node);
381
497
  for (const p of strs(c.prerequisites)) {
382
- if (!emittedConcepts.has(p)) {
383
- throw new Error("Concept " + cid + " (phase " + pid + ") has prerequisite " + p + " that is not introduced in an earlier phase - fail-closed");
498
+ if (!conceptById.has(p)) {
499
+ if (!externalByNode.has(cid)) externalByNode.set(cid, []);
500
+ externalByNode.get(cid).push(p);
501
+ node.external_prerequisites.push(p);
502
+ continue;
384
503
  }
385
- edges.push({ from: p, to: cid, kind: "knowledge", reason: "concept prerequisite" });
504
+ const previewOfPrereq = previewNodeIdByConcept.get(p);
505
+ if (!emittedConcepts.has(p) && !previewOfPrereq) {
506
+ throw new Error("Concept " + cid + " (phase " + pid + ") has prerequisite " + p + " that is neither introduced nor previewed in an EARLIER phase - fail-closed");
507
+ }
508
+ edges.push({ from: emittedConcepts.has(p) ? p : previewOfPrereq, to: cid, kind: "knowledge", reason: "concept prerequisite" });
386
509
  }
387
510
  emittedConcepts.add(cid);
388
511
  conceptOrder.push(cid);
512
+ } else if (str(intro.aspect, "new") === "new") {
513
+ throw new Error("Phase " + pid + " introduces " + cid + " as new but it was already introduced in an earlier phase - fail-closed");
389
514
  } else {
390
515
  const revisitId = cid + "__ADV" + pad2(order);
391
516
  const node = feedNodeFromConcept(c);
@@ -393,8 +518,16 @@ function emitFromHybrid(hybrid, opts) {
393
518
  node.name = str(c.name, cid) + " (advanced)";
394
519
  node.phase_id = pid;
395
520
  node.introduce_aspect = "advanced";
396
- 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" }];
397
- node.estimated_minutes = Math.max(15, Math.round(node.estimated_minutes * 0.5));
521
+ node.estimated_minutes = Math.max(
522
+ DEPTH_MINUTE_FRACTIONS.advancedFloor,
523
+ Math.round(node.estimated_minutes * DEPTH_MINUTE_FRACTIONS.advancedRevisit)
524
+ );
525
+ node.depth_scaffold_candidates = [{
526
+ from_depth: "sio",
527
+ to_depth: "cio",
528
+ minutes_saved: Math.round(node.estimated_minutes * DEPTH_MINUTE_FRACTIONS.advancedRecapSave),
529
+ reason: "advanced revisit may drop to CIO recap without breaking first-teach chain"
530
+ }];
398
531
  node.concept_codes = [str(c.code) || cid];
399
532
  edges.push({ from: cid, to: revisitId, kind: "knowledge", reason: "advanced revisit of " + cid + " at phase " + pid });
400
533
  nodes.push(node);
@@ -404,6 +537,7 @@ function emitFromHybrid(hybrid, opts) {
404
537
  const featureIds = strs(rp.feature_ids).filter((fid) => featureOrder.has(fid)).sort((a, b) => featureOrder.get(a) - featureOrder.get(b));
405
538
  emitCtx.push({ id: pid, order, name: str(rp.name, pid), productCompletion: str(rp.product_completion), conceptOrder, featureIds });
406
539
  }
540
+ noteExternalPrerequisites(opts, externalByNode);
407
541
  for (const l of linkList) {
408
542
  const cid = str(l.concept_id);
409
543
  if (cid && conceptById.has(cid) && !emittedConcepts.has(cid)) {
@@ -415,6 +549,16 @@ function emitFromHybrid(hybrid, opts) {
415
549
  if (missingFeatures.length > 0) {
416
550
  throw new Error("Features not covered by any phase: " + missingFeatures.join(", ") + " - fail-closed");
417
551
  }
552
+ const orphans = Array.from(conceptById.keys()).filter((cid) => !emittedConcepts.has(cid));
553
+ if (orphans.length > 0) {
554
+ pushDiagnostic(
555
+ opts,
556
+ "orphan_concept",
557
+ "error",
558
+ "Knowledge graph has " + orphans.length + " concept(s) no phase introduces and no link consumes \u2014 they will not be taught: " + orphans.join(", "),
559
+ orphans
560
+ );
561
+ }
418
562
  const phaseNodeIds = [];
419
563
  for (const ctx of emitCtx) {
420
564
  const ids = [...ctx.conceptOrder];
@@ -422,13 +566,26 @@ function emitFromHybrid(hybrid, opts) {
422
566
  const f = features.find((x) => str(x.id) === fid);
423
567
  const stepIds = addFeatureGroup(f, fid, nodes, edges, groupings);
424
568
  for (const sid of stepIds) {
425
- const node = nodes.find((n) => n.id === sid);
569
+ const node = nodes.find((x) => x.id === sid);
426
570
  node.phase_id = ctx.id;
427
571
  }
428
572
  ids.push(...stepIds);
429
573
  }
574
+ if (ids.length === 0) {
575
+ 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");
576
+ }
430
577
  phaseNodeIds.push(ids);
431
578
  }
579
+ const idlessInHybrid = takeSteplessStepIds();
580
+ if (idlessInHybrid.length > 0) {
581
+ pushDiagnostic(
582
+ opts,
583
+ "step_without_id",
584
+ "warning",
585
+ "Step(s) carrying no id cannot become planning nodes and were dropped (feature#index): " + idlessInHybrid.join(", "),
586
+ idlessInHybrid
587
+ );
588
+ }
432
589
  const placed = new Set(phaseNodeIds.flat());
433
590
  const orderedNodes = [];
434
591
  for (const ids of phaseNodeIds) {
@@ -459,7 +616,13 @@ function emitFromHybrid(hybrid, opts) {
459
616
  const anchorPhaseIdx = anchorNode ? emitCtx.findIndex((c2) => c2.id === anchorNode.phase_id) : -1;
460
617
  const stepPhaseIdx = stepNode ? emitCtx.findIndex((c2) => c2.id === stepNode.phase_id) : -1;
461
618
  if (emitCtx.length > 0 && anchorPhaseIdx > stepPhaseIdx) {
462
- pushWarning(opts, "hybrid link drift: concept " + cid + " is fully taught in a later phase than consumer " + firstStep + " (a preview covers the dependency)");
619
+ pushDiagnostic(
620
+ opts,
621
+ "hybrid_link_drift",
622
+ "warning",
623
+ "hybrid link drift: concept " + cid + " is fully taught in a later phase than consumer " + firstStep + " (a preview covers the dependency)",
624
+ [cid, firstStep]
625
+ );
463
626
  continue;
464
627
  }
465
628
  pushEdge(cid, firstStep, str(l.relationship, "hybrid link") + " [" + depth + "]");
@@ -473,12 +636,12 @@ function emitFromHybrid(hybrid, opts) {
473
636
  const feedOrder = new Map(nodes.map((n, i) => [n.id, i]));
474
637
  const introducer = /* @__PURE__ */ new Map();
475
638
  for (const n of nodes) for (const k of n.keywords?.new ?? []) {
476
- const key = k.toLowerCase();
639
+ const key = keywordKey(k);
477
640
  if (!introducer.has(key)) introducer.set(key, n.id);
478
641
  }
479
642
  for (const n of nodes) {
480
643
  for (const k of n.keywords?.prerequisite ?? []) {
481
- const src = introducer.get(k.toLowerCase());
644
+ const src = introducer.get(keywordKey(k));
482
645
  if (!src || src === n.id) continue;
483
646
  const srcNode = nodes.find((x) => x.id === src);
484
647
  const phaseOf = (id) => {
@@ -489,9 +652,12 @@ function emitFromHybrid(hybrid, opts) {
489
652
  const fromPhase = phaseOf(src);
490
653
  const toPhase = phaseOf(n.id);
491
654
  if (conceptOvertaken || emitCtx.length > 0 && fromPhase > toPhase) {
492
- pushWarning(
655
+ pushDiagnostic(
493
656
  opts,
494
- "keyword-order drift: introducer " + src + " sits in a later phase than consumer " + n.id + ' (keyword "' + k + '") \u2014 host should move the introducer earlier'
657
+ conceptOvertaken ? "concept_overtaken_by_step" : "keyword_order_drift",
658
+ "warning",
659
+ "keyword-order drift: introducer " + src + " sits in a later phase than consumer " + n.id + ' (keyword "' + k + '") \u2014 host should move the introducer earlier',
660
+ [src, n.id]
495
661
  );
496
662
  continue;
497
663
  }
@@ -507,7 +673,7 @@ function emitFromHybrid(hybrid, opts) {
507
673
  }));
508
674
  return validateCurriculumFeed({
509
675
  schema_version: 2,
510
- source: { graph_type: "hybrid_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
676
+ source: buildSource(opts, "hybrid_graph"),
511
677
  learning_nodes: nodes,
512
678
  dependency_edges: edges,
513
679
  suggested_groupings: groupings,
@@ -535,11 +701,17 @@ function emitFromKnowledge(kg, opts) {
535
701
  const edges = [];
536
702
  const groupings = [];
537
703
  const conceptIds = new Set(arr(kg.concepts).map((c) => str(c.id)));
704
+ const externalByNode = /* @__PURE__ */ new Map();
538
705
  for (const c of arr(kg.concepts)) {
539
706
  const node = feedNodeFromConcept(c);
540
707
  nodes.push(node);
541
- for (const p of strs(c.prerequisites)) {
542
- if (p !== node.id && conceptIds.has(p)) {
708
+ const { internal, external } = splitPrerequisites(c, conceptIds);
709
+ if (external.length > 0) {
710
+ node.external_prerequisites = external;
711
+ externalByNode.set(node.id, external);
712
+ }
713
+ for (const p of internal) {
714
+ if (p !== node.id) {
543
715
  edges.push({
544
716
  from: p,
545
717
  to: node.id,
@@ -549,6 +721,7 @@ function emitFromKnowledge(kg, opts) {
549
721
  }
550
722
  }
551
723
  }
724
+ noteExternalPrerequisites(opts, externalByNode);
552
725
  if (arr(kg.learning_path).length > 0) {
553
726
  const orderMap = /* @__PURE__ */ new Map();
554
727
  arr(kg.learning_path).forEach((s, idx) => {
@@ -573,7 +746,7 @@ function emitFromKnowledge(kg, opts) {
573
746
  newKeywordsInOrder(nodes);
574
747
  return validateCurriculumFeed({
575
748
  schema_version: 2,
576
- source: { graph_type: "knowledge_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
749
+ source: buildSource(opts, "knowledge_graph"),
577
750
  learning_nodes: nodes,
578
751
  dependency_edges: edges,
579
752
  suggested_groupings: groupings,
@@ -584,17 +757,38 @@ function emitFromProject(pg, opts) {
584
757
  const nodes = [];
585
758
  const edges = [];
586
759
  const groupings = [];
587
- arr(pg.features).forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad2(fi + 1)), nodes, edges, groupings));
588
- if (nodes.length === 0 && arr(pg.features).length > 0) {
760
+ const allFeatures = arr(pg.features);
761
+ allFeatures.forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad2(fi + 1)), nodes, edges, groupings));
762
+ const stepless = allFeatures.filter((f) => arr(f.steps).length === 0).map((f) => str(f.id)).filter(Boolean);
763
+ if (stepless.length > 0) {
764
+ pushDiagnostic(
765
+ opts,
766
+ "feature_without_steps",
767
+ "warning",
768
+ "Feature(s) declared but carrying no steps \u2014 they will be absent from the feed: " + stepless.join(", "),
769
+ stepless
770
+ );
771
+ }
772
+ const idless = takeSteplessStepIds();
773
+ if (idless.length > 0) {
774
+ pushDiagnostic(
775
+ opts,
776
+ "step_without_id",
777
+ "warning",
778
+ "Step(s) carrying no id cannot become planning nodes and were dropped (feature#index): " + idless.join(", "),
779
+ idless
780
+ );
781
+ }
782
+ if (nodes.length === 0 && allFeatures.length > 0) {
589
783
  const hasTasks = arr(pg.implementation ?? {}).length > 0 || arr(pg.implementation?.tasks).length > 0;
590
784
  throw new Error(
591
- "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.")
785
+ "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.")
592
786
  );
593
787
  }
594
788
  newKeywordsInOrder(nodes);
595
789
  return validateCurriculumFeed({
596
790
  schema_version: 2,
597
- source: { graph_type: "project_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
791
+ source: buildSource(opts, "project_graph"),
598
792
  learning_nodes: nodes,
599
793
  dependency_edges: edges,
600
794
  suggested_groupings: groupings,
@@ -613,6 +807,8 @@ function emitCurriculumFeed(graph, opts = {}) {
613
807
  return emitFromProject(g, opts);
614
808
  }
615
809
 
810
+ exports.DEFAULT_STEP_MINUTES = DEFAULT_STEP_MINUTES;
811
+ exports.DEPTH_MINUTE_FRACTIONS = DEPTH_MINUTE_FRACTIONS;
616
812
  exports.emitCurriculumFeed = emitCurriculumFeed;
617
813
  //# sourceMappingURL=index.cjs.map
618
814
  //# sourceMappingURL=index.cjs.map