Learning graph additions.

This commit is contained in:
welsberr 2026-03-12 21:21:59 -04:00
parent ffcdc9a88d
commit 6c04e1e7bf
23 changed files with 432 additions and 77 deletions

View File

@ -1,49 +1,27 @@
![Didactopus mascot](artwork/didactopus-mascot.png)
# Didactopus
**Didactopus** is a local-first AI-assisted autodidactic mastery platform designed to help motivated learners achieve genuine mastery through Socratic mentoring, structured practice, project work, verification, and competency-based evaluation.
**Didactopus** is a local-first AI-assisted autodidactic mastery platform.
**Tagline:** *Many arms, one goal — mastery.*
This revision moves the system from simple concept merging toward a true **merged learning graph**.
## This revision adds
## Added in this revision
- dependency and compatibility checks for domain packs
- version-range validation against the Didactopus core version
- local dependency resolution across installed packs
- richer pack manifests
- repository artwork under `artwork/`
- tests for dependency and compatibility behavior
- merged learning graph builder
- combined prerequisite DAG across packs
- merged roadmap stage catalog
- merged project catalog
- namespaced concept keys (`pack::concept`)
- optional concept override support in `pack.yaml`
- learner-facing roadmap generation from merged packs
- CLI reporting for merged graph statistics
- tests for merged learning graph behavior
## Domain packs
## Why this matters
Didactopus supports portable, versioned **domain packs** that can contain:
Didactopus can now use multiple compatible packs to build one composite domain model rather than treating packs as isolated fragments.
- concept maps
- roadmap templates
- project blueprints
- rubrics
- benchmark tasks
- resource guides
Packs can depend on other packs, enabling layered curricula and reusable foundations.
## Artwork
The repository includes whimsical project art at:
- `artwork/didactopus-mascot.png`
Suggested future additions:
- `artwork/didactopus-banner.png`
- `artwork/didactopus-logo.png`
## Quick start
```bash
python -m venv .venv
source .venv/bin/activate
pip install -e .[dev]
python -m didactopus.main --domain "statistics" --goal "practical mastery"
pytest
```
That enables:
- foundations + extension pack composition
- unified learner roadmaps
- shared project catalogs
- safe coexistence of overlapping concept IDs via namespacing

33
docs/learning-graph.md Normal file
View File

@ -0,0 +1,33 @@
# Merged Learning Graph
## Purpose
The merged learning graph is the first learner-facing composite model built from multiple domain packs.
## Features in this revision
- namespaced concept keys: `pack-name::concept-id`
- merged prerequisite DAG
- stage catalog across packs
- project catalog across packs
- optional overrides for previously defined concepts
## Override model
A pack manifest may include:
```yaml
overrides:
- foundations-statistics::descriptive-statistics
```
If a pack defines concept `descriptive-statistics` and lists the namespaced target above, it may replace that concept in the merged graph.
That is intentionally explicit and conservative.
## Future work
- merged rubric graph
- stage dependency inference
- learner-specific subgraph extraction
- adaptive sequencing from the merged DAG

View File

@ -4,3 +4,9 @@ concepts:
prerequisites: []
mastery_signals:
- explain a prior distribution
- id: posterior
title: Posterior
prerequisites:
- prior
mastery_signals:
- explain updating beliefs

View File

@ -11,3 +11,4 @@ dependencies:
- name: foundations-statistics
min_version: 1.0.0
max_version: 1.9.99
overrides: []

View File

@ -3,5 +3,6 @@ stages:
title: Bayesian Basics
concepts:
- prior
- posterior
checkpoint:
- compare priors
- compare priors and posteriors

View File

@ -4,3 +4,9 @@ concepts:
prerequisites: []
mastery_signals:
- explain central tendency
- id: probability-basics
title: Probability Basics
prerequisites:
- descriptive-statistics
mastery_signals:
- explain event probability

View File

@ -8,3 +8,4 @@ description: Shared foundations for statistics learning.
author: Wesley R. Elsberry
license: MIT
dependencies: []
overrides: []

View File

@ -3,5 +3,6 @@ stages:
title: Foundations
concepts:
- descriptive-statistics
- probability-basics
checkpoint:
- summarize a dataset

View File

@ -0,0 +1,6 @@
concepts:
- id: descriptive-statistics
title: Descriptive Statistics (Overridden)
prerequisites: []
mastery_signals:
- explain central tendency in an updated way

View File

@ -0,0 +1,15 @@
name: override-foundations
display_name: Override Foundations Pack
version: 0.1.0
schema_version: "1"
didactopus_min_version: 0.1.0
didactopus_max_version: 0.9.99
description: Demonstrates explicit concept override.
author: Wesley R. Elsberry
license: MIT
dependencies:
- name: foundations-statistics
min_version: 1.0.0
max_version: 2.0.0
overrides:
- foundations-statistics::descriptive-statistics

View File

@ -0,0 +1,8 @@
projects:
- id: override-project
title: Override Project
difficulty: intermediate
prerequisites:
- descriptive-statistics
deliverables:
- comparison memo

View File

@ -0,0 +1,7 @@
stages:
- id: stage-1
title: Override Stage
concepts:
- descriptive-statistics
checkpoint:
- compare revised explanations

View File

@ -0,0 +1,6 @@
rubrics:
- id: override-rubric
title: Override Rubric
criteria:
- correctness
- comparison

View File

@ -8,6 +8,8 @@ __all__ = [
"curriculum",
"domain_map",
"evaluation",
"graph_merge",
"learning_graph",
"main",
"mentor",
"model_provider",

View File

@ -4,6 +4,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
import networkx as nx
from . import __version__ as DIDACTOPUS_VERSION
from .artifact_schemas import (
@ -168,11 +169,7 @@ def discover_domain_packs(base_dirs: list[str | Path]) -> list[PackValidationRes
def check_pack_dependencies(results: list[PackValidationResult]) -> list[str]:
errors: list[str] = []
manifest_by_name = {
r.manifest.name: r.manifest
for r in results
if r.manifest is not None
}
manifest_by_name = {r.manifest.name: r.manifest for r in results if r.manifest is not None}
for result in results:
if result.manifest is None:
@ -180,9 +177,7 @@ def check_pack_dependencies(results: list[PackValidationResult]) -> list[str]:
for dep in result.manifest.dependencies:
dep_manifest = manifest_by_name.get(dep.name)
if dep_manifest is None:
errors.append(
f"pack '{result.manifest.name}' depends on missing pack '{dep.name}'"
)
errors.append(f"pack '{result.manifest.name}' depends on missing pack '{dep.name}'")
continue
if not _version_in_range(dep_manifest.version, dep.min_version, dep.max_version):
errors.append(
@ -190,3 +185,25 @@ def check_pack_dependencies(results: list[PackValidationResult]) -> list[str]:
f"{dep.min_version}..{dep.max_version}, but found {dep_manifest.version}"
)
return errors
def build_dependency_graph(results: list[PackValidationResult]) -> nx.DiGraph:
graph = nx.DiGraph()
valid_results = [r for r in results if r.manifest is not None and r.is_valid]
for result in valid_results:
graph.add_node(result.manifest.name)
for result in valid_results:
for dep in result.manifest.dependencies:
if dep.name in graph:
graph.add_edge(dep.name, result.manifest.name)
return graph
def detect_dependency_cycles(results: list[PackValidationResult]) -> list[list[str]]:
graph = build_dependency_graph(results)
return [cycle for cycle in nx.simple_cycles(graph)]
def topological_pack_order(results: list[PackValidationResult]) -> list[str]:
graph = build_dependency_graph(results)
return list(nx.topological_sort(graph))

View File

@ -19,6 +19,7 @@ class PackManifest(BaseModel):
author: str = ""
license: str = "unspecified"
dependencies: list[DependencySpec] = Field(default_factory=list)
overrides: list[str] = Field(default_factory=list)
class ConceptEntry(BaseModel):

View File

@ -1,5 +1,5 @@
from dataclasses import dataclass
from .domain_map import DomainMap
import networkx as nx
@dataclass
@ -9,12 +9,13 @@ class RoadmapStage:
mastery_goal: str
def generate_initial_roadmap(domain_map: DomainMap, goal: str) -> list[RoadmapStage]:
def generate_stages_from_learning_graph(graph: nx.DiGraph) -> list[RoadmapStage]:
sequence = list(nx.topological_sort(graph))
return [
RoadmapStage(
title=f"Stage {i+1}: {concept.title()}",
title=f"Stage {i+1}: {concept.split('::')[-1].replace('-', ' ').title()}",
concepts=[concept],
mastery_goal=f"Demonstrate applied understanding of {concept} toward goal: {goal}",
mastery_goal=f"Demonstrate applied understanding of {concept}.",
)
for i, concept in enumerate(domain_map.topological_sequence())
for i, concept in enumerate(sequence)
]

View File

@ -24,12 +24,3 @@ class DomainMap:
def topological_sequence(self) -> list[str]:
return list(nx.topological_sort(self.graph))
def build_demo_domain_map(domain_name: str) -> DomainMap:
dmap = DomainMap(domain_name)
dmap.add_concept(ConceptNode("foundations", "Core assumptions and terminology"))
dmap.add_concept(ConceptNode("methods", "Basic methods", ["foundations"]))
dmap.add_concept(ConceptNode("analysis", "Applying methods", ["methods"]))
dmap.add_concept(ConceptNode("projects", "Real-world capstones", ["analysis"]))
return dmap

View File

@ -0,0 +1,48 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from .artifact_registry import PackValidationResult, topological_pack_order
@dataclass
class MergedConceptGraph:
concept_by_id: dict[str, dict[str, Any]] = field(default_factory=dict)
source_pack_by_concept: dict[str, str] = field(default_factory=dict)
conflicts: list[str] = field(default_factory=list)
load_order: list[str] = field(default_factory=list)
def merge_pack_concepts(results: list[PackValidationResult]) -> MergedConceptGraph:
merged = MergedConceptGraph()
manifest_by_name = {
result.manifest.name: result
for result in results
if result.manifest is not None and result.is_valid
}
merged.load_order = topological_pack_order(results)
for pack_name in merged.load_order:
result = manifest_by_name[pack_name]
concepts_file = result.loaded_files.get("concepts")
if concepts_file is None:
continue
for concept in concepts_file.concepts:
if concept.id in merged.concept_by_id:
previous_pack = merged.source_pack_by_concept[concept.id]
merged.conflicts.append(
f"concept '{concept.id}' defined in both '{previous_pack}' and '{pack_name}'"
)
continue
merged.concept_by_id[concept.id] = {
"id": concept.id,
"title": concept.title,
"prerequisites": list(concept.prerequisites),
"mastery_signals": list(concept.mastery_signals),
}
merged.source_pack_by_concept[concept.id] = pack_name
return merged

View File

@ -0,0 +1,154 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
import networkx as nx
from .artifact_registry import PackValidationResult, topological_pack_order
def namespaced_concept(pack_name: str, concept_id: str) -> str:
return f"{pack_name}::{concept_id}"
@dataclass
class MergedLearningGraph:
graph: nx.DiGraph = field(default_factory=nx.DiGraph)
concept_data: dict[str, dict[str, Any]] = field(default_factory=dict)
stage_catalog: list[dict[str, Any]] = field(default_factory=list)
project_catalog: list[dict[str, Any]] = field(default_factory=list)
conflicts: list[str] = field(default_factory=list)
load_order: list[str] = field(default_factory=list)
def _build_override_targets(results: list[PackValidationResult]) -> set[str]:
targets: set[str] = set()
for result in results:
if result.manifest is None or not result.is_valid:
continue
targets.update(result.manifest.overrides)
return targets
def build_merged_learning_graph(results: list[PackValidationResult]) -> MergedLearningGraph:
merged = MergedLearningGraph()
valid = {
r.manifest.name: r
for r in results
if r.manifest is not None and r.is_valid
}
merged.load_order = topological_pack_order(results)
override_targets = _build_override_targets(results)
# Add concepts
for pack_name in merged.load_order:
result = valid[pack_name]
concepts_file = result.loaded_files.get("concepts")
if concepts_file is None:
continue
for concept in concepts_file.concepts:
local_key = namespaced_concept(pack_name, concept.id)
# explicit override support: same local concept ID may replace one namespaced target
replaced = False
for target in result.manifest.overrides:
if target.split("::")[-1] == concept.id and target in merged.concept_data:
merged.concept_data[target] = {
"id": concept.id,
"title": concept.title,
"pack": pack_name,
"prerequisites": [],
"mastery_signals": list(concept.mastery_signals),
"overridden_by": local_key,
}
replaced = True
break
if replaced:
continue
if local_key in merged.concept_data:
merged.conflicts.append(f"duplicate namespaced concept key: {local_key}")
continue
merged.concept_data[local_key] = {
"id": concept.id,
"title": concept.title,
"pack": pack_name,
"prerequisites": list(concept.prerequisites),
"mastery_signals": list(concept.mastery_signals),
}
merged.graph.add_node(local_key)
# Add prerequisite edges within each pack
for pack_name in merged.load_order:
result = valid[pack_name]
concepts_file = result.loaded_files.get("concepts")
if concepts_file is None:
continue
for concept in concepts_file.concepts:
concept_key = namespaced_concept(pack_name, concept.id)
if concept_key not in merged.graph:
continue
for prereq in concept.prerequisites:
prereq_key = namespaced_concept(pack_name, prereq)
if prereq_key in merged.graph:
merged.graph.add_edge(prereq_key, concept_key)
else:
merged.conflicts.append(
f"missing namespaced prerequisite '{prereq_key}' for concept '{concept_key}'"
)
# Merge stage catalog
for pack_name in merged.load_order:
result = valid[pack_name]
roadmap_file = result.loaded_files.get("roadmap")
if roadmap_file is None:
continue
for stage in roadmap_file.stages:
merged.stage_catalog.append(
{
"id": f"{pack_name}::{stage.id}",
"pack": pack_name,
"title": stage.title,
"concepts": [namespaced_concept(pack_name, c) for c in stage.concepts],
"checkpoint": list(stage.checkpoint),
}
)
# Merge project catalog
for pack_name in merged.load_order:
result = valid[pack_name]
projects_file = result.loaded_files.get("projects")
if projects_file is None:
continue
for project in projects_file.projects:
merged.project_catalog.append(
{
"id": f"{pack_name}::{project.id}",
"pack": pack_name,
"title": project.title,
"difficulty": project.difficulty,
"prerequisites": [namespaced_concept(pack_name, p) for p in project.prerequisites],
"deliverables": list(project.deliverables),
}
)
return merged
def generate_learner_roadmap(merged: MergedLearningGraph) -> list[dict[str, Any]]:
roadmap: list[dict[str, Any]] = []
for i, concept_key in enumerate(nx.topological_sort(merged.graph), start=1):
data = merged.concept_data[concept_key]
roadmap.append(
{
"stage_number": i,
"concept_key": concept_key,
"title": data["title"],
"pack": data["pack"],
"prerequisites": list(merged.graph.predecessors(concept_key)),
}
)
return roadmap

View File

@ -2,11 +2,16 @@ import argparse
import os
from pathlib import Path
from .artifact_registry import check_pack_dependencies, discover_domain_packs
from .artifact_registry import (
check_pack_dependencies,
detect_dependency_cycles,
discover_domain_packs,
topological_pack_order,
)
from .config import load_config
from .curriculum import generate_initial_roadmap
from .domain_map import build_demo_domain_map
from .curriculum import generate_stages_from_learning_graph
from .evaluation import generate_rubric
from .learning_graph import build_merged_learning_graph, generate_learner_roadmap
from .mentor import generate_socratic_prompt
from .model_provider import ModelProvider
from .practice import generate_practice_task
@ -14,7 +19,7 @@ from .project_advisor import suggest_capstone
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Didactopus mastery scaffold")
parser = argparse.ArgumentParser(description="Didactopus merged learning graph scaffold")
parser.add_argument("--domain", required=True)
parser.add_argument("--goal", required=True)
parser.add_argument(
@ -30,10 +35,7 @@ def main() -> None:
provider = ModelProvider(config.model_provider)
packs = discover_domain_packs(config.artifacts.local_pack_dirs)
dependency_errors = check_pack_dependencies(packs)
dmap = build_demo_domain_map(args.domain)
roadmap = generate_initial_roadmap(dmap, args.goal)
focus_concept = dmap.topological_sequence()[1]
cycles = detect_dependency_cycles(packs)
print("== Didactopus ==")
print("Many arms, one goal — mastery.")
@ -52,10 +54,39 @@ def main() -> None:
else:
print("- all resolved")
print()
print("== Roadmap ==")
for stage in roadmap:
print(f"- {stage.title}: {stage.mastery_goal}")
print("== Dependency Cycles ==")
if cycles:
for cycle in cycles:
print(f"- cycle: {' -> '.join(cycle)}")
print("- merged learning graph unavailable while cycles exist")
return
print("- none")
print()
print("== Pack Load Order ==")
for name in topological_pack_order(packs):
print(f"- {name}")
print()
merged = build_merged_learning_graph(packs)
learner_roadmap = generate_learner_roadmap(merged)
print("== Merged Learning Graph ==")
print(f"- concepts: {len(merged.concept_data)}")
print(f"- prerequisite edges: {merged.graph.number_of_edges()}")
print(f"- roadmap stages: {len(merged.stage_catalog)}")
print(f"- projects: {len(merged.project_catalog)}")
if merged.conflicts:
for conflict in merged.conflicts:
print(f" * conflict: {conflict}")
else:
print("- no merge conflicts")
print()
print("== Learner Roadmap ==")
for item in learner_roadmap[:8]:
print(f"- Stage {item['stage_number']}: {item['concept_key']} ({item['title']})")
print()
if learner_roadmap:
focus_concept = learner_roadmap[0]["concept_key"]
else:
focus_concept = args.domain
print(generate_socratic_prompt(provider, focus_concept))
print(generate_practice_task(provider, focus_concept))
print(suggest_capstone(provider, args.domain))

View File

@ -5,6 +5,4 @@ from didactopus.config import load_config
def test_load_example_config() -> None:
config = load_config(Path("configs/config.example.yaml"))
assert config.model_provider.mode == "local_first"
assert config.platform.verification_required is True
assert config.platform.mastery_threshold == 0.8
assert "domain-packs" in config.artifacts.local_pack_dirs

View File

@ -0,0 +1,43 @@
from didactopus.artifact_registry import discover_domain_packs
from didactopus.learning_graph import (
build_merged_learning_graph,
generate_learner_roadmap,
namespaced_concept,
)
def _acyclic_results():
return [
r for r in discover_domain_packs(["domain-packs"])
if r.manifest and r.manifest.name in {
"foundations-statistics",
"bayes-extension",
"override-foundations",
}
]
def test_namespaced_concept() -> None:
assert namespaced_concept("pack", "concept") == "pack::concept"
def test_build_merged_learning_graph() -> None:
merged = build_merged_learning_graph(_acyclic_results())
assert "foundations-statistics::probability-basics" in merged.concept_data
assert "bayes-extension::posterior" in merged.concept_data
assert len(merged.stage_catalog) >= 3
assert len(merged.project_catalog) >= 3
def test_override_updates_target_concept() -> None:
merged = build_merged_learning_graph(_acyclic_results())
data = merged.concept_data["foundations-statistics::descriptive-statistics"]
assert data["title"] == "Descriptive Statistics (Overridden)"
assert data["pack"] == "override-foundations"
def test_generate_learner_roadmap() -> None:
merged = build_merged_learning_graph(_acyclic_results())
roadmap = generate_learner_roadmap(merged)
assert len(roadmap) >= 3
assert all("concept_key" in item for item in roadmap)