@thanh01.pmt/domain-kit 0.2.1 → 0.4.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 (51) hide show
  1. package/README.md +42 -11
  2. package/dist/{chunk-CRN6D4HG.mjs → chunk-HILSUZOF.mjs} +32 -8
  3. package/dist/chunk-HILSUZOF.mjs.map +1 -0
  4. package/dist/{chunk-23TQPTHG.mjs → chunk-LZQLLAYP.mjs} +5 -5
  5. package/dist/{chunk-23TQPTHG.mjs.map → chunk-LZQLLAYP.mjs.map} +1 -1
  6. package/dist/{chunk-HH4UX65C.mjs → chunk-PKUEOSZ3.mjs} +42 -4
  7. package/dist/chunk-PKUEOSZ3.mjs.map +1 -0
  8. package/dist/{chunk-HUDFN4IX.mjs → chunk-QJF4QCBJ.mjs} +99 -4
  9. package/dist/chunk-QJF4QCBJ.mjs.map +1 -0
  10. package/dist/{chunk-WHBAGJWN.mjs → chunk-VCPIWJ7R.mjs} +4 -4
  11. package/dist/chunk-VCPIWJ7R.mjs.map +1 -0
  12. package/dist/curriculumFeedEmitter-6IK6PFDH.mjs +4 -0
  13. package/dist/{curriculumFeedEmitter-E5DAJYUZ.mjs.map → curriculumFeedEmitter-6IK6PFDH.mjs.map} +1 -1
  14. package/dist/{curriculumFeedSchema-TEeQg2bY.d.ts → curriculumFeedSchema-B66H3gEV.d.cts} +148 -8
  15. package/dist/{curriculumFeedSchema-TEeQg2bY.d.cts → curriculumFeedSchema-B66H3gEV.d.ts} +148 -8
  16. package/dist/feed/index.cjs +136 -3
  17. package/dist/feed/index.cjs.map +1 -1
  18. package/dist/feed/index.d.cts +1 -1
  19. package/dist/feed/index.d.ts +1 -1
  20. package/dist/feed/index.mjs +2 -2
  21. package/dist/graph/index.cjs +30 -5
  22. package/dist/graph/index.cjs.map +1 -1
  23. package/dist/graph/index.d.cts +2 -2
  24. package/dist/graph/index.d.ts +2 -2
  25. package/dist/graph/index.mjs +1 -1
  26. package/dist/{hybridGraphPipeline-DVZYLCAQ.d.ts → hybridGraphPipeline-DXQDbBEj.d.ts} +14 -2
  27. package/dist/{hybridGraphPipeline-DmWRKYXf.d.cts → hybridGraphPipeline-dT-bio-3.d.cts} +14 -2
  28. package/dist/{hybridGraphSchema-BCgXicgA.d.ts → hybridGraphSchema-nrUeSwpU.d.cts} +15 -15
  29. package/dist/{hybridGraphSchema-BCgXicgA.d.cts → hybridGraphSchema-nrUeSwpU.d.ts} +15 -15
  30. package/dist/index.cjs +168 -10
  31. package/dist/index.cjs.map +1 -1
  32. package/dist/index.d.cts +3 -3
  33. package/dist/index.d.ts +3 -3
  34. package/dist/index.mjs +5 -5
  35. package/dist/pipeline/index.cjs +165 -8
  36. package/dist/pipeline/index.cjs.map +1 -1
  37. package/dist/pipeline/index.d.cts +3 -3
  38. package/dist/pipeline/index.d.ts +3 -3
  39. package/dist/pipeline/index.mjs +4 -4
  40. package/dist/schemas/index.cjs +42 -4
  41. package/dist/schemas/index.cjs.map +1 -1
  42. package/dist/schemas/index.d.cts +2 -2
  43. package/dist/schemas/index.d.ts +2 -2
  44. package/dist/schemas/index.mjs +2 -2
  45. package/package.json +10 -9
  46. package/LICENSE +0 -21
  47. package/dist/chunk-CRN6D4HG.mjs.map +0 -1
  48. package/dist/chunk-HH4UX65C.mjs.map +0 -1
  49. package/dist/chunk-HUDFN4IX.mjs.map +0 -1
  50. package/dist/chunk-WHBAGJWN.mjs.map +0 -1
  51. package/dist/curriculumFeedEmitter-E5DAJYUZ.mjs +0 -4
@@ -105,8 +105,39 @@ declare const FeedNodeSchema: z.ZodObject<{
105
105
  }>, "many">>;
106
106
  feature_id: z.ZodDefault<z.ZodNullable<z.ZodString>>;
107
107
  concept_ref: z.ZodDefault<z.ZodNullable<z.ZodString>>;
108
+ user_visible_deliverable: z.ZodDefault<z.ZodString>;
109
+ depth_variants: z.ZodOptional<z.ZodObject<{
110
+ ulo: z.ZodNumber;
111
+ cio: z.ZodNumber;
112
+ sio: z.ZodNumber;
113
+ }, "strip", z.ZodTypeAny, {
114
+ ulo: number;
115
+ cio: number;
116
+ sio: number;
117
+ }, {
118
+ ulo: number;
119
+ cio: number;
120
+ sio: number;
121
+ }>>;
122
+ depth_scaffold_candidates: z.ZodOptional<z.ZodArray<z.ZodObject<{
123
+ from_depth: z.ZodEnum<["ulo", "cio", "sio"]>;
124
+ to_depth: z.ZodEnum<["ulo", "cio", "sio"]>;
125
+ minutes_saved: z.ZodNumber;
126
+ reason: z.ZodDefault<z.ZodString>;
127
+ }, "strip", z.ZodTypeAny, {
128
+ reason: string;
129
+ minutes_saved: number;
130
+ from_depth: "ulo" | "cio" | "sio";
131
+ to_depth: "ulo" | "cio" | "sio";
132
+ }, {
133
+ minutes_saved: number;
134
+ from_depth: "ulo" | "cio" | "sio";
135
+ to_depth: "ulo" | "cio" | "sio";
136
+ reason?: string | undefined;
137
+ }>, "many">>;
138
+ is_core: z.ZodDefault<z.ZodBoolean>;
108
139
  phase_id: z.ZodDefault<z.ZodString>;
109
- introduce_aspect: z.ZodDefault<z.ZodNullable<z.ZodEnum<["new", "advanced"]>>>;
140
+ introduce_aspect: z.ZodDefault<z.ZodNullable<z.ZodEnum<["new", "advanced", "preview"]>>>;
110
141
  }, "strip", z.ZodTypeAny, {
111
142
  estimated_minutes: number;
112
143
  id: string;
@@ -137,7 +168,20 @@ declare const FeedNodeSchema: z.ZodObject<{
137
168
  file: string;
138
169
  }[];
139
170
  concept_ref: string | null;
140
- introduce_aspect: "new" | "advanced" | null;
171
+ user_visible_deliverable: string;
172
+ is_core: boolean;
173
+ introduce_aspect: "new" | "advanced" | "preview" | null;
174
+ depth_variants?: {
175
+ ulo: number;
176
+ cio: number;
177
+ sio: number;
178
+ } | undefined;
179
+ depth_scaffold_candidates?: {
180
+ reason: string;
181
+ minutes_saved: number;
182
+ from_depth: "ulo" | "cio" | "sio";
183
+ to_depth: "ulo" | "cio" | "sio";
184
+ }[] | undefined;
141
185
  }, {
142
186
  estimated_minutes: number;
143
187
  id: string;
@@ -168,7 +212,20 @@ declare const FeedNodeSchema: z.ZodObject<{
168
212
  evidence?: string | undefined;
169
213
  }[] | undefined;
170
214
  concept_ref?: string | null | undefined;
171
- introduce_aspect?: "new" | "advanced" | null | undefined;
215
+ user_visible_deliverable?: string | undefined;
216
+ depth_variants?: {
217
+ ulo: number;
218
+ cio: number;
219
+ sio: number;
220
+ } | undefined;
221
+ depth_scaffold_candidates?: {
222
+ minutes_saved: number;
223
+ from_depth: "ulo" | "cio" | "sio";
224
+ to_depth: "ulo" | "cio" | "sio";
225
+ reason?: string | undefined;
226
+ }[] | undefined;
227
+ is_core?: boolean | undefined;
228
+ introduce_aspect?: "new" | "advanced" | "preview" | null | undefined;
172
229
  }>;
173
230
  declare const FeedEdgeSchema: z.ZodObject<{
174
231
  from: z.ZodString;
@@ -306,8 +363,39 @@ declare const CurriculumFeedSchema: z.ZodObject<{
306
363
  }>, "many">>;
307
364
  feature_id: z.ZodDefault<z.ZodNullable<z.ZodString>>;
308
365
  concept_ref: z.ZodDefault<z.ZodNullable<z.ZodString>>;
366
+ user_visible_deliverable: z.ZodDefault<z.ZodString>;
367
+ depth_variants: z.ZodOptional<z.ZodObject<{
368
+ ulo: z.ZodNumber;
369
+ cio: z.ZodNumber;
370
+ sio: z.ZodNumber;
371
+ }, "strip", z.ZodTypeAny, {
372
+ ulo: number;
373
+ cio: number;
374
+ sio: number;
375
+ }, {
376
+ ulo: number;
377
+ cio: number;
378
+ sio: number;
379
+ }>>;
380
+ depth_scaffold_candidates: z.ZodOptional<z.ZodArray<z.ZodObject<{
381
+ from_depth: z.ZodEnum<["ulo", "cio", "sio"]>;
382
+ to_depth: z.ZodEnum<["ulo", "cio", "sio"]>;
383
+ minutes_saved: z.ZodNumber;
384
+ reason: z.ZodDefault<z.ZodString>;
385
+ }, "strip", z.ZodTypeAny, {
386
+ reason: string;
387
+ minutes_saved: number;
388
+ from_depth: "ulo" | "cio" | "sio";
389
+ to_depth: "ulo" | "cio" | "sio";
390
+ }, {
391
+ minutes_saved: number;
392
+ from_depth: "ulo" | "cio" | "sio";
393
+ to_depth: "ulo" | "cio" | "sio";
394
+ reason?: string | undefined;
395
+ }>, "many">>;
396
+ is_core: z.ZodDefault<z.ZodBoolean>;
309
397
  phase_id: z.ZodDefault<z.ZodString>;
310
- introduce_aspect: z.ZodDefault<z.ZodNullable<z.ZodEnum<["new", "advanced"]>>>;
398
+ introduce_aspect: z.ZodDefault<z.ZodNullable<z.ZodEnum<["new", "advanced", "preview"]>>>;
311
399
  }, "strip", z.ZodTypeAny, {
312
400
  estimated_minutes: number;
313
401
  id: string;
@@ -338,7 +426,20 @@ declare const CurriculumFeedSchema: z.ZodObject<{
338
426
  file: string;
339
427
  }[];
340
428
  concept_ref: string | null;
341
- introduce_aspect: "new" | "advanced" | null;
429
+ user_visible_deliverable: string;
430
+ is_core: boolean;
431
+ introduce_aspect: "new" | "advanced" | "preview" | null;
432
+ depth_variants?: {
433
+ ulo: number;
434
+ cio: number;
435
+ sio: number;
436
+ } | undefined;
437
+ depth_scaffold_candidates?: {
438
+ reason: string;
439
+ minutes_saved: number;
440
+ from_depth: "ulo" | "cio" | "sio";
441
+ to_depth: "ulo" | "cio" | "sio";
442
+ }[] | undefined;
342
443
  }, {
343
444
  estimated_minutes: number;
344
445
  id: string;
@@ -369,7 +470,20 @@ declare const CurriculumFeedSchema: z.ZodObject<{
369
470
  evidence?: string | undefined;
370
471
  }[] | undefined;
371
472
  concept_ref?: string | null | undefined;
372
- introduce_aspect?: "new" | "advanced" | null | undefined;
473
+ user_visible_deliverable?: string | undefined;
474
+ depth_variants?: {
475
+ ulo: number;
476
+ cio: number;
477
+ sio: number;
478
+ } | undefined;
479
+ depth_scaffold_candidates?: {
480
+ minutes_saved: number;
481
+ from_depth: "ulo" | "cio" | "sio";
482
+ to_depth: "ulo" | "cio" | "sio";
483
+ reason?: string | undefined;
484
+ }[] | undefined;
485
+ is_core?: boolean | undefined;
486
+ introduce_aspect?: "new" | "advanced" | "preview" | null | undefined;
373
487
  }>, "many">;
374
488
  dependency_edges: z.ZodDefault<z.ZodArray<z.ZodObject<{
375
489
  from: z.ZodString;
@@ -466,7 +580,20 @@ declare const CurriculumFeedSchema: z.ZodObject<{
466
580
  file: string;
467
581
  }[];
468
582
  concept_ref: string | null;
469
- introduce_aspect: "new" | "advanced" | null;
583
+ user_visible_deliverable: string;
584
+ is_core: boolean;
585
+ introduce_aspect: "new" | "advanced" | "preview" | null;
586
+ depth_variants?: {
587
+ ulo: number;
588
+ cio: number;
589
+ sio: number;
590
+ } | undefined;
591
+ depth_scaffold_candidates?: {
592
+ reason: string;
593
+ minutes_saved: number;
594
+ from_depth: "ulo" | "cio" | "sio";
595
+ to_depth: "ulo" | "cio" | "sio";
596
+ }[] | undefined;
470
597
  }[];
471
598
  dependency_edges: {
472
599
  from: string;
@@ -517,7 +644,20 @@ declare const CurriculumFeedSchema: z.ZodObject<{
517
644
  evidence?: string | undefined;
518
645
  }[] | undefined;
519
646
  concept_ref?: string | null | undefined;
520
- introduce_aspect?: "new" | "advanced" | null | undefined;
647
+ user_visible_deliverable?: string | undefined;
648
+ depth_variants?: {
649
+ ulo: number;
650
+ cio: number;
651
+ sio: number;
652
+ } | undefined;
653
+ depth_scaffold_candidates?: {
654
+ minutes_saved: number;
655
+ from_depth: "ulo" | "cio" | "sio";
656
+ to_depth: "ulo" | "cio" | "sio";
657
+ reason?: string | undefined;
658
+ }[] | undefined;
659
+ is_core?: boolean | undefined;
660
+ introduce_aspect?: "new" | "advanced" | "preview" | null | undefined;
521
661
  }[];
522
662
  phases?: {
523
663
  id: string;
@@ -31,11 +31,33 @@ var FeedNodeSchema = zod.z.object({
31
31
  references: zod.z.array(zod.z.object({ file: zod.z.string().min(1), evidence: zod.z.string().default("") })).default([]),
32
32
  feature_id: zod.z.string().nullable().default(null),
33
33
  concept_ref: zod.z.string().nullable().default(null),
34
+ // P52/T2.1: outcome.user_visible projected from the ProjectGraph step — the
35
+ // tangible checkpoint the session-cutting planner aims for (empty when the
36
+ // step declares no user-visible result). Concepts carry '' by default.
37
+ user_visible_deliverable: zod.z.string().default(""),
38
+ // P52/T3.1 — depth-reconcile inputs (optional/defaulted so old feeds
39
+ // validate unchanged):
40
+ // • depth_variants: minutes to teach this node at each depth level.
41
+ // ULO (know-it-exists) ≤ CIO (mechanism) ≤ SIO (implementation). All 3
42
+ // required so a downgrade never turns an anchor teach into a zero.
43
+ // • depth_scaffold_candidates: parallel to scaffold_candidates but
44
+ // acts on DEPTH (e.g. SIO→CIO) instead of lesson time.
45
+ // • is_core: concept is a Master-Tree core concept — reconciler must
46
+ // never downgrade it; escalate instead.
47
+ depth_variants: zod.z.object({ ulo: zod.z.number().int().nonnegative(), cio: zod.z.number().int().nonnegative(), sio: zod.z.number().int().nonnegative() }).optional(),
48
+ depth_scaffold_candidates: zod.z.array(zod.z.object({
49
+ from_depth: zod.z.enum(["ulo", "cio", "sio"]),
50
+ to_depth: zod.z.enum(["ulo", "cio", "sio"]),
51
+ minutes_saved: zod.z.number().int().nonnegative(),
52
+ reason: zod.z.string().default("")
53
+ })).optional(),
54
+ is_core: zod.z.boolean().default(false),
34
55
  // Progressive-completion bookkeeping (feed v2, additive):
35
56
  // phase_id = development phase that introduces/uses this node;
36
- // introduce_aspect = how the phase teaches a concept node (first intro vs advanced revisit).
57
+ // introduce_aspect = how the phase teaches a concept node (first intro, advanced
58
+ // revisit, or preview — a light ULO awareness pass that is NOT the introduction).
37
59
  phase_id: zod.z.string().default(""),
38
- introduce_aspect: zod.z.enum(["new", "advanced"]).nullable().default(null)
60
+ introduce_aspect: zod.z.enum(["new", "advanced", "preview"]).nullable().default(null)
39
61
  });
40
62
  var FeedEdgeSchema = zod.z.object({
41
63
  from: zod.z.string().min(1),
@@ -94,6 +116,22 @@ function validateCurriculumFeed(feed) {
94
116
  }
95
117
  const phaseIds = parsed.phases.map((p) => p.id);
96
118
  if (new Set(phaseIds).size !== phaseIds.length) throw new Error("curriculum feed contains duplicate phase ids");
119
+ if (parsed.phases.length > 0) {
120
+ const phaseIndexOf = /* @__PURE__ */ new Map();
121
+ parsed.phases.forEach((p, i) => {
122
+ for (const id of p.node_ids) phaseIndexOf.set(id, i);
123
+ });
124
+ for (const e of parsed.dependency_edges) {
125
+ const fromIdx = phaseIndexOf.get(e.from);
126
+ const toIdx = phaseIndexOf.get(e.to);
127
+ if (fromIdx === void 0 || toIdx === void 0) continue;
128
+ if (fromIdx > toIdx) {
129
+ throw new Error(
130
+ "curriculum feed teaching-order violation: prerequisite " + e.from + " (phase " + (fromIdx + 1) + ") is scheduled AFTER its consumer " + e.to + " (phase " + (toIdx + 1) + ")"
131
+ );
132
+ }
133
+ }
134
+ }
97
135
  return parsed;
98
136
  }
99
137
 
@@ -108,6 +146,9 @@ function stepMinutes(step) {
108
146
  const effort = step.effort ?? {};
109
147
  return Math.max(0, Math.round(num(effort.estimated_minutes, num(step.estimated_minutes, 45))));
110
148
  }
149
+ function pushWarning(opts, message) {
150
+ (opts.warnings ?? (opts.warnings = [])).push(message);
151
+ }
111
152
  function newKeywordsInOrder(nodes) {
112
153
  const seen = /* @__PURE__ */ new Set();
113
154
  for (const n of nodes) {
@@ -137,6 +178,10 @@ function feedNodeFromStep(step, featureId) {
137
178
  references: arr(step.files).map((f) => ({ file: str(f), evidence: "step file" })).filter((f) => f.file),
138
179
  feature_id: featureId,
139
180
  concept_ref: null,
181
+ // P52/T2.1: carry the step's tangible outcome so the planner can cut
182
+ // sessions at user-visible checkpoints instead of raw minute counts.
183
+ user_visible_deliverable: str(step.outcome?.user_visible),
184
+ is_core: false,
140
185
  phase_id: "",
141
186
  introduce_aspect: null
142
187
  };
@@ -155,6 +200,20 @@ function deriveConceptDepthHint(concept) {
155
200
  if (str(concept.cio) || prereqs.length >= 1) return "cio";
156
201
  return "ulo";
157
202
  }
203
+ 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);
207
+ const variants = { ulo, cio, sio };
208
+ const candidates = [];
209
+ if (depth === "sio") {
210
+ candidates.push({ from_depth: "sio", to_depth: "cio", minutes_saved: sio - cio, reason: "SIO implementation practice deferred \u2014 CIO mechanism suffices for downstream consumers" });
211
+ }
212
+ if (depth === "cio" || depth === "sio") {
213
+ 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" });
214
+ }
215
+ return { depth_variants: variants, depth_scaffold_candidates: candidates };
216
+ }
158
217
  function feedNodeFromConcept(concept) {
159
218
  const id = str(concept.id);
160
219
  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]);
@@ -184,6 +243,10 @@ function feedNodeFromConcept(concept) {
184
243
  references,
185
244
  feature_id: null,
186
245
  concept_ref: id,
246
+ user_visible_deliverable: "",
247
+ // concepts are taught, not shipped — checkpoints live on product steps
248
+ ...deriveDepthInputs(Math.max(0, Math.round(num(concept.estimated_minutes, 15))), deriveConceptDepthHint(concept)),
249
+ is_core: false,
187
250
  phase_id: "",
188
251
  introduce_aspect: null
189
252
  };
@@ -244,6 +307,7 @@ function emitFromHybrid(hybrid, opts) {
244
307
  }
245
308
  const emitCtx = [];
246
309
  const emittedConcepts = /* @__PURE__ */ new Set();
310
+ const previewsEmitted = /* @__PURE__ */ new Set();
247
311
  const linkList = arr(hybrid.links);
248
312
  for (const rp of rawPhases) {
249
313
  const pid = str(rp.id);
@@ -252,7 +316,8 @@ function emitFromHybrid(hybrid, opts) {
252
316
  const intros = arr(rp.introduces);
253
317
  const news = intros.filter((i) => str(i.aspect, "new") === "new");
254
318
  const advanced = intros.filter((i) => str(i.aspect) === "advanced");
255
- for (const intro of [...news, ...advanced]) {
319
+ const previews = intros.filter((i) => str(i.aspect) === "preview");
320
+ for (const intro of [...news, ...advanced, ...previews]) {
256
321
  const cid = str(intro.concept_id);
257
322
  const c = conceptById.get(cid);
258
323
  if (!c) {
@@ -261,6 +326,39 @@ function emitFromHybrid(hybrid, opts) {
261
326
  if (str(intro.aspect, "new") === "advanced" && !emittedConcepts.has(cid)) {
262
327
  throw new Error("Phase " + pid + " advanced-revisits concept " + cid + " that was never emitted - fail-closed");
263
328
  }
329
+ if (str(intro.aspect, "new") === "preview") {
330
+ if (emittedConcepts.has(cid)) {
331
+ throw new Error("Phase " + pid + " previews concept " + cid + " that was already introduced - preview must precede the anchor teach - fail-closed");
332
+ }
333
+ if (previewsEmitted.has(cid)) {
334
+ throw new Error("Phase " + pid + " duplicate preview of concept " + cid + " - fail-closed");
335
+ }
336
+ const pidIdx = rawPhases.findIndex((rp2) => str(rp2.id) === pid);
337
+ const anchorIdx = rawPhases.findIndex((rp2) => arr(rp2.introduces).some((i2) => str(i2.concept_id) === cid && str(i2.aspect, "new") !== "preview"));
338
+ if (anchorIdx < 0) {
339
+ throw new Error("Phase " + pid + " previews concept " + cid + " that is never fully introduced in any phase - fail-closed");
340
+ }
341
+ if (pidIdx >= anchorIdx) {
342
+ throw new Error("Phase " + pid + " previews concept " + cid + " whose anchor introduction is not in a LATER phase - fail-closed");
343
+ }
344
+ previewsEmitted.add(cid);
345
+ const prevNode = feedNodeFromConcept(c);
346
+ prevNode.id = cid + "__PREV" + pad2(order);
347
+ prevNode.name = str(c.name, cid) + " (preview)";
348
+ prevNode.phase_id = pid;
349
+ prevNode.introduce_aspect = "preview";
350
+ prevNode.estimated_minutes = Math.max(5, Math.round(prevNode.estimated_minutes * 0.25));
351
+ prevNode.depth_hint = "ulo";
352
+ prevNode.bloom_hint = "Understand";
353
+ prevNode.concept_codes = [str(c.code) || cid];
354
+ prevNode.keywords = { all: strs(c.keywords), new: [], prerequisite: [] };
355
+ prevNode.scaffolding = { scaffold_candidates: [], core_value_minutes: 0 };
356
+ prevNode.depth_variants = { ulo: prevNode.estimated_minutes, cio: prevNode.estimated_minutes, sio: prevNode.estimated_minutes };
357
+ prevNode.depth_scaffold_candidates = [];
358
+ nodes.push(prevNode);
359
+ conceptOrder.push(prevNode.id);
360
+ continue;
361
+ }
264
362
  if (!emittedConcepts.has(cid)) {
265
363
  const node = feedNodeFromConcept(c);
266
364
  node.phase_id = pid;
@@ -281,6 +379,7 @@ function emitFromHybrid(hybrid, opts) {
281
379
  node.name = str(c.name, cid) + " (advanced)";
282
380
  node.phase_id = pid;
283
381
  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" }];
284
383
  node.estimated_minutes = Math.max(15, Math.round(node.estimated_minutes * 0.5));
285
384
  node.concept_codes = [str(c.code) || cid];
286
385
  edges.push({ from: cid, to: revisitId, kind: "knowledge", reason: "advanced revisit of " + cid + " at phase " + pid });
@@ -316,6 +415,17 @@ function emitFromHybrid(hybrid, opts) {
316
415
  }
317
416
  phaseNodeIds.push(ids);
318
417
  }
418
+ const placed = new Set(phaseNodeIds.flat());
419
+ const orderedNodes = [];
420
+ for (const ids of phaseNodeIds) {
421
+ for (const id of ids) {
422
+ const n = nodes.find((x) => x.id === id);
423
+ if (n) orderedNodes.push(n);
424
+ }
425
+ }
426
+ for (const n of nodes) if (!placed.has(n.id)) orderedNodes.push(n);
427
+ nodes.length = 0;
428
+ nodes.push(...orderedNodes);
319
429
  const edgeSeen = new Set(edges.map((e) => e.from + ">" + e.to));
320
430
  const pushEdge = (from, to, reason) => {
321
431
  const key = from + ">" + to;
@@ -330,6 +440,14 @@ function emitFromHybrid(hybrid, opts) {
330
440
  const firstStep = (stepIdsByFeature.get(fid) || [])[0];
331
441
  if (!firstStep) continue;
332
442
  const depth = str(l.depth, "cio");
443
+ const anchorNode = nodes.find((n2) => n2.kind === "concept" && n2.concept_ref === cid && n2.introduce_aspect === "new");
444
+ const stepNode = nodes.find((n2) => n2.id === firstStep);
445
+ const anchorPhaseIdx = anchorNode ? emitCtx.findIndex((c2) => c2.id === anchorNode.phase_id) : -1;
446
+ const stepPhaseIdx = stepNode ? emitCtx.findIndex((c2) => c2.id === stepNode.phase_id) : -1;
447
+ 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)");
449
+ continue;
450
+ }
333
451
  pushEdge(cid, firstStep, str(l.relationship, "hybrid link") + " [" + depth + "]");
334
452
  for (const n of nodes) {
335
453
  if (n.kind === "concept" && n.concept_ref === cid && n.introduce_aspect === "advanced" && n.phase_id === str(l.phase_id)) {
@@ -348,6 +466,21 @@ function emitFromHybrid(hybrid, opts) {
348
466
  for (const k of n.keywords?.prerequisite ?? []) {
349
467
  const src = introducer.get(k.toLowerCase());
350
468
  if (!src || src === n.id) continue;
469
+ const srcNode = nodes.find((x) => x.id === src);
470
+ const phaseOf = (id) => {
471
+ const n2 = nodes.find((x) => x.id === id);
472
+ return n2 ? emitCtx.findIndex((c) => c.id === n2.phase_id) : -1;
473
+ };
474
+ const conceptOvertaken = n.kind === "concept" && srcNode?.kind === "product_step";
475
+ const fromPhase = phaseOf(src);
476
+ const toPhase = phaseOf(n.id);
477
+ if (conceptOvertaken || emitCtx.length > 0 && fromPhase > toPhase) {
478
+ pushWarning(
479
+ 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'
481
+ );
482
+ continue;
483
+ }
351
484
  if (feedOrder.get(src) < feedOrder.get(n.id)) pushEdge(src, n.id, "keyword prerequisite [" + k + "]");
352
485
  }
353
486
  }