@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
@@ -52,6 +52,23 @@ 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([]),
63
+ // Depth layer text (optional, additive — old feeds validate unchanged).
64
+ // Carries the ULO/CIO/SIO authored text from the KnowledgeGraph concept
65
+ // so downstream consumers (KX generator) can inject depth-appropriate
66
+ // material into their prompts without a second LLM call.
67
+ depth_context: zod.z.object({
68
+ ulo: zod.z.string().default(""),
69
+ cio: zod.z.string().default(""),
70
+ sio: zod.z.array(zod.z.string()).default([])
71
+ }).optional(),
55
72
  // Progressive-completion bookkeeping (feed v2, additive):
56
73
  // phase_id = development phase that introduces/uses this node;
57
74
  // introduce_aspect = how the phase teaches a concept node (first intro, advanced
@@ -78,12 +95,28 @@ var FeedPhaseSchema = zod.z.object({
78
95
  product_completion: zod.z.string().min(1),
79
96
  node_ids: zod.z.array(zod.z.string()).min(1)
80
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
+ });
81
111
  var CurriculumFeedSchema = zod.z.object({
82
112
  schema_version: zod.z.literal(2),
83
113
  source: zod.z.object({
84
114
  graph_type: zod.z.enum(["project_graph", "knowledge_graph", "hybrid_graph"]),
115
+ /** DERIVED from diagnostics — do not write to it directly. */
85
116
  warnings: zod.z.array(zod.z.string()).default([]),
86
- 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()
87
120
  }),
88
121
  learning_nodes: zod.z.array(FeedNodeSchema).min(1, "curriculum feed must contain at least one learning node"),
89
122
  dependency_edges: zod.z.array(FeedEdgeSchema).default([]),
@@ -116,6 +149,15 @@ function validateCurriculumFeed(feed) {
116
149
  }
117
150
  const phaseIds = parsed.phases.map((p) => p.id);
118
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
+ }
119
161
  if (parsed.phases.length > 0) {
120
162
  const phaseIndexOf = /* @__PURE__ */ new Map();
121
163
  parsed.phases.forEach((p, i) => {
@@ -136,18 +178,82 @@ function validateCurriculumFeed(feed) {
136
178
  }
137
179
 
138
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;
139
197
  var BLOOM_RANK = { Remember: 0, Understand: 1, Apply: 2, Analyze: 3, Evaluate: 4, Create: 5 };
140
198
  var pad2 = (n) => String(n).padStart(2, "0");
199
+ var steplessStepIds = [];
200
+ var takeSteplessStepIds = () => steplessStepIds.splice(0, steplessStepIds.length);
141
201
  var arr = (v) => Array.isArray(v) ? v : [];
142
202
  var str = (v, d = "") => typeof v === "string" ? v : d;
143
203
  var num = (v, d) => typeof v === "number" && Number.isFinite(v) ? v : d;
144
204
  var strs = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
145
205
  function stepMinutes(step) {
146
206
  const effort = step.effort ?? {};
147
- 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))));
148
208
  }
149
- function pushWarning(opts, message) {
150
- (opts.warnings ?? (opts.warnings = [])).push(message);
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
+ };
254
+ }
255
+ function keywordKey(k) {
256
+ return k.normalize("NFC").toLowerCase();
151
257
  }
152
258
  function newKeywordsInOrder(nodes) {
153
259
  const seen = /* @__PURE__ */ new Set();
@@ -156,9 +262,9 @@ function newKeywordsInOrder(nodes) {
156
262
  n.keywords.new = [];
157
263
  n.keywords.prerequisite = [];
158
264
  for (const k of all) {
159
- if (seen.has(k)) n.keywords.prerequisite.push(k);
265
+ if (seen.has(keywordKey(k))) n.keywords.prerequisite.push(k);
160
266
  else n.keywords.new.push(k);
161
- seen.add(k);
267
+ seen.add(keywordKey(k));
162
268
  }
163
269
  }
164
270
  }
@@ -182,6 +288,7 @@ function feedNodeFromStep(step, featureId) {
182
288
  // sessions at user-visible checkpoints instead of raw minute counts.
183
289
  user_visible_deliverable: str(step.outcome?.user_visible),
184
290
  is_core: false,
291
+ external_prerequisites: [],
185
292
  phase_id: "",
186
293
  introduce_aspect: null
187
294
  };
@@ -201,9 +308,9 @@ function deriveConceptDepthHint(concept) {
201
308
  return "ulo";
202
309
  }
203
310
  function deriveDepthInputs(minutes, depth) {
204
- const ulo = Math.max(5, Math.round(minutes * 0.35));
205
- const cio = Math.max(ulo + 5, Math.round(minutes * 0.65));
206
- 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);
207
314
  const variants = { ulo, cio, sio };
208
315
  const candidates = [];
209
316
  if (depth === "sio") {
@@ -246,7 +353,16 @@ function feedNodeFromConcept(concept) {
246
353
  user_visible_deliverable: "",
247
354
  // concepts are taught, not shipped — checkpoints live on product steps
248
355
  ...deriveDepthInputs(Math.max(0, Math.round(num(concept.estimated_minutes, 15))), deriveConceptDepthHint(concept)),
249
- 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: [],
361
+ depth_context: {
362
+ ulo: str(concept.ulo),
363
+ cio: str(concept.cio),
364
+ sio: strs(concept.sio)
365
+ },
250
366
  phase_id: "",
251
367
  introduce_aspect: null
252
368
  };
@@ -257,7 +373,10 @@ function addFeatureGroup(f, fid, nodes, edges, groupings) {
257
373
  let prev = "";
258
374
  for (let i = 0; i < steps.length; i++) {
259
375
  const stepNode = feedNodeFromStep(steps[i], fid);
260
- if (!stepNode.id) continue;
376
+ if (!stepNode.id) {
377
+ steplessStepIds.push(fid + "#" + i);
378
+ continue;
379
+ }
261
380
  stepIds.push(stepNode.id);
262
381
  nodes.push(stepNode);
263
382
  if (prev) edges.push({ from: prev, to: stepNode.id, kind: "task", reason: "feature build order" });
@@ -280,18 +399,26 @@ function emitFromHybrid(hybrid, opts) {
280
399
  const features = arr(pg.features);
281
400
  const conceptRecs = arr(kg.concepts);
282
401
  const conceptById = new Map(conceptRecs.map((c) => [str(c.id), c]));
402
+ const knownConceptIds = new Set(conceptById.keys());
283
403
  if (rawPhases.length === 0) {
404
+ const externalByNode2 = /* @__PURE__ */ new Map();
284
405
  for (const c of conceptRecs) {
285
406
  const node = feedNodeFromConcept(c);
286
407
  nodes.push(node);
287
- 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" });
288
414
  }
415
+ noteExternalPrerequisites(opts, externalByNode2);
289
416
  features.forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad2(fi + 1)), nodes, edges, groupings));
290
417
  linkHybridKnowledgeEdges(hybrid, nodes, edges);
291
418
  newKeywordsInOrder(nodes);
292
419
  return validateCurriculumFeed({
293
420
  schema_version: 2,
294
- source: { graph_type: "hybrid_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
421
+ source: buildSource(opts, "hybrid_graph"),
295
422
  learning_nodes: nodes,
296
423
  dependency_edges: edges,
297
424
  suggested_groupings: groupings,
@@ -308,6 +435,8 @@ function emitFromHybrid(hybrid, opts) {
308
435
  const emitCtx = [];
309
436
  const emittedConcepts = /* @__PURE__ */ new Set();
310
437
  const previewsEmitted = /* @__PURE__ */ new Set();
438
+ const previewNodeIdByConcept = /* @__PURE__ */ new Map();
439
+ const externalByNode = /* @__PURE__ */ new Map();
311
440
  const linkList = arr(hybrid.links);
312
441
  for (const rp of rawPhases) {
313
442
  const pid = str(rp.id);
@@ -347,7 +476,7 @@ function emitFromHybrid(hybrid, opts) {
347
476
  prevNode.name = str(c.name, cid) + " (preview)";
348
477
  prevNode.phase_id = pid;
349
478
  prevNode.introduce_aspect = "preview";
350
- 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));
351
480
  prevNode.depth_hint = "ulo";
352
481
  prevNode.bloom_hint = "Understand";
353
482
  prevNode.concept_codes = [str(c.code) || cid];
@@ -357,6 +486,7 @@ function emitFromHybrid(hybrid, opts) {
357
486
  prevNode.depth_scaffold_candidates = [];
358
487
  nodes.push(prevNode);
359
488
  conceptOrder.push(prevNode.id);
489
+ previewNodeIdByConcept.set(cid, prevNode.id);
360
490
  continue;
361
491
  }
362
492
  if (!emittedConcepts.has(cid)) {
@@ -365,13 +495,22 @@ function emitFromHybrid(hybrid, opts) {
365
495
  node.introduce_aspect = "new";
366
496
  nodes.push(node);
367
497
  for (const p of strs(c.prerequisites)) {
368
- if (!emittedConcepts.has(p)) {
369
- 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;
370
503
  }
371
- 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" });
372
509
  }
373
510
  emittedConcepts.add(cid);
374
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");
375
514
  } else {
376
515
  const revisitId = cid + "__ADV" + pad2(order);
377
516
  const node = feedNodeFromConcept(c);
@@ -379,8 +518,16 @@ function emitFromHybrid(hybrid, opts) {
379
518
  node.name = str(c.name, cid) + " (advanced)";
380
519
  node.phase_id = pid;
381
520
  node.introduce_aspect = "advanced";
382
- 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" }];
383
- 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
+ }];
384
531
  node.concept_codes = [str(c.code) || cid];
385
532
  edges.push({ from: cid, to: revisitId, kind: "knowledge", reason: "advanced revisit of " + cid + " at phase " + pid });
386
533
  nodes.push(node);
@@ -390,6 +537,7 @@ function emitFromHybrid(hybrid, opts) {
390
537
  const featureIds = strs(rp.feature_ids).filter((fid) => featureOrder.has(fid)).sort((a, b) => featureOrder.get(a) - featureOrder.get(b));
391
538
  emitCtx.push({ id: pid, order, name: str(rp.name, pid), productCompletion: str(rp.product_completion), conceptOrder, featureIds });
392
539
  }
540
+ noteExternalPrerequisites(opts, externalByNode);
393
541
  for (const l of linkList) {
394
542
  const cid = str(l.concept_id);
395
543
  if (cid && conceptById.has(cid) && !emittedConcepts.has(cid)) {
@@ -401,6 +549,16 @@ function emitFromHybrid(hybrid, opts) {
401
549
  if (missingFeatures.length > 0) {
402
550
  throw new Error("Features not covered by any phase: " + missingFeatures.join(", ") + " - fail-closed");
403
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
+ }
404
562
  const phaseNodeIds = [];
405
563
  for (const ctx of emitCtx) {
406
564
  const ids = [...ctx.conceptOrder];
@@ -408,13 +566,26 @@ function emitFromHybrid(hybrid, opts) {
408
566
  const f = features.find((x) => str(x.id) === fid);
409
567
  const stepIds = addFeatureGroup(f, fid, nodes, edges, groupings);
410
568
  for (const sid of stepIds) {
411
- const node = nodes.find((n) => n.id === sid);
569
+ const node = nodes.find((x) => x.id === sid);
412
570
  node.phase_id = ctx.id;
413
571
  }
414
572
  ids.push(...stepIds);
415
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
+ }
416
577
  phaseNodeIds.push(ids);
417
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
+ }
418
589
  const placed = new Set(phaseNodeIds.flat());
419
590
  const orderedNodes = [];
420
591
  for (const ids of phaseNodeIds) {
@@ -445,7 +616,13 @@ function emitFromHybrid(hybrid, opts) {
445
616
  const anchorPhaseIdx = anchorNode ? emitCtx.findIndex((c2) => c2.id === anchorNode.phase_id) : -1;
446
617
  const stepPhaseIdx = stepNode ? emitCtx.findIndex((c2) => c2.id === stepNode.phase_id) : -1;
447
618
  if (emitCtx.length > 0 && anchorPhaseIdx > stepPhaseIdx) {
448
- 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
+ );
449
626
  continue;
450
627
  }
451
628
  pushEdge(cid, firstStep, str(l.relationship, "hybrid link") + " [" + depth + "]");
@@ -459,12 +636,12 @@ function emitFromHybrid(hybrid, opts) {
459
636
  const feedOrder = new Map(nodes.map((n, i) => [n.id, i]));
460
637
  const introducer = /* @__PURE__ */ new Map();
461
638
  for (const n of nodes) for (const k of n.keywords?.new ?? []) {
462
- const key = k.toLowerCase();
639
+ const key = keywordKey(k);
463
640
  if (!introducer.has(key)) introducer.set(key, n.id);
464
641
  }
465
642
  for (const n of nodes) {
466
643
  for (const k of n.keywords?.prerequisite ?? []) {
467
- const src = introducer.get(k.toLowerCase());
644
+ const src = introducer.get(keywordKey(k));
468
645
  if (!src || src === n.id) continue;
469
646
  const srcNode = nodes.find((x) => x.id === src);
470
647
  const phaseOf = (id) => {
@@ -475,9 +652,12 @@ function emitFromHybrid(hybrid, opts) {
475
652
  const fromPhase = phaseOf(src);
476
653
  const toPhase = phaseOf(n.id);
477
654
  if (conceptOvertaken || emitCtx.length > 0 && fromPhase > toPhase) {
478
- pushWarning(
655
+ pushDiagnostic(
479
656
  opts,
480
- "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]
481
661
  );
482
662
  continue;
483
663
  }
@@ -493,7 +673,7 @@ function emitFromHybrid(hybrid, opts) {
493
673
  }));
494
674
  return validateCurriculumFeed({
495
675
  schema_version: 2,
496
- source: { graph_type: "hybrid_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
676
+ source: buildSource(opts, "hybrid_graph"),
497
677
  learning_nodes: nodes,
498
678
  dependency_edges: edges,
499
679
  suggested_groupings: groupings,
@@ -521,11 +701,17 @@ function emitFromKnowledge(kg, opts) {
521
701
  const edges = [];
522
702
  const groupings = [];
523
703
  const conceptIds = new Set(arr(kg.concepts).map((c) => str(c.id)));
704
+ const externalByNode = /* @__PURE__ */ new Map();
524
705
  for (const c of arr(kg.concepts)) {
525
706
  const node = feedNodeFromConcept(c);
526
707
  nodes.push(node);
527
- for (const p of strs(c.prerequisites)) {
528
- 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) {
529
715
  edges.push({
530
716
  from: p,
531
717
  to: node.id,
@@ -535,6 +721,7 @@ function emitFromKnowledge(kg, opts) {
535
721
  }
536
722
  }
537
723
  }
724
+ noteExternalPrerequisites(opts, externalByNode);
538
725
  if (arr(kg.learning_path).length > 0) {
539
726
  const orderMap = /* @__PURE__ */ new Map();
540
727
  arr(kg.learning_path).forEach((s, idx) => {
@@ -559,7 +746,7 @@ function emitFromKnowledge(kg, opts) {
559
746
  newKeywordsInOrder(nodes);
560
747
  return validateCurriculumFeed({
561
748
  schema_version: 2,
562
- source: { graph_type: "knowledge_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
749
+ source: buildSource(opts, "knowledge_graph"),
563
750
  learning_nodes: nodes,
564
751
  dependency_edges: edges,
565
752
  suggested_groupings: groupings,
@@ -570,17 +757,38 @@ function emitFromProject(pg, opts) {
570
757
  const nodes = [];
571
758
  const edges = [];
572
759
  const groupings = [];
573
- arr(pg.features).forEach((f, fi) => addFeatureGroup(f, str(f.id, "F" + pad2(fi + 1)), nodes, edges, groupings));
574
- 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) {
575
783
  const hasTasks = arr(pg.implementation ?? {}).length > 0 || arr(pg.implementation?.tasks).length > 0;
576
784
  throw new Error(
577
- "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.")
578
786
  );
579
787
  }
580
788
  newKeywordsInOrder(nodes);
581
789
  return validateCurriculumFeed({
582
790
  schema_version: 2,
583
- source: { graph_type: "project_graph", warnings: opts.warnings ?? [], hallucination_count: opts.hallucinationCount ?? 0 },
791
+ source: buildSource(opts, "project_graph"),
584
792
  learning_nodes: nodes,
585
793
  dependency_edges: edges,
586
794
  suggested_groupings: groupings,
@@ -599,6 +807,8 @@ function emitCurriculumFeed(graph, opts = {}) {
599
807
  return emitFromProject(g, opts);
600
808
  }
601
809
 
810
+ exports.DEFAULT_STEP_MINUTES = DEFAULT_STEP_MINUTES;
811
+ exports.DEPTH_MINUTE_FRACTIONS = DEPTH_MINUTE_FRACTIONS;
602
812
  exports.emitCurriculumFeed = emitCurriculumFeed;
603
813
  //# sourceMappingURL=index.cjs.map
604
814
  //# sourceMappingURL=index.cjs.map