@thanh01.pmt/domain-kit
v0.6.9
Published
Domain graph generation engine — decomposes any domain (software, hardware, math, science...) into structured domain graphs for curriculum planning.
Readme
@thanh01.pmt/domain-kit
Project graph generation engine — decomposes any domain (software, hardware, math, science, language...) into structured domain graphs for curriculum planning.
Table of Contents
- Overview
- End-to-End: From Raw Input to
CurriculumFeed - Installation
- Quick Start
- Architecture
- Input → Processing → Output
- Domain Detection
- Graph Types
- Schemas
- API Reference
- Pipeline Details
- AI Agent Guide
- Examples
- Contributing
Overview
domain-kit is a TypeScript package that analyzes a learning domain and produces a structured domain_graph — the single source of truth consumed by curriculum-kit to generate lessons, activities, assessments, and all pedagogical artifacts.
Key capabilities:
- Auto-detect input type (repo, syllabus, description) and domain category (software, math, science...) with multi-language support (EN, VI, ZH, JA, KO, FR)
- Context-dependent domain detection —
.cppfiles default tosoftware;hardware_iotonly when Arduino/ESP32/sensor context is present - Choose graph type automatically:
project_graph(product-driven) orknowledge_graph(concept-driven) orhybrid_graph - Source scanning of Swift, TypeScript/JavaScript, Python and C/C++ files (regex-based — the package has no parser dependency)
- LLM-powered decomposition of projects into features → steps → keywords
- ULO/CIO/SIO depth classification — each keyword/concept mapped to WHAT (ulo), HOW (cio), or IMPLEMENTATION (sio)
- Concept resolution with ULO/CIO-aware matching against Master Tree concept codes
- Prerequisite cycle detection — DFS cycle detection + Kahn's topological sort for knowledge graphs
- Keyword-per-milestone tracking with new vs prerequisite distinction
- Time-budget-aware assembly — milestones split against a 90-minute session, a 1.15 overhead factor and a 120-minute cap (fixed by the pipeline today;
assembleRoadmap()takes them as parameters)
End-to-End: From Raw Input to CurriculumFeed
domain-kit never writes a lesson, never schedules a session, never picks an activity.
Its entire job is to turn something that already exists — a repository, a syllabus, a
short description — into one JSON object with a fixed shape (CurriculumFeed v2)
that curriculum-kit can schedule without guessing.
Read this section as four layers that always run in this order:
| Layer | Who | Calls the LLM? | When it fails |
|---|---|---|---|
| 1. Detect | detectDomainProfile() | no | never throws — reports confidence / ambiguous |
| 2. Generate | runProjectGraphPipeline / runKnowledgeGraphPipeline / runHybridGraphPipeline | yes | throw (fail-closed) |
| 3. Emit | emitCurriculumFeed() | no | throw (fail-closed) |
| 4. Validate | validateCurriculumFeed() | no | throw (fail-closed) |
The rule that makes the package trustworthy: layer 2 and 3 never hand back a half-built graph. A missing LLM key, an empty phase plan, a concept with no keywords, a backwards prerequisite edge — all of them stop the run at the layer that produced them, instead of surfacing as a broken lesson three packages later.
Shape is fail-closed; content is reported. Structural violations throw at emission. Things that make a graph pedagogically suspect — a feature with no steps, a concept no phase introduces — are diagnostics (
source.diagnostics, each with acodeand aseverity) rather than throws, so the host decides whether to block. See ADR-0002.
The whole picture
+------------------------------------------------------------------------------+
| INPUT |
| repoDir / repoUrl - syllabusText / parsedSyllabus - description - goal |
| techStack - chapters - fileExtensions - gradeBand |
| forcedLearningMode (router override) - knowledgeTreePath - proposalsPath |
+------------------------------------+-----------------------------------------+
v
+------------------------------------------------------------------------------+
| LAYER 1 - detectDomainProfile() - pure, deterministic, no LLM |
| detectInputType() -> detectDomain() -> detectLearningMode() |
| => DomainProfile { inputType, domainCategory, learningMode, graphType, |
| confidence, evidence, ambiguous? } |
+------------------------------------+-----------------------------------------+
| effectiveMode = forcedLearningMode
| ?? domainProfile.learningMode
| (graphType is only its echo)
+---------------------+---------------------+
v v v
'product' 'concept' 'hybrid'
| | |
| | repoDir present? --no--> FALLBACK to the
| | | concept branch,
| | | loud if auto (R1)
v v v
runProjectGraph runKnowledgeGraph runProjectGraphPipeline + runKnowledgeGraphPipeline
Pipeline Pipeline (two full graph runs), then:
STEP_1 scan/ (parseSyllabus generateHybridGraph()
select/regex first if raw STEP_1 feature-concept links
STEP_2 keyword syllabusText) STEP_2 decomposePhases
extract generateKnowledge (auditPhasePlan fail-closed)
STEP_3 SDK API Graph: STEP_3 totals
index STEP_1 decompose
STEP_4 LLM STEP_2 validate & clean
C0/C1/C2 STEP_2_5 standardize vs
STEP_5 verify mlo-knowlege-tree.tsv <== knowledgeTreePath enters
vs source here, fail-closed (R5, R6)
STEP_6 escalate STEP_3 detect + break cycles (R4)
keywords STEP_4 topological sort
STEP_7 resolve to STEP_5 ULO/CIO/SIO authoring
Master Tree STEP_5b SIO keyword enrichment
concepts STEP_5c minute calibration
STEP_8 assemble STEP_6 verify + coverage audit
roadmap (feed is
emitted by the
ROUTER, not this
pipeline - R3)
| | |
+---------------------+---------------------+
v
+------------------------------------------------------------------------------+
| LAYER 3 - emitCurriculumFeed(graph) - pure projection, no LLM |
| Options built in ONE shared helper (pipeline/emitFeed.ts, R3 fixed): |
| router (project), knowledge orchestrator, hybrid orchestrator. |
| learning_nodes[] - dependency_edges[] - suggested_groupings[] - phases[] |
| (phases[] is filled ONLY by the hybrid emitter - R1) |
+------------------------------------+-----------------------------------------+
v
+------------------------------------------------------------------------------+
| LAYER 4 - validateCurriculumFeed(feed) - structural + teaching order |
| Entry dispatches by graph shape; anything that already looks like a |
| feed is pass-through validated (R7) |
+------------------------------------------------------------------------------+
v
CurriculumFeed (schema_version: 2)
||
|| ====== package boundary ======
|| nothing below this line is
|| domain-kit's business
v
curriculum-kit -> units -> sessions -> lessons -> activitiesRisk tags (R1-R9) are defined in the Risk map below.
Two facts about that diagram are easy to miss and worth stating up front:
Only the hybrid path fills
phases[].emitFromProjectandemitFromKnowledgeboth returnphases: []. "The order in the file is the order in the classroom" is therefore a guarantee that only hybrid consumers can rely on — a project-only or knowledge-only feed has no phase authority at all.runProjectGraphPipelinestops at STEP_8. Feed emission for the product branch is done byrunDomainPipeline()(static import of the emitter - the old dynamicimport()was removed as pure inconsistency), not by the project pipeline itself. The knowledge and hybrid branches emit from their own thin orchestrators insrc/pipeline/.Hybrid degrades loudly; explicit requests fail closed. An AUTO-detected
hybridwithout arepoDirruns the concept branch instead and returns a structuredmodeDowngrade: { from, to, reason }on the result (R1). A FORCEDforcedLearningMode: 'hybrid'withoutrepoDirthrows. Both hybrid knowledge legs passknowledgeTreePath/proposalsPaththrough, so STEP_2_5 tree standardization runs in every mode (R2). Both fixed 2026-10-07.
Risk map (2026-10-07 - re-verified against code)
Written while re-drawing the diagram above; every row cites the code it was
verified against. Full postmortems: deep review 2026-10-05 (app repo,
docs/analysis/2026-10-05-graph-curriculum-deep-review.md) and
ADR-0002.
| # | Risk | Where | Severity |
|---|---|---|---|
| R1 | FIXED 2026-10-07. Was: hybrid without repoDir silently degraded to a knowledge-graph feed (only a warnings[] entry). Now a FORCED hybrid throws (fail-closed); an auto-detected downgrade returns a structured modeDowngrade { from, to, reason }. | src/pipeline/domainRouter.ts | High |
| R2 | FIXED 2026-10-07. Was: the hybrid branch's knowledge legs dropped knowledgeTreePath/proposalsPath, so STEP_2_5 ran in concept mode but not hybrid mode for the same subject. Now both hybrid legs pass them through. | src/pipeline/domainRouter.ts | High |
| R3 | FIXED 2026-10-07. Was: feed emission invoked from three places with drifting options (hallucinationCount only from the router). Now ONE shared helper emitNormalizedFeed() in src/pipeline/emitFeed.ts builds options for all three call sites (lives in pipeline/ so feed/ keeps its no-llmClient invariant). | src/pipeline/emitFeed.ts | Medium |
| R4 | Cycle breaking removes the DFS back-edge positionally (mechanism verified 2026-10-07: NO "weakest edge"/link-counting weighting — that was the old misleading log text, now fixed) and only warns. Which edge is dropped decides teaching order. | src/graph/knowledgeGraphVerifier.ts:69 | Medium |
| R5 | FIXED 2026-10-07. Was: STEP_2_5 only ran when knowledgeTreePath was provided and the absence of tree anchoring was silent. Now the pipeline pushes an explicit warning (all concepts then stay proposed_new). | src/graph/knowledgeGraphPipeline.ts | Medium |
| R6 | FIXED 2026-10-07. Was: tree prereq codes outside THIS analysis were dropped silently. Now one aggregated warning lists unmapped tree prereq codes (deduped, display capped at 10). | src/graph/knowledgeGraphPipeline.ts | Medium |
| R7 | emitCurriculumFeed dispatches by shape (key presence) and pass-through-validates anything that "looks like a feed" (CF-8 in the deep review). | src/feed/curriculumFeedEmitter.ts:833-839 | Low |
| R8 | Non-fatal pedagogy issues funnel into a single lossy warnings[] string channel; some drops only hit console.warn (e.g. a concept falling out of learning_path, KG-3). Structured diagnostics per ADR-0002 should replace string warnings. | deep review KG-3 | Medium |
| R9 | FIXED 2026-10-07. Was: ASCII-only regex assumptions broke on Vietnamese text (KG-1) - empty derived keywords threw, registries filled with fragments. Now a shared stripDiacritics() (NFD + combining-mark strip + đ→d, incl. the precomposed-base-letter case NFD cannot decompose) backs every tokenizer; regression tests in src/__tests__/vietnamese-tokenization.test.ts. | src/utils/diacritics.ts | Critical |
Layer 1 - Detect
detectDomainProfile() runs three detectors in sequence and never calls an LLM:
- Input type —
repository | syllabus | description | files | mixed - Domain category — multi-signal scoring over 340+ keywords in 10 categories,
with Vietnamese / English / Chinese / Japanese / French keyword lists and
context-dependent extension mapping (
.cppis ambiguous: software by default,hardware_iotonly with Arduino/ESP32/sensor context). - Learning mode —
product | concept | hybrid, the signal that actually picks the branch in layer 2.
graphType on the profile is a convenience mirror of learningMode
(product→project_graph, concept→knowledge_graph, hybrid→hybrid_graph); when the
two would disagree, forcedLearningMode overrides the mode and graphType is
recomputed at the branch.
Layer 2 - Generate (one branch by learning mode)
Branch A — project_graph (product-driven)
STEP_1 scan the file TREE, names only (no content)
└─▶ LLM picks the relevant files ─▶ read content for those files only
└─▶ parse .swift / .ts .tsx / .js .jsx / .py / .ino .cpp .c .h .hpp
└─▶ merge results by PRIMARY language
STEP_2 extractKeywords() → weighted keywords (app / esp32 tags)
STEP_3 SDK API index, derived from STEP_2 keywords
STEP_4_C0 scaffold → feature F0 "FOUNDATION & SETUP" (LLM failure ⇒ F0 is skipped
silently and the pipeline continues without it)
STEP_4_C1 overview → product{ goals, users, journeys, development_stages } + features
STEP_4_C2 steps → per-feature steps, one batched LLM call, falling back to
per-feature; a feature that still fails is logged and left
step-less (no throw)
STEP_5 verifyProjectGraph() → strip files / APIs / keywords not found in code;
F0 keywords are exempt (pedagogical terms)
STEP_6 escalateAndMapConcepts() → keyword → technology-neutral concept +
depth (ulo | cio | sio) + evidence files.
Depth comes from the LLM; on LLM failure a
regex fallback reads the usage context
(import → sio, code body → sio, comment → ulo)
STEP_7 resolveConcepts() → concept → Master-Tree code, scored ULO/CIO-aware:
sio keyword → concept.keywords
cio keyword → concept.description + concept.cio
ulo keyword → concept.ulo + concept.description
STEP_8 assembleRoadmap() → milestones grouped into phases, each milestone tagged
with all / new / prerequisite keywords + a time budget
───────────────────────── package boundary ───────────────────────────────
emitCurriculumFeed(projectGraph) → CurriculumFeed (phases: [])Branch B — knowledge_graph (concept-driven)
STEP_1 parseSyllabus(syllabusText) → structured units/topics
LLM decomposes the subject → concepts + prerequisites + problem_types
+ categories + techniques. The syllabus is truncated at
KG_MAX_SYLLABUS_CHARS (default 32000) with a warning.
STEP_2 validate & clean
· description shorter than 50 chars → warning
· KEYWORD REGISTRY IS FAIL-CLOSED: a concept with no keywords gets them
derived from its name/description; if nothing is derivable → throw
· a prerequisite pointing at a non-existent concept → edge removed + warning
STEP_2_5 only when knowledgeTreePath is supplied: standardize every concept against
mlo-knowlege-tree.tsv → `standardized` (adopts the tree code + the keywords
the standardizer chose for this tech stack) or `proposed_new`.
A concept with no decision → throw. Tree prerequisite codes then seed
internal edges where both ends are in this analysis.
STEP_3 detectPrerequisiteCycles() → breakPrerequisiteCycles() at the weakest edge
STEP_4 topologicalSort() (Kahn) → learning_path is REBUILT from the sorted order
STEP_5 hierarchical ULO/CIO/SIO authoring, then deterministic ULO/CIO/SIO codes
(a tree-standardized code from STEP_2_5 always wins over a generated one)
STEP_5b mine the SIO text for technology identifiers — backticked tokens,
@Annotations, a.b() calls, .modifiers, camelCase — and merge them into the
keyword registry (capped at 8 per concept)
STEP_5c minute calibration:
score = 0.2·sio + 0.15·problem_types + 0.15·prerequisites
+ 0.2·(has Analyze/Evaluate/Create)
minutes = 15 + 45·score ⇒ 15 … 60 minutes
The calibrated value overrides the LLM estimate only when the LLM returned
its default 30, or when the two diverge by more than 50%.
STEP_6 verifyKnowledgeGraph() → referential integrity + REVERSE COVERAGE audit:
every syllabus topic must be covered by at least one concept.
Findings are reported as warnings, not thrown.
───────────────────────── package boundary ───────────────────────────────
emitCurriculumFeed(knowledgeGraph) → CurriculumFeed (phases: [])Branch C — hybrid_graph (both, and how they interlock)
Needs a project_graph and a knowledge_graph — it is the only branch that runs two
generators before it starts.
input: project_graph + knowledge_graph
STEP_1 LLM emits feature ↔ concept links
{ feature_id, concept_id, relationship: requires|demonstrates|applies|extends,
depth: ulo|cio|sio, estimated_prereq_minutes }
A link naming an unknown feature or concept is DROPPED with a warning.
STEP_2 decomposePhases() — the LLM turns the build into PROGRESSIVE COMPLETION
PHASES: each phase states what the product can demonstrably do once it is
done, owns every feature built in it, and declares which concepts it teaches
at its start (aspect = new | advanced | preview).
auditPhasePlan() then checks the contract deterministically:
· every feature in EXACTLY one phase, none skipped
· every phase has a non-empty product_completion
· a concept is introduced no later than the phase that consumes it
· no concept before its own prerequisites — checked per PHASE, not per array
position: teaching a concept and its prerequisite in the SAME phase is valid
whichever order the LLM emitted them, and flagging it would burn all three
repair attempts and then throw away a good plan
· a preview is satisfied by an earlier anchor or an earlier preview, and every
preview has an anchor teach somewhere
· preview must precede its anchor and precede nothing already introduced
Violations are fed back verbatim; at most 3 attempts, then throw. A phase
model that cannot be trusted is worse than no output.
STEP_3 totals
total_project_minutes = Σ step.effort.estimated_minutes (fallback 15)
total_concept_minutes = Σ concept.estimated_minutes (fallback 30)
linked_prereq_minutes = total_project_minutes + Σ max-per-concept prereq
minutes, counting only concepts that HAVE a feature
link. It is NOT the course total.
───────────────────────── package boundary ───────────────────────────────
emitCurriculumFeed(hybridGraph) → CurriculumFeed — the only path that
fills phases[], and therefore the only path that defines teaching order.Layer 3 - Emit (one shape for every graph)
emitCurriculumFeed(graph, opts?)
├─ graph already has learning_nodes[] → validate and pass through unchanged
├─ has project_graph | knowledge_graph | links[] | phases[]
│ → emitFromHybrid()
├─ has concepts[] or type==='knowledge_graph'
│ → emitFromKnowledge()
└─ otherwise → emitFromProject()emitFromHybrid — phase-driven emission. For each phase, in order, it emits
new concepts first, then advanced revisits, then previews, and only then the
phase's features and their steps (project-graph order):
| Aspect | Node id | Minutes | Carries |
|---|---|---|---|
| new | <cid> | the concept's own estimate | keywords.new — the legitimate vocabulary introducer |
| advanced | <cid>__ADV<phase> | max(15, 50% of anchor) | a sio → cio downgrade candidate saving 30% of the revisit's own minutes |
| preview | <cid>__PREV<phase> | max(5, 25% of anchor) | keywords.all — may introduce the vocabulary early, with no prerequisite edges and no scaffolding |
A preview in an EARLIER phase satisfies a downstream concept's prerequisite — that is
what it is for. A preview in the same phase does not, because within a phase the order
is new → advanced → preview, so it would arrive after the dependent concept.
Knowledge edges
Edges are materialized as concept → first step of the consumer feature (kind: 'knowledge');
feature build order and depends_on become kind: 'task'. When a prerequisite is satisfied
only by a preview, the edge points at the preview node — the vocabulary genuinely comes
from there.
Keyword identity is case- and Unicode-insensitive: SwiftUI / swiftui and an NFD/NFC
Vietnamese pair are the same keyword. The ledger and the edge materializer share one
comparison key, so they cannot disagree about what counts as a first appearance.
Edges are materialized as concept → first step of the consumer feature (kind: 'knowledge');
feature build order and depends_on become kind: 'task'.
Where the graph contradicts itself, the emitter reports instead of inventing:
- a link whose anchor teach sits in a LATER phase than its consumer →
hybrid_link_drift, no edge; - a product step that would become the introducer of a concept's vocabulary →
concept_overtaken_by_step, no edge; - a keyword introducer that sits in a later phase than its consumer →
keyword_order_drift, no edge.
That last class of edge is what once produced the 2026-09-17 "Teaching-order violation" incident: the contradiction used to escape emission and explode inside the planner.
Coverage holes that used to be silent
| Diagnostic | Severity | Meaning |
|---|---|---|
| orphan_concept | error | the knowledge graph holds a concept that no phase introduces and no link consumes — it will not be taught |
| feature_without_steps | warning | the project graph declares a feature whose steps never materialised (STEP_4_C2 failed for it) — it will not appear in the feed |
| step_without_id | warning | a step with no id cannot become a node and was dropped (reported as feature#index) |
| external_prerequisite | info | a concept depends on knowledge outside this analysis — recorded on the node as external_prerequisites, not an error |
| upstream_warning | warning | a warning string handed in by the graph pipeline, kept for backward compatibility |
Both of the first two used to vanish with an empty warnings: []. The host can gate on
code/severity instead of substring-matching free text.
Entry requirements
A concept can depend on knowledge the analysis does not cover — a course on vector calculus legitimately requires linear algebra. Those ids are not nodes here, so they cannot be edges; dropping them silently would throw away exactly what someone deciding whether to enrol needs most.
They land on the node instead:
{ "id": "C7", "kind": "concept", "external_prerequisites": ["C_LINALG"] }and surface once as an external_prerequisite info diagnostic. Info, not warning —
a course with entry requirements is normal, and a host that gates on severity should not
block on it. The three emission paths used to disagree here: the knowledge path dropped
them silently while both hybrid paths hard-failed (one built a dangling edge, the other
threw an ordering error blaming a concept that was not in the analysis), so any graph
referencing prior knowledge was un-emittable.
Provenance
source.provenance records which providers actually served the run:
provenance: {
providers: ['openrouter', 'nvidia'], // failover is visible, not silent
models: ['preset-x', 'nemotron-3-ultra'],
calls: 7,
failed_calls: 1, // openrouter fell through
degraded: true,
}Without it, a graph produced half by a paid preset and half by a free tier is indistinguishable from a clean one — and not reproducible or auditable. The client is created once per pipeline run and threaded through every step, so all calls land in one log; it is per-instance, so concurrent runs never inherit each other's history.
Layer 4 - Validate (fail-closed, at the source)
validateCurriculumFeed() runs on every emission path and rejects:
- duplicate node ids;
- an edge pointing at an unknown node, or a self-loop;
- a grouping or phase referencing an unknown node;
- duplicate phase ids;
- duplicate or non-ascending phase
order—orderis the teaching sequence once phases exist, so two phases claiming one slot makes it undefined; - an empty feed (
learning_nodeshas a minimum of 1); - the teaching-order invariant — when
phases[]is non-empty, a prerequisite must live in the same phase or an earlier one than its consumer.
emitFromProject adds one more: a graph with features[] but no features[].steps is
rejected by name as a legacy knowledge-tree graph, with the regeneration command in the
message.
The three graph types at a glance
| | project_graph | knowledge_graph | hybrid_graph |
|---|---|---|---|
| Answers | "what is being built?" | "what must be understood?" | "both, and how they interlock" |
| LLM sees | file tree + selected file contents | syllabus / description | both graphs, rendered as text |
| Atomic node | features[].steps[] | concepts[] | both |
| Ordering authority | array order of features/steps | learning_path (topological) | phases[] → feed.phases |
| Time source | step.effort.estimated_minutes | concept.estimated_minutes (calibrated 15–60) | sum of both |
| Prerequisite evidence | none — build order only | concept.prerequisites[], proven acyclic | both, cross-checked against concept prerequisites |
| feed.phases | always [] | always [] | filled ⇒ teaching order |
| Entry point | runProjectGraphPipeline() | runKnowledgeGraphPipeline() | runHybridGraphPipeline() |
Depth and minute conventions
Exported from code as DEPTH_MINUTE_FRACTIONS and calibrateConceptMinutes() — read
those rather than copying numbers out of this table.
| Convention | Value | Source |
|---|---|---|
| preview node minutes | max(5, 25%) of the anchor | DEPTH_MINUTE_FRACTIONS |
| ULO / CIO / SIO minutes for a concept node | max(5, 35%) / max(ulo+5, 65%) / max(cio+5, 100%) | DEPTH_MINUTE_FRACTIONS |
| advanced revisit minutes | max(15, 50%) of the anchor | DEPTH_MINUTE_FRACTIONS |
| advanced revisit downgrade candidate | sio → cio, saves 30% of the revisit's own minutes | DEPTH_MINUTE_FRACTIONS |
| step with no estimated_minutes | 15 minutes (same constant the hybrid totals use) | DEFAULT_STEP_MINUTES |
| concept minutes after calibration | 15–60 minutes | calibrateConceptMinutes() |
| roadmap session / overhead / milestone split cap | 90 min / ×1.15 / 120 min — hardcoded by the pipeline, not configurable through it | STEP_8 in pipeline/projectGraphPipeline.ts |
| phase minute balance demanded of the LLM | 120–240 min per phase; rebalance below 60 or above 400 | STEP_2 in graph/hybridGraphPipeline.ts |
| file budget per run | 70 files / 500 000 chars | utils/fileUtils.ts |
The three depth levels are a single ladder applied everywhere:
ulo= WHAT + WHY (technology-agnostic),cio= HOW the mechanism works (still no concrete API),sio= IMPLEMENTATION (must name a real API, function, module or file). A keyword can only ever move down this ladder, never across it.The percentages above live in one exported table,
DEPTH_MINUTE_FRACTIONS. They were previously hardcoded in three places (25% / 35% / 30%), which meant "what does a ULO pass cost?" had three answers. Read that table rather than hardcoding a fourth.
Unicode: Vietnamese input reaches the detector in NFC or NFD form. Text is normalized at the boundary and also matched with diacritics stripped, so the same sentence classifies identically whichever form it arrives in — before this, one sentence measured as
math(confidence 1.0) in NFC andother(confidence 0.2) in NFD.
Installation
npm install @thanh01.pmt/domain-kitOr with workspace link:
npm link @thanh01.pmt/domain-kitPeer Dependencies
{
"zod": "^3.23.8"
}Environment Variables (for LLM features)
LLM_API_KEY=sk-... # OpenAI-compatible API key
LLM_BASE_URL=https://api.openai.com/v1 # API base URL
LLM_MODEL=gpt-4o-mini # Model to use
LLM_MAX_TOKENS=65536 # Max output tokens (default)
LLM_MIN_REQUEST_INTERVAL_MS=2500 # process-wide pacing; 0 disables
# Extra knobs the pipelines read directly:
KG_MAX_SYLLABUS_CHARS=32000 # syllabus truncation before decomposition
KG_MAX_TOKENS=65536 # knowledge-graph generation budget
LLM_REQUEST_TIMEOUT_MS=300000 # per-request capProvider chain. createLlmClient() builds an ordered chain and fails over through
it on transient errors (408/429/500/502/503/504):
- explicit
llmConfig.apiKey LLM_*(orOPENAI_*) — generic OpenAI-compatibleDASHSCOPE_*/ALIBABA_*NVIDIA_API_KEY→ NVIDIA NIMOPENROUTER_API_KEY→ OpenRouter
If OPENROUTER_MODEL is set, OpenRouter is moved to the front. Requests are paced
process-wide so free tiers are not rate-limited by a burst; the interval is configurable
via LLM_MIN_REQUEST_INTERVAL_MS or llmConfig.minRequestIntervalMs (set 0 when the
caller owns the rate limit — several concurrent pipelines in one process, or serverless).
Each client keeps a callLog of every provider attempt it made. One client is created
per pipeline run and threaded through every step, so PipelineResult.provenance and
CurriculumFeed.source.provenance reflect that whole run — and because the log is
per-instance, concurrent runs never inherit each other's history.
Quick Start
1. Detect Domain Profile
import { detectDomainProfile } from '@thanh01.pmt/domain-kit';
const profile = detectDomainProfile({
repoUrl: 'https://github.com/user/talky',
goal: 'Build iOS chat app with SwiftUI and Firebase',
techStack: 'Swift,SwiftUI,Firebase',
});
console.log(profile.graphType); // 'project_graph'
console.log(profile.domainCategory); // 'software'
console.log(profile.learningMode); // 'product'2. Generate Project Graph (Software)
import { runProjectGraphPipeline } from '@thanh01.pmt/domain-kit';
const result = await runProjectGraphPipeline({
repoDir: '/path/to/repo',
goal: 'Build iOS chat app with SwiftUI',
techStack: 'Swift,SwiftUI,Firebase',
});
console.log(result.projectGraph); // ProjectGraph schema
console.log(result.roadmap); // AssembledRoadmap with phases
console.log(result.keywords); // Extracted keywords3. Generate Knowledge Graph (Math/Science)
import { generateKnowledgeGraph } from '@thanh01.pmt/domain-kit';
const result = await generateKnowledgeGraph({
subject: 'Hình học K12',
syllabusText: 'Chương 1: Phân loại góc...\nChương 2: Tam giác...',
gradeBand: [6, 9],
domain: 'math',
});
console.log(result.knowledgeGraph); // KnowledgeGraph schema4. Generate Hybrid Graph
import { generateHybridGraph } from '@thanh01.pmt/domain-kit';
const result = await generateHybridGraph({
projectGraph: existingProjectGraph,
knowledgeGraph: existingKnowledgeGraph,
});
console.log(result.hybridGraph); // HybridGraph with links[] + phases[]5. Emit Curriculum Feed (from any graph)
import { emitCurriculumFeed } from '@thanh01.pmt/domain-kit';
// `graph` is whichever graph object you just produced — note that step 4 above
// rebinds `result` to the HYBRID result, so name the graph explicitly:
const feed = emitCurriculumFeed(projectGraph); // project_graph
// const feed = emitCurriculumFeed(knowledgeGraph); // knowledge_graph
// const feed = emitCurriculumFeed(hybridGraph); // hybrid_graph (phases filled)
console.log(feed.learning_nodes.length); // unified nodes ready for planner
console.log(feed.dependency_edges.length); // knowledge + task edges
console.log(feed.suggested_groupings); // feature/category groupings
console.log(feed.phases.length); // non-empty ⇒ learning_nodes are in teaching order (phases define units)
// Each node has: bloom_hint, depth_hint, keywords.new/.prerequisite, introduce_aspect, user_visible_deliverableArchitecture
| Directory | Files | Owns |
|---|---|---|
| schemas/ | projectGraphSchema, knowledgeGraphSchema, hybridGraphSchema, domainProfileSchema, curriculumFeedSchema, domainExtensions | every Zod schema; validateCurriculumFeed() lives in curriculumFeedSchema |
| detector/ | inputTypeDetector, domainDetector, learningModeDetector, domainProfileDetector | layer 1 — pure, no LLM |
| parsers/ | swift, typescript, python, cpp, syllabusParser | source + syllabus readers |
| extractors/ | keywordExtractor, fileSelector, fileRelevanceScorer | keyword weights, LLM file relevance |
| graph/ | scaffoldExtractor, overviewExtractor, stepExtractor, graphVerifier, conceptEscalator, hierarchicalLoAuthoring, loCodeStandard, loExemplars, depthAudit, knowledgeTreeCatalog, conceptStandardizer, knowledgeGraphPipeline, knowledgeGraphVerifier, hybridGraphPipeline | layer 2 — the three graph generators |
| concepts/ | conceptResolver | keyword → Master-Tree code, ULO/CIO-aware |
| assembly/ | roadmapAssembler | milestones, phases, keyword ledger, time budget |
| pipeline/ | domainRouter, projectGraphPipeline, knowledgeGraphPipeline, hybridGraphPipeline | the three orchestrators (thin) + the router; only projectGraphPipeline holds the 8 real steps |
| feed/ | curriculumFeedEmitter | layer 3 — projects any graph onto CurriculumFeed v2 |
| utils/ | llmClient, fileUtils | provider chain + pacing, file budget |
graph/andpipeline/both contain files namedknowledgeGraphPipeline.tsandhybridGraphPipeline.ts. They are not duplicates: thegraph/ones do the LLM work, thepipeline/ones parse the syllabus, call thegraph/one, and emit the feed.
Input → Processing → Output
Input
User provides one or more of:
| Field | Type | Description |
|---|---|---|
| repoUrl | string | Git repository URL |
| repoDir | string | Local repository path |
| goal | string | Learning goal or project description |
| description | string | Subject/project description |
| syllabusText | string | Syllabus or curriculum text |
| chapters | string[] | Chapter/section names |
| techStack | string | Comma-separated technologies |
| fileExtensions | string[] | File extensions in repo |
| gradeBand | [number, number] | Target grade range |
Processing
Input
│
▼
Step 1: DETECT
├── detectInputType() → repository | syllabus | description | files | mixed
├── detectDomain() → software | hardware_iot | math | science | ...
│ └── context-dependent: .cpp→software (default), hardware only with ESP32/Arduino context
├── detectLearningMode() → product | concept | hybrid
└── → DomainProfile (with ambiguity flag if domains overlap)
│
Step 2: ROUTE (by graphType)
│
├── project_graph → parsers → extractors → graph/LLM → verify → concepts → assembly
├── knowledge_graph → LLM decompose → ULO/CIO/SIO → cycle detect → topo sort → problem_types
└── hybrid_graph → project_graph + knowledge_graph + LLM link features↔concepts
│
Step 3: OUTPUT
├── the graph object (project_graph | knowledge_graph | hybrid_graph)
└── CurriculumFeed v2 (learning_nodes / dependency_edges / groupings / phases)
↑ always produced by emitCurriculumFeed(); hybrid is the only branch that fills phases[]
Note: domain-kit returns objects in memory — it never writes *.json itself.
Serialising them is the host's job.Output
Always one of three schemas:
| Graph Type | Schema | When |
|---|---|---|
| project_graph | ProjectGraph | Product-driven (software, hardware, 3D design) |
| knowledge_graph | KnowledgeGraph | Concept-driven (math, science, language) |
| hybrid_graph | HybridGraph | Both product + concept linked |
Domain Detection
How it works
detectDomainProfile() runs three detectors in sequence:
- Input Type Detector — Checks for repo URLs, syllabus text, file extensions, descriptions
- Domain Detector — Multi-signal scoring with 340+ keywords across 10 domains, multi-language support (EN, VI, ZH, JA, KO, FR), and context-dependent extension mapping (e.g.,
.cpp→ software by default, hardware only when Arduino/ESP32/sensor context present) - Learning Mode Detector — Combines input signals + domain heuristics + verb patterns
Ambiguous Domain Handling
Some file extensions map to multiple domains (e.g., .cpp → software or hardware). The detector:
- Default-maps ambiguous extensions to
software(most common use) - Checks context keywords (e.g.,
arduino,ESP32,GPIO,sensor) to upgrade tohardware_iot - Sets
ambiguousfield in output when detection is borderline - Agent should ask user to confirm when
ambiguousis set orconfidence.domainCategory < 0.6
Domain Categories
| Category | Examples | Keywords (sample) |
|---|---|---|
| software | App, web, API | swift, react, typescript, docker |
| hardware_iot | Arduino, robotics | arduino, sensor, ESP32, GPIO |
| 3d_design | Tinkercad, Fusion360 | shape, dimension, STL, CAD |
| math | Hình học, đại số | tam giác, phương trình, calculus |
| science | Lý, hóa, sinh | thí nghiệm, năng lượng, cell |
| language | Tiếng Anh, writing | grammar, vocabulary, essay |
| arts | Âm nhạc, thiết kế | piano, color theory, typography |
| research | Nghiên cứu, thống kê | methodology, hypothesis, regression |
| business | Kinh doanh, tài chính | revenue, agile, kế hoạch kinh doanh |
| other | Fallback | — |
Confidence & Fallback
const profile = detectDomainProfile({ ... });
// Check for ambiguous detection
if (profile.ambiguous) {
// E.g., { domain1: 'software', domain2: 'hardware_iot', reason: 'C++ files + ESP32 context' }
// → Ask user to confirm which domain
}
if (profile.confidence.domainCategory < 0.6) {
// Low confidence — ask user to confirm
}
if (profile.domainCategory === 'other') {
// Unknown domain — ask user to specify
}Graph Types
Project Graph (Product-Driven)
For projects where the learner builds something: apps, robots, 3D models.
{
"project": { "id": "...", "tech_stack": {...} },
"product": { "goals": [...], "development_stages": [...] },
"features": [
{
"id": "F1",
"name": "Authentication",
"steps": [
{
"id": "F1-S1",
"name": "Implement AuthViewModel",
"keywords": ["@Published", "@EnvironmentObject"],
"api_usage": ["FirebaseAuth"],
"effort": { "estimated_minutes": 30 }
}
]
}
]
}Knowledge Graph (Concept-Driven)
For subjects where the learner understands concepts: math, science, language.
{
"subject": { "id": "geometry-k12", "domain": "math" },
"concepts": [
{
"id": "G1",
"name": "Angle Classification",
"ulo": "WHAT: Angles are... WHY: We classify to compare...",
"cio": "HOW: Measured in degrees, compared using protractor...",
"sio": ["protractor usage", "angle notation"],
"prerequisites": [],
"problem_types": [{ "bloom_level": "Remember", "difficulty": "easy" }]
}
],
"learning_path": [
{ "concept_id": "G1", "order": 1 },
{ "concept_id": "G2", "order": 2, "depends_on": ["G1"] }
]
}Hybrid Graph
For projects that require both building and understanding.
{
"project_graph": { ... },
"knowledge_graph": { ... },
"links": [
{
"feature_id": "F2",
"concept_id": "G3",
"relationship": "requires",
"depth": "cio",
"estimated_prereq_minutes": 30,
"phase_id": "PH2"
}
],
// phases[] is the teaching order — and the only source of CurriculumFeed.phases
"phases": [
{
"id": "PH1",
"order": 1,
"name": "Runnable slice",
"product_completion": "App launches and shows an empty conversation list",
"feature_ids": ["F0", "F1"],
"introduces": [
{ "concept_id": "G1", "aspect": "new", "note": "..." },
{ "concept_id": "G3", "aspect": "preview", "note": "vocabulary only" }
]
}
],
"total_project_minutes": 480,
"total_concept_minutes": 210,
"linked_prereq_minutes": 540
}Schemas
All schemas are Zod-validated TypeScript types.
| Schema | File | Purpose |
|---|---|---|
| ProjectGraphSchema | projectGraphSchema.ts | Software/hardware/3D project structure |
| KnowledgeGraphSchema | knowledgeGraphSchema.ts | Concept-driven subject structure |
| HybridGraphSchema | hybridGraphSchema.ts | Combined project + knowledge |
| DomainProfileSchema | domainProfileSchema.ts | Domain detection output |
| HardwareFeatureExtensionSchema | domainExtensions.ts | Hardware overlay (wiring, materials) |
| ThreeDesignFeatureExtensionSchema | domainExtensions.ts | 3D Design overlay (shapes, dimensions) |
| MathConceptExtensionSchema | domainExtensions.ts | Math overlay (theorems) |
| ScienceConceptExtensionSchema | domainExtensions.ts | Science overlay (experiment procedures) |
| LanguageConceptExtensionSchema | domainExtensions.ts | Language overlay (grammar rules) |
| ConceptSchema | knowledgeGraphSchema.ts | One knowledge concept |
| LearningPathStepSchema | knowledgeGraphSchema.ts | One topological-order step |
| CurriculumFeedSchema | curriculumFeedSchema.ts | Planner-ready normalized feed (nodes, edges, groupings, phases) |
| FeedNodeSchema | curriculumFeedSchema.ts | Individual learning node (concept, skill, or product step) |
| FeedEdgeSchema | curriculumFeedSchema.ts | Dependency edge (knowledge or task) |
| FeedGroupingSchema | curriculumFeedSchema.ts | Suggested grouping of nodes (feature or category) |
| FeedPhaseSchema | curriculumFeedSchema.ts | Development phase — when non-empty, defines teaching sequence and unit structure |
| FeedDiagnosticSchema | curriculumFeedSchema.ts | Typed finding (code + severity + node ids) |
| FeedProvenanceSchema | curriculumFeedSchema.ts | Which LLM providers served the run |
Key Types
// Project Graph
type ProjectGraph = {
schema_version: 3;
project: ProjectInfo;
product: Product;
features: Feature[];
capabilities: Capability[];
implementation: { tasks: Task[] };
missing_gaps: MissingGap[];
tech_debt: TechDebt[];
}
type Feature = {
id: string;
name: string;
steps: Step[];
depends_on: string[];
}
type Step = {
id: string;
name: string;
keywords: string[];
api_usage: string[];
completion_level: 'base' | 'mvp' | 'extend' | 'polish';
outcome?: { user_visible: string; technical: string }; // → FeedNode.user_visible_deliverable
// all effort fields are OPTIONAL — downstream totals fall back to 15 minutes
effort?: { estimated_minutes?: number; complexity?: 'low' | 'medium' | 'high';
concepts_count?: number; files_touched?: number };
}
// Knowledge Graph
type KnowledgeGraph = {
schema_version: 1;
type: 'knowledge_graph';
subject: SubjectInfo;
concepts: Concept[];
categories: ConceptCategory[];
learning_path: LearningPathStep[];
}
type Concept = {
id: string;
name: string;
description: string;
keywords: string[]; // SIO-level tech terms — drives the keyword ledger, STEP_5b enrichment
// and the SIO-binding audit; NEVER silently empty (fail-closed)
ulo: string; // WHAT + WHY
cio: string; // HOW
sio: string[]; // Specific implementations
prerequisites: string[];
estimated_minutes: number; // calibrated 15–60 (STEP_5c)
problem_types: ProblemType[];
techniques: string[];
common_mistakes: string[];
materials: string[]; // physical lab equipment
visual_aids: string[]; // diagrams / images / animations
// codes — a knowledge-tree code from STEP_2_5 always wins over a generated one
code?: string;
standardization?: 'standardized' | 'proposed_new';
tree_code?: string;
ulo_code?: string;
cio_code?: string;
sio_codes?: string[];
}
// Domain Detection
type DomainProfile = {
inputType: 'repository' | 'syllabus' | 'description' | 'files' | 'mixed';
domainCategory: 'software' | 'math' | 'science' | ...;
learningMode: 'product' | 'concept' | 'hybrid';
graphType: 'project_graph' | 'knowledge_graph' | 'hybrid_graph';
confidence: { inputType: number; domainCategory: number; learningMode: number };
ambiguous?: { domain1: DomainCategory; domain2: DomainCategory; reason: string };
}
// Concept Escalation (ULO/CIO/SIO depth)
type ConceptMapping = {
keyword: string;
concept_code: string;
concept_name: string;
depth: 'ulo' | 'cio' | 'sio'; // WHAT (ulo), HOW (cio), IMPLEMENTATION (sio)
depth_source: 'llm' | 'heuristic' | 'override'; // who decided it — the regex
// fallback (comment→ulo, import→sio) has no
// pedagogical basis and must stay distinguishable
depth_rationale: string;
evidence_files: string[];
}
// Assembled Roadmap (with time budget) — TypeScript interface, not a Zod schema
type AssembledRoadmap = {
project: ProjectGraph['project'];
product: ProjectGraph['product'];
phases: AssembledPhase[]; // { phase_id, phase_name, milestones, total_minutes,
// concept_progression }
feature_concepts: Map<string, ConceptMapping[]>;
time_budget: {
session_minutes: number; // 90
overhead_factor: number; // 1.15
total_estimated_minutes: number; // Σ milestone.estimated_minutes (raw)
total_adjusted_minutes: number; // Σ milestone.adjusted_minutes (after overhead)
};
};
type AssembledMilestone = {
id: string; name: string; description: string;
feature_id: string; feature_name: string;
concept_code: string; depth: 'ulo' | 'cio' | 'sio';
all_keywords: string[]; // keyword ledger lives HERE, per milestone
new_keywords: string[];
prerequisite_keywords: string[];
steps: Step[];
estimated_minutes: number;
adjusted_minutes: number; // estimated_minutes × overhead_factor
completion_level: string;
};
// Curriculum Feed (planner-ready projection)
type CurriculumFeed = {
schema_version: 2;
source: {
graph_type: 'project_graph' | 'knowledge_graph' | 'hybrid_graph';
warnings: string[]; // DERIVED from diagnostics (severity ≠ 'info')
hallucination_count: number;
diagnostics: FeedDiagnostic[]; // { code, severity, message, node_ids }
provenance?: FeedProvenance; // providers / models / calls / degraded
};
learning_nodes: FeedNode[]; // in TEACHING ORDER when phases[] is non-empty
dependency_edges: FeedEdge[];
suggested_groupings: FeedGrouping[];
phases: FeedPhase[]; // when non-empty, THESE — not suggested_groupings — define the teaching sequence and unit structure
}
type FeedNode = {
id: string;
kind: 'skill' | 'concept' | 'product_step';
name: string;
concept_codes: string[];
keywords: { all: string[]; new: string[]; prerequisite: string[] };
estimated_minutes: number;
bloom_hint: 'Remember' | 'Understand' | 'Apply' | 'Analyze' | 'Evaluate' | 'Create' | null;
depth_hint: 'ulo' | 'cio' | 'sio' | null;
completion_level: 'mvp' | 'standard' | 'stretch';
scaffolding: { scaffold_candidates: ScaffoldCandidate[]; core_value_minutes: number };
references: { file: string; evidence: string }[];
feature_id: string | null;
concept_ref: string | null;
phase_id: string; // development phase that introduces/uses this node ('' on legacy feeds)
introduce_aspect: 'new' | 'advanced' | 'preview' | null; // first intro / advanced revisit / preview priming
user_visible_deliverable: string; // step's outcome.user_visible — checkpoint candidate for session cutting ('' on concept nodes)
depth_variants?: { ulo: number; cio: number; sio: number }; // minutes per depth level — reconciler price list (ULO ≤ CIO ≤ SIO)
depth_scaffold_candidates?: { from_depth: 'ulo' | 'cio' | 'sio'; to_depth: 'ulo' | 'cio' | 'sio'; minutes_saved: number; reason: string }[]; // DEPTH downgrades (SIO→CIO, CIO→ULO) — parallel to scaffold_candidates
is_core: boolean; // Master-Tree core concept — never depth-downgraded; escalate instead
external_prerequisites: string[]; // what the learner must already know that this feed does not teach
}
type FeedPhase = {
id: string;
order: number; // 1-based teaching order
name: string;
product_completion: string;
node_ids: string[]; // nodes of this phase, already in teaching order
}
type FeedEdge = {
from: string; // node id
to: string; // node id
kind: 'knowledge' | 'task';
reason: string;
}
type FeedGrouping = {
id: string;
name: string;
node_ids: string[];
rationale: string;
}API Reference
detectDomainProfile(options) → DomainProfile
Detects input type, domain category, learning mode, and graph type.
import { detectDomainProfile } from '@thanh01.pmt/domain-kit';
const profile = detectDomainProfile({
repoUrl?: string;
repoDir?: string;
goal?: string;
description?: string;
syllabusText?: string;
chapters?: string[];
fileExtensions?: string[];
techStack?: string;
gradeBand?: [number, number];
parsedSyllabus?: ParsedSyllabus; // pre-parsed syllabus; skips parseSyllabus()
});runProjectGraphPipeline(options) → PipelineResult
Full pipeline for product-driven projects. Requires source code repository.
import { runProjectGraphPipeline } from '@thanh01.pmt/domain-kit';
const result = await runProjectGraphPipeline({
repoDir: string; // Required: local repo path
goal: string; // Required: project goal
techStack: string; // Required: comma-separated technologies
llmConfig?: LlmClientConfig;
embeddings?: Record<string, { embedding?: number[] }>;
onProgress?: (step: string, message: string) => void;
});
// NOTE: sessionMinutes / overheadFactor / maxMilestoneMinutes are NOT options here.
// STEP_8 hardcodes 90 / 1.15 / 120 when it calls assembleRoadmap(). Call
// assembleRoadmap() yourself if you need different values.
// Returns:
{
projectGraph: ProjectGraph;
resolvedConcepts: ResolveConceptsResult;
roadmap: AssembledRoadmap;
hallucinations: Hallucination[];
keywords: Keyword[];
featureConcepts: Map<string, ConceptMapping[]>;
provenance: LlmCallRecord[]; // every provider attempt this run made
}generateKnowledgeGraph(options) → KnowledgeGraphPipelineResult
Generates knowledge graph for concept-driven subjects.
import { generateKnowledgeGraph } from '@thanh01.pmt/domain-kit';
const result = await generateKnowledgeGraph({
subject: string; // Required: subject name
description?: string;
syllabusText?: string; // Full syllabus text (multi-language supported)
gradeBand?: [number, number];
domain?: string;
techStack?: string; // target tech for SIO keyword binding (e.g. "python")
knowledgeTreePath?: string; // path to mlo-knowlege-tree.tsv → enables STEP_2_5 standardization
proposalsPath?: string; // where proposed-new concepts are persisted for the later merge
llmConfig?: LlmClientConfig;
onProgress?: (step: string, message: string) => void;
});
// Internally runs STEP_1 … STEP_6 — see [Pipeline Details](#pipeline-details).
// Returns:
{
knowledgeGraph: KnowledgeGraph;
warnings: string[];
verificationReport?: KnowledgeGraphVerificationReport; // uncovered topics, missing ULO/CIO/SIO…
}Prefer
runKnowledgeGraphPipeline()overgenerateKnowledgeGraph(): it parses the syllabus, applies syllabus-derived defaults (subject, domain, grade band) and emits theCurriculumFeedfor you.
generateHybridGraph(options) → HybridGraphPipelineResult
Links project graph with knowledge graph.
import { generateHybridGraph } from '@thanh01.pmt/domain-kit';
const result = await generateHybridGraph({
projectGraph: ProjectGraph;
knowledgeGraph: KnowledgeGraph; // both are required
llmConfig?: LlmClientConfig;
onProgress?: (step: string, message: string) => void;
});
// Returns:
{
hybridGraph: HybridGraph; // links[] + phases[] + total_project_minutes /
// total_concept_minutes / linked_prereq_minutes
warnings: string[];
}emitCurriculumFeed(graph, opts?) → CurriculumFeed
Converts any domain graph (project, knowledge, or hybrid) into a unified, planner-ready CurriculumFeed. Pure transformation — no LLM calls. Validates output (fail-closed).
import { emitCurriculumFeed } from '@thanh01.pmt/domain-kit';
// From any graph type:
const feed = emitCurriculumFeed(projectGraph); // project_graph
const feed = emitCurriculumFeed(knowledgeGraph); // knowledge_graph
const feed = emitCurriculumFeed(hybridGraph); // hybrid_graph
const feed = emitCurriculumFeed(existingFeed); // pass-through (already a feed)
// With options:
const feed = emitCurriculumFeed(graph, {
hallucinationCount: 3, // metadata for source info
warnings: ['Feature F5 had low confidence'],
provenance: { providers: ['nvidia'], models: ['nemotron'], calls: 7, failed_calls: 0, degraded: false },
});
// Gate on diagnostics rather than substring-matching warning text:
const blockers = feed.source.diagnostics.filter((d) => d.severity === 'error');
if (blockers.length) throw new Error(blockers.map((d) => d.code).join(', '));
// Returns CurriculumFeed with:
// - learning_nodes[] — unified node format (concept | skill | product_step), in teaching order when phases[] is non-empty
// - dependency_edges[] — knowledge (prerequisite) or task (build order) edges
// - suggested_groupings[] — feature or category groupings (layout hint only)
// - phases[] — development phases; when non-empty, the teaching sequence + unit structure
// - Each node has: bloom_hint, depth_hint (ulo/cio/sio), keywords.new vs .prerequisite,
// introduce_aspect (new/advanced/preview), user_visible_deliverable,
// depth_variants + depth_scaffold_candidates + is_core (depth-reconcile inputs)Key behaviors:
- Teaching-order emission (contract, 2026-09-18): when a hybrid graph carries
phases[], thenodesarray follows the phase teaching sequence — the order in the file IS the order in the classroom. The former "concepts emitted first, then steps" array layout is ABOLISHED: it contradicted phase order, fabricated backwards prerequisite edges, and caused the planner's "Teaching-order violation" failure (see postmortem 2026-09-17 §9) - No fabricated backwards edges: when a keyword consumer sits in an EARLIER phase than the concept's anchor teach (and no preview covers it), the emitter does NOT invent a dependency edge — it records a drift warning instead (
hybrid link drift: …); the host decides, the emitter reports - Overtake guard: a product step must never become the introducer of a concept's vocabulary; the first appearance of a keyword belongs to the concept (or its preview node) that teaches it
- Preview priming (aspect
preview, kit ≥0.3.0): a phase may declare a light awareness pass for a concept whose anchor teach lives in a LATER phase — emitted as node<cid>__PREVxxwith depthulo, bloomUnderstand, ~25% minutes (min 5),keywords.all(making it the legitimate vocabulary introducer), no prerequisite edges, no scaffolding. Five fail-closed emission rules: unknown concept, already-introduced concept, duplicate preview, no anchor anywhere, anchor not in a later phase - Knowledge graphs: concepts + prerequisite edges + category groupings
- Project graphs: features → steps + depends_on edges + feature groupings
- Pass-through: if input already has
learning_nodes[], validates and returns as-is - Legacy rejection: throws descriptive error if graph has
implementation.tasksbut nofeatures[].steps(old knowledge-tree format) - New/prerequisite keywords: computed in teaching order (phase sequence when phases exist, array order otherwise) — first appearance = new, seen before = prerequisite. Anchoring keywords to array position instead of teaching order is the exact assumption that caused the 2026-09-17 incident
- Fail-closed validation: output is validated before return — duplicate IDs, unresolvable edges, self-loops, non-empty, and the teaching-order invariant: when phases define the sequence, a prerequisite must live in the same or an EARLIER phase than its consumer (a violation throws at emission, never defers to the planner)
Parsers
import { parseSwiftFile, parseTsFile, parsePythonFile, parseCppFile } from '@thanh01.pmt/domain-kit';
const swiftResult = parseSwiftFile('AuthViewModel.swift', fileContent);
// → { imports: ['SwiftUI', 'FirebaseAuth'], types: [...], functions: [...] }Keyword Extraction
import { extractKeywords } from '@thanh01.pmt/domain-kit';
const keywords = extractKeywords(parsedSourceContext);
// → [{ keyword: 'SwiftUI', source: 'import', weight: 1.0, platform: 'app' }]Pipeline Details
Project Graph Pipeline (product-driven)
runProjectGraphPipeline() — the only pipeline whose steps live inside its orchestrator.
Step ids below are the ones it actually logs.
| Step | What happens | Fail mode |
|---|---|---|
| STEP_1 | scan the file tree (names only) → LLM selects relevant files → read only those → parse → merge by primary language | parse failure is per-file, non-fatal |
| STEP_2 | extractKeywords() — filter stdlib, assign weights, tag platform (app / esp32) | — |
| STEP_3 | SDK API index, derived from STEP_2 keywords | — |
| STEP_4_C0 | scaffold → feature F0 "FOUNDATION & SETUP" | scaffold extractor returns null; the pipeline continues without F0 |
| STEP_4_C1 | overview → product meta (goals, users, journeys, development stages) + the feature list | — |
| STEP_4_C2 | steps per feature — one batched LLM call, falling back to per-feature | a feature that still fails is logged and left step-less; no throw |
| STEP_5 | verifyProjectGraph() — strip files / APIs / keywords not found in code; F0 keywords exempt | findings returned as hallucinations[], graph is repaired |
| STEP_6 | escalateAndMapConcepts() — keyword → neutral concept + ulo/cio/sio + evidence files. Depth from the LLM; regex fallback on LLM failure (import → sio, code body → sio, comment → ulo) | LLM failure degrades to the regex fallback |
| STEP_7 | resolveConcepts() — concept → Master-Tree code, scored ULO/CIO-aware | unmatched keywords become proposed concepts |
| STEP_8 | assembleRoadmap() — milestones grouped into phases; each milestone gets all / new / prerequisite keywords and a time budget (90 min sessions, ×1.15 overhead, 120 min split cap — hardcoded) | — |
There is no STEP 9 here. Feed emission happens one layer up: runDomainPipeline()
calls emitCurriculumFeed() for the product branch, and the knowledge / hybrid
orchestrators call it for theirs.
Knowledge Graph Pipeline (concept-driven)
generateKnowledgeGraph() — the LLM work. Step ids as logged:
| Step | What happens | Fail mode |
|---|---|---|
| STEP_1 | parse the syllabus into structured units/topics, then LLM-decompose the subject into concepts, prerequisites, problem_types, categories | syllabus truncated at KG_MAX_SYLLABUS_CHARS (default 32000) + warning |
| STEP_2 | validate & clean — description ≥ 50 chars; keyword registry fail-closed (empty keywords are derived from name/description; nothing derivable → throw); dangling prerequisite edges removed | throw when keywords cannot be derived |
| STEP_2_5 | only when knowledgeTreePath is supplied — standardize every concept against mlo-knowlege-tree.tsv into standardized or proposed_new; adopted tech-stack keywords merge into the registry; tree prerequisite codes seed internal edges | missing decision for a concept → throw |
| STEP_3 | detectPrerequisiteCycles() → breakPrerequisiteCycles() at the weakest edge | warnings; edges are removed |
| STEP_4 | topologicalSort() (Kahn) — learning_path is rebuilt from the sorted order | incomplete sort → warning |
| STEP_5 | hierarchical ULO/CIO/SIO authoring + deterministic ulo_code / cio_code / sio_codes (a STEP_2_5 tree code always wins) | — |
| STEP_5b | mine SIO text for tech identifiers (backticked tokens, @Annotations, a.b(), .modifiers, camelCase, cap 8) → merge into keywords | — |
| STEP_5c | minute calibration — each signal normalised against its own cap before weighting (0.30·sio + 0.25·problem_types + 0.20·prereqs + 0.25·highBloom, all over min(count/4, 1)), minutes = 15 + 45·score ⇒ 15–60 min. Overrides the LLM only when the two diverge by more than 50% | — |
| STEP_6 | verifyKnowledgeGraph() — referential integrity + reverse coverage (every syllabus topic covered by ≥ 1 concept) | warnings, never throws |
Feed emission is not a step of generateKnowledgeGraph() — it is the next line of
runKnowledgeGraphPipeline() in src/pipeline/.
Hybrid Graph Pipeline (both)
generateHybridGraph() — see Layer 2 · Branch C
for the annotated diagram. STEP_1 links, STEP_2 phases (up to 3 repair attempts, then
throw), STEP_3 totals.
AI Agent Guide
This section is specifically for AI agents that need to use domain-kit programmatically.
When to Use domain-kit
Use domain-kit when you need to:
- Analyze a project and understand what it builds, what technologies it uses, what concepts it teaches
- Decompose a subject into structured learning concepts with prerequisites
- Generate a domain graph that
curriculum-kitcan consume to create lessons, activities, assessments
Step-by-Step Usage
Step 1: Detect the Domain
Always start here. This tells you what kind of input you have and what graph type to generate.
import { detectDomainProfile } from '@thanh01.pmt/domain-kit';
const profile = detectDomainProfile({
// Provide whatever the user gave you:
repoUrl: userRepoUrl,
goal: use