@thanh01.pmt/domain-kit 0.5.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 (69) 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-TYXSJUQU.mjs → chunk-2OGNQUXC.mjs} +81 -32
  5. package/dist/chunk-2OGNQUXC.mjs.map +1 -0
  6. package/dist/{chunk-G7HLFMD2.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-E4GCCWJO.mjs → chunk-CGNXGVHV.mjs} +37 -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-TWKIVUTQ.mjs → chunk-WKTH3JPA.mjs} +197 -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-DUFwFyr2.d.ts → curriculumFeedSchema-DYayelAA.d.cts} +156 -1
  23. package/dist/{curriculumFeedSchema-DUFwFyr2.d.cts → curriculumFeedSchema-DYayelAA.d.ts} +156 -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 +229 -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 +73 -29
  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 +2634 -2359
  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 +2207 -1958
  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 +55 -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 +10 -9
  59. package/LICENSE +0 -21
  60. package/dist/chunk-BL5NBKPH.mjs.map +0 -1
  61. package/dist/chunk-E4GCCWJO.mjs.map +0 -1
  62. package/dist/chunk-G7HLFMD2.mjs.map +0 -1
  63. package/dist/chunk-TWKIVUTQ.mjs.map +0 -1
  64. package/dist/chunk-TYXSJUQU.mjs.map +0 -1
  65. package/dist/chunk-VCPIWJ7R.mjs.map +0 -1
  66. package/dist/curriculumFeedEmitter-T7NUBLAM.mjs +0 -4
  67. package/dist/curriculumFeedEmitter-T7NUBLAM.mjs.map +0 -1
  68. package/dist/llmClient-ysPhLjcH.d.cts +0 -16
  69. package/dist/llmClient-ysPhLjcH.d.ts +0 -16
@@ -37,9 +37,10 @@ function resolveProviderChain(config) {
37
37
  return chain;
38
38
  }
39
39
  var MIN_REQUEST_INTERVAL_MS = 2500;
40
+ var requestIntervalMs = Number(process.env.LLM_MIN_REQUEST_INTERVAL_MS || MIN_REQUEST_INTERVAL_MS);
40
41
  var lastRequestAt = 0;
41
42
  async function pacedDelay() {
42
- const wait = lastRequestAt + MIN_REQUEST_INTERVAL_MS - Date.now();
43
+ const wait = lastRequestAt + requestIntervalMs - Date.now();
43
44
  if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
44
45
  lastRequestAt = Date.now();
45
46
  }
@@ -54,6 +55,10 @@ function createLlmClient(config) {
54
55
  primary.apiKey;
55
56
  primary.baseUrl;
56
57
  const model = primary.model;
58
+ if (config?.minRequestIntervalMs !== void 0) {
59
+ requestIntervalMs = Math.max(0, config.minRequestIntervalMs);
60
+ }
61
+ const callLog = [];
57
62
  async function chat(messages, options) {
58
63
  const temperature = options?.temperature ?? config?.temperature ?? 0.1;
59
64
  const maxTokens = options?.maxTokens ?? config?.maxTokens ?? parseInt(process.env.LLM_MAX_TOKENS || "65536", 10);
@@ -68,6 +73,7 @@ function createLlmClient(config) {
68
73
  await new Promise((resolve) => setTimeout(resolve, 2e4));
69
74
  }
70
75
  for (const provider of chain) {
76
+ const recIdx = callLog.push({ provider: provider.label, model: provider.model, ok: false }) - 1;
71
77
  try {
72
78
  const headers = {
73
79
  "Content-Type": "application/json",
@@ -128,6 +134,7 @@ function createLlmClient(config) {
128
134
  const retryData = await retry.json();
129
135
  const retryContent2 = retryData.choices?.[0]?.message?.content || "";
130
136
  if (!retryContent2.trim()) throw err;
137
+ callLog[recIdx] = { ...callLog[recIdx], ok: true };
131
138
  return {
132
139
  content: retryContent2,
133
140
  model: retryData.model || provider.model,
@@ -156,6 +163,7 @@ function createLlmClient(config) {
156
163
  if (!retry.ok) throw err;
157
164
  const retryData = await retry.json();
158
165
  const retryContent = retryData.choices?.[0]?.message?.content || "";
166
+ callLog[recIdx] = { ...callLog[recIdx], ok: true };
159
167
  return {
160
168
  content: retryContent,
161
169
  model: retryData.model || provider.model,
@@ -186,6 +194,7 @@ function createLlmClient(config) {
186
194
  };
187
195
  } catch (err) {
188
196
  const msg = err instanceof Error ? err.message : String(err);
197
+ callLog[recIdx] = { ...callLog[recIdx], ok: false, error: msg.slice(0, 200) };
189
198
  attempts.push(`${provider.label}: ${msg}`);
190
199
  lastError = err instanceof LlmClientError ? err : new LlmClientError(`[${provider.label}] ${msg}`);
191
200
  }
@@ -195,7 +204,7 @@ function createLlmClient(config) {
195
204
  }
196
205
  throw lastError ?? new LlmClientError("All LLM providers failed after " + MAX_CHAIN_ROUNDS + " rounds.");
197
206
  }
198
- return { chat, model };
207
+ return { chat, model, callLog };
199
208
  }
200
209
  function parseJsonFromLlm(text) {
201
210
  let cleaned = text.trim();
@@ -259,8 +268,8 @@ async function llmChatJson(client, systemPrompt, userPrompt, options) {
259
268
 
260
269
  // src/graph/scaffoldExtractor.ts
261
270
  async function extractScaffold(options) {
262
- const { goal, techStack, fileList, llmConfig } = options;
263
- const client = createLlmClient(llmConfig);
271
+ const { goal, techStack, fileList, llmConfig, sharedClient } = options;
272
+ const client = sharedClient ?? createLlmClient(llmConfig);
264
273
  const fileListStr = fileList.length > 0 ? fileList.join("\n") : "(empty)";
265
274
  const systemPrompt = "You are a programming pedagogy expert. For ONE concrete project, extract the 'FOUNDATION & SETUP' feature (id F0) \u2014 the COMPLETE set of 'MUST-KNOW' steps the learner needs before studying the first feature. Break it into concrete ACTION steps (5-8 steps), each with completion_level='base'. Include:\n1. Tools required for this tech stack (IDE, simulator/emulator, terminal, git, package manager) + basic operations (open project, build, run, debug, commit).\n2. Create the initial project: from a template OR clone/open the base-project (the provided repo).\n3. MINIMAL programming knowledge of the language (variables, types, functions, if/for, view declaration) + build one small demo (e.g. a single-screen Hello World app) BEFORE touching the real project.\n4. Repo map of the actual project: folder structure, entry point, how to build/run, main architecture (MVVM/Flux...), important packages/modules.\n5. Development loop (build->run->see result->debug), how to read/fix basic compile errors, minimal git workflow (clone/branch/commit/push).\nkeywords[]: real tool/language terms (e.g. 'Xcode', 'Simulator', 'Git', 'Swift', '@main', 'MVVM'). files[]: leave [] or use real paths if the step touches specific files. description: ONE concise sentence, MAX 140 characters. intent: WHY this step matters. outcome: {user_visible: string, technical: string}. acceptance[]: 2 verifiable criteria. effort: {estimated_minutes: number, complexity: low|medium|high}. NEVER invent. ALL text must be in ENGLISH (technical terms stay as-is). Return JSON containing only feature F0.";
266
275
  const userPrompt = `App goal: ${goal}
@@ -345,8 +354,8 @@ ${content}`);
345
354
  return blocks.join("\n\n");
346
355
  }
347
356
  async function extractProjectOverview(options) {
348
- const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig } = options;
349
- const client = createLlmClient(llmConfig);
357
+ const { goal, techStack, sdkApiIndex, fileContentsMap, astKeywords, llmConfig, sharedClient } = options;
358
+ const client = sharedClient ?? createLlmClient(llmConfig);
350
359
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
351
360
  const allFilesStr = buildSourceText(fileContentsMap);
352
361
  let keywordsAnchor = "";
@@ -415,8 +424,8 @@ ${content}`);
415
424
  return blocks.join("\n\n");
416
425
  }
417
426
  async function extractFeatureSteps(options) {
418
- const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig } = options;
419
- const client = createLlmClient(llmConfig);
427
+ const { goal, techStack, sdkApiIndex, fileContentsMap, feature, llmConfig, sharedClient } = options;
428
+ const client = sharedClient ?? createLlmClient(llmConfig);
420
429
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
421
430
  const allFilesStr = buildSourceText2(fileContentsMap);
422
431
  const fid = feature.id || "F_UNK";
@@ -483,8 +492,8 @@ Split this feature into 2-6 action steps. Return JSON:
483
492
  concept_codes: []
484
493
  }));
485
494
  }
486
- async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig) {
487
- const client = createLlmClient(llmConfig);
495
+ async function extractFeatureStepsBatched(goal, techStack, sdkApiIndex, fileContentsMap, featuresMeta, llmConfig, sharedClient) {
496
+ const client = sharedClient ?? createLlmClient(llmConfig);
488
497
  const usedSdkApis = sdkApiIndex.sdk_apis.filter((api) => api.used_in_demo).map((api) => api.name).slice(0, 2e3);
489
498
  const sourceText = buildSourceText2(fileContentsMap);
490
499
  const featuresListing = featuresMeta.map(
@@ -751,7 +760,7 @@ function inferDepthFromContext(keyword, fileContentsMap) {
751
760
  return { depth: "sio", rationale: `Default: treating '${keyword}' as SIO (implementation-level)` };
752
761
  }
753
762
  async function escalateAndMapConcepts(options) {
754
- const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig } = options;
763
+ const { projectGraph, fileContentsMap, availableConcepts, conceptMapOverride, llmConfig, sharedClient } = options;
755
764
  const features = projectGraph.features || [];
756
765
  const allTerms = /* @__PURE__ */ new Set();
757
766
  for (const feature of features) {
@@ -767,7 +776,7 @@ async function escalateAndMapConcepts(options) {
767
776
  llmResults = conceptMapOverride;
768
777
  } else {
769
778
  try {
770
- const client = createLlmClient(llmConfig);
779
+ const client = sharedClient ?? createLlmClient(llmConfig);
771
780
  const conceptBank = (availableConcepts || []).sort().join(", ") || "(empty)";
772
781
  const termEntries = termList.map((term) => {
773
782
  const snippets = extractTermSnippets(term, fileContentsMap);
@@ -820,13 +829,16 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
820
829
  const cName = info.concept_name || kw;
821
830
  let depth = "sio";
822
831
  let depthRationale = "Default: SIO";
832
+ let depthSource = "override";
823
833
  if (info.depth && ["ulo", "cio", "sio"].includes(info.depth)) {
824
834
  depth = info.depth;
825
835
  depthRationale = `LLM classified as ${depth}`;
836
+ depthSource = "llm";
826
837
  } else {
827
838
  const inferred = inferDepthFromContext(kw, fileContentsMap);
828
839
  depth = inferred.depth;
829
840
  depthRationale = inferred.rationale;
841
+ depthSource = "heuristic";
830
842
  }
831
843
  const kwKey = `${cCode}::${kw}`;
832
844
  if (seen.has(kwKey)) continue;
@@ -844,6 +856,7 @@ Return JSON: {"results": {"<keyword>": {"concept_code": "...", "concept_name": "
844
856
  keyword: kw,
845
857
  depth,
846
858
  depth_rationale: depthRationale,
859
+ depth_source: depthSource,
847
860
  evidence_files: evidenceFiles
848
861
  });
849
862
  }
@@ -968,6 +981,13 @@ var ULO_CODE_RE = /^ULO-[A-Z][A-Z0-9_]*-\d{2}$/;
968
981
  var CIO_CODE_RE = /^CIO-[A-Z][A-Z0-9_]*-\d{2}-[A-Z][A-Z0-9_]*$/;
969
982
  var SIO_CODE_RE = /^SIO-[A-Z0-9]+-[A-Z][A-Z0-9_]*-\d{2}$/;
970
983
 
984
+ // src/utils/diacritics.ts
985
+ var COMBINING_MARKS = /[\u0300-\u036f]/g;
986
+ var D_WITH_STROKE = /đ/gi;
987
+ function stripDiacritics(s) {
988
+ return s.normalize("NFD").replace(COMBINING_MARKS, "").replace(D_WITH_STROKE, "d").toLowerCase();
989
+ }
990
+
971
991
  // src/parsers/syllabusParser.ts
972
992
  var STOP_WORDS = /* @__PURE__ */ new Set([
973
993
  "and",
@@ -1730,7 +1750,7 @@ function loadKnowledgeTreeCatalog(tsvPath) {
1730
1750
  }
1731
1751
  function tokens(s) {
1732
1752
  return new Set(
1733
- s.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
1753
+ stripDiacritics(s).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4)
1734
1754
  );
1735
1755
  }
1736
1756
  function filterBySelection(catalog, selectedCategories, selectedTopics, analysisConcepts, maxConcepts = 120) {
@@ -1904,6 +1924,15 @@ async function standardizeConcepts(opts) {
1904
1924
  }
1905
1925
 
1906
1926
  // src/graph/knowledgeGraphPipeline.ts
1927
+ var MIN_CALIBRATION_MINUTES = 15;
1928
+ var MAX_CALIBRATION_MINUTES = 60;
1929
+ var DEFAULT_CONCEPT_MINUTES = 30;
1930
+ var MIN_CALIBRATION_DIVERGENCE = 0.5;
1931
+ function calibrateConceptMinutes(sioCount, problemTypeCount, prereqCount, hasHighBloom) {
1932
+ const ratio = (value, cap) => Math.min(1, Math.max(0, value) / cap);
1933
+ const score = 0.3 * ratio(sioCount, 4) + 0.25 * ratio(problemTypeCount, 4) + 0.2 * ratio(prereqCount, 4) + 0.25 * (hasHighBloom ? 1 : 0);
1934
+ return Math.round(MIN_CALIBRATION_MINUTES + score * (MAX_CALIBRATION_MINUTES - MIN_CALIBRATION_MINUTES));
1935
+ }
1907
1936
  function topologicalSort(concepts) {
1908
1937
  const inDegree = /* @__PURE__ */ new Map();
1909
1938
  const adjacency = /* @__PURE__ */ new Map();
@@ -2069,7 +2098,7 @@ Return JSON:
2069
2098
  }
2070
2099
  {
2071
2100
  const STOP = /* @__PURE__ */ new Set(["and", "the", "for", "with", "from", "that", "this", "into", "when", "while", "using", "their", "your", "each", "how", "why", "what", "between", "through", "about"]);
2072
- const tokensFrom = (text) => text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4 && !STOP.has(w));
2101
+ const tokensFrom = (text) => stripDiacritics(text).replace(/[^a-z0-9\s-]/g, " ").split(/\s+/).filter((w) => w.length >= 4 && !STOP.has(w));
2073
2102
  for (const c of concepts) {
2074
2103
  const existing = (c.keywords || []).map((k) => String(k).trim()).filter(Boolean);
2075
2104
  if (existing.length === 0) {
@@ -2237,14 +2266,11 @@ Return JSON:
2237
2266
  const hasHighBloom = (concept.problem_types || []).some(
2238
2267
  (pt) => ["Analyze", "Evaluate", "Create"].includes(pt.bloom_level || "")
2239
2268
  );
2240
- const score = Math.min(1, sioCount * 0.2 + ptCount * 0.15 + prereqCount * 0.15 + (hasHighBloom ? 0.2 : 0));
2241
- const calibrated = Math.round(15 + score * 45);
2242
- const llmEstimate = concept.estimated_minutes || 30;
2243
- if (llmEstimate === 30 || Math.abs(calibrated - llmEstimate) / llmEstimate > 0.5) {
2269
+ const calibrated = calibrateConceptMinutes(sioCount, ptCount, prereqCount, hasHighBloom);
2270
+ const llmEstimate = concept.estimated_minutes || DEFAULT_CONCEPT_MINUTES;
2271
+ if (Math.abs(calibrated - llmEstimate) / llmEstimate > MIN_CALIBRATION_DIVERGENCE) {
2244
2272
  concept.estimated_minutes = calibrated;
2245
- if (llmEstimate !== calibrated) {
2246
- warnings.push("Concept " + concept.id + ": minutes calibrated " + llmEstimate + "m \u2192 " + calibrated + "m (score=" + score.toFixed(2) + ")");
2247
- }
2273
+ warnings.push("Concept " + concept.id + ": minutes calibrated " + llmEstimate + "m \u2192 " + calibrated + "m");
2248
2274
  }
2249
2275
  }
2250
2276
  log("STEP_5c", "Minute calibration complete");
@@ -2269,7 +2295,8 @@ Return JSON:
2269
2295
  return {
2270
2296
  knowledgeGraph: verifiedGraph,
2271
2297
  warnings,
2272
- verificationReport: report
2298
+ verificationReport: report,
2299
+ provenance: client.callLog
2273
2300
  };
2274
2301
  }
2275
2302
 
@@ -2296,9 +2323,10 @@ async function generateHybridGraph(options) {
2296
2323
  description: "",
2297
2324
  total_project_minutes: 0,
2298
2325
  total_concept_minutes: 0,
2299
- estimated_total_minutes: 0
2326
+ linked_prereq_minutes: 0
2300
2327
  },
2301
- warnings
2328
+ warnings,
2329
+ provenance: []
2302
2330
  };
2303
2331
  }
2304
2332
  const client = createLlmClient(llmConfig);
@@ -2379,9 +2407,14 @@ async function generateHybridGraph(options) {
2379
2407
  description: "Progressive completion path: " + phases.length + " phases, " + features.length + " features, " + concepts.length + " concepts",
2380
2408
  total_project_minutes: totalProjectMinutes,
2381
2409
  total_concept_minutes: totalConceptMinutes,
2382
- estimated_total_minutes: totalProjectMinutes + totalPrereqMinutes
2410
+ // HG-1 (2026-10-05): renamed from estimated_total_minutes. It counts only
2411
+ // concepts that HAVE a feature link (max per concept, not summed), so it
2412
+ // is NOT the course total — see HybridGraphSchema.linked_prereq_minutes.
2413
+ // The phase planner below commits to totalProjectMinutes + totalConceptMinutes.
2414
+ linked_prereq_minutes: totalProjectMinutes + totalPrereqMinutes
2383
2415
  },
2384
- warnings
2416
+ warnings,
2417
+ provenance: client.callLog
2385
2418
  };
2386
2419
  }
2387
2420
  function auditPhasePlan(plan, features, concepts, links, warnings, log) {
@@ -2457,12 +2490,20 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
2457
2490
  } else {
2458
2491
  newIntroPhase.set(cid, order);
2459
2492
  }
2493
+ introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
2494
+ }
2495
+ for (const intro of introductions) {
2496
+ const cid = intro.concept_id;
2497
+ if (intro.aspect === "advanced") continue;
2498
+ const introOrder = newIntroPhase.get(cid);
2499
+ if (introOrder === void 0 || introOrder !== order) continue;
2460
2500
  for (const pre of prereqOf.get(cid) || []) {
2461
- if (!newIntroPhase.has(pre)) {
2462
- errors.push("Phase " + id + ": concept " + cid + " introduced before its prerequisite " + pre + " is introduced - violates the prerequisite chain");
2501
+ const previewOrder = previewPhase.get(pre);
2502
+ const covered = newIntroPhase.has(pre) || previewOrder !== void 0 && previewOrder < order;
2503
+ if (!covered) {
2504
+ errors.push("Phase " + id + ": concept " + cid + " introduced before its prerequisite " + pre + " is introduced (or previewed in an earlier phase) - violates the prerequisite chain");
2463
2505
  }
2464
2506
  }
2465
- introductions.push({ concept_id: cid, aspect, note: String(ri.note || "") });
2466
2507
  }
2467
2508
  phases.push({
2468
2509
  id,
@@ -2500,6 +2541,9 @@ function auditPhasePlan(plan, features, concepts, links, warnings, log) {
2500
2541
  if (anchor !== void 0 && anchor <= pOrder) {
2501
2542
  errors.push("Concept " + cid + " previewed in phase " + pOrder + " but fully introduced in phase " + anchor + " - preview must precede the anchor introduction");
2502
2543
  }
2544
+ if (anchor === void 0) {
2545
+ 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");
2546
+ }
2503
2547
  }
2504
2548
  return { phases, errors };
2505
2549
  }