diff --git a/README.md b/README.md index 3513f72..79cb033 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/learning-graph.md b/docs/learning-graph.md new file mode 100644 index 0000000..0b4f536 --- /dev/null +++ b/docs/learning-graph.md @@ -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 diff --git a/domain-packs/bayes-extension/concepts.yaml b/domain-packs/bayes-extension/concepts.yaml index 7c17308..935e9a4 100644 --- a/domain-packs/bayes-extension/concepts.yaml +++ b/domain-packs/bayes-extension/concepts.yaml @@ -4,3 +4,9 @@ concepts: prerequisites: [] mastery_signals: - explain a prior distribution + - id: posterior + title: Posterior + prerequisites: + - prior + mastery_signals: + - explain updating beliefs diff --git a/domain-packs/bayes-extension/pack.yaml b/domain-packs/bayes-extension/pack.yaml index 881109c..eed6c70 100644 --- a/domain-packs/bayes-extension/pack.yaml +++ b/domain-packs/bayes-extension/pack.yaml @@ -11,3 +11,4 @@ dependencies: - name: foundations-statistics min_version: 1.0.0 max_version: 1.9.99 +overrides: [] diff --git a/domain-packs/bayes-extension/roadmap.yaml b/domain-packs/bayes-extension/roadmap.yaml index 3074db1..a27bfcd 100644 --- a/domain-packs/bayes-extension/roadmap.yaml +++ b/domain-packs/bayes-extension/roadmap.yaml @@ -3,5 +3,6 @@ stages: title: Bayesian Basics concepts: - prior + - posterior checkpoint: - - compare priors + - compare priors and posteriors diff --git a/domain-packs/foundations-statistics/concepts.yaml b/domain-packs/foundations-statistics/concepts.yaml index c8d8efc..fe41c25 100644 --- a/domain-packs/foundations-statistics/concepts.yaml +++ b/domain-packs/foundations-statistics/concepts.yaml @@ -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 diff --git a/domain-packs/foundations-statistics/pack.yaml b/domain-packs/foundations-statistics/pack.yaml index e90d144..3ab1ec7 100644 --- a/domain-packs/foundations-statistics/pack.yaml +++ b/domain-packs/foundations-statistics/pack.yaml @@ -8,3 +8,4 @@ description: Shared foundations for statistics learning. author: Wesley R. Elsberry license: MIT dependencies: [] +overrides: [] diff --git a/domain-packs/foundations-statistics/roadmap.yaml b/domain-packs/foundations-statistics/roadmap.yaml index 7425072..699425d 100644 --- a/domain-packs/foundations-statistics/roadmap.yaml +++ b/domain-packs/foundations-statistics/roadmap.yaml @@ -3,5 +3,6 @@ stages: title: Foundations concepts: - descriptive-statistics + - probability-basics checkpoint: - summarize a dataset diff --git a/domain-packs/override-foundations/concepts.yaml b/domain-packs/override-foundations/concepts.yaml new file mode 100644 index 0000000..1cd7868 --- /dev/null +++ b/domain-packs/override-foundations/concepts.yaml @@ -0,0 +1,6 @@ +concepts: + - id: descriptive-statistics + title: Descriptive Statistics (Overridden) + prerequisites: [] + mastery_signals: + - explain central tendency in an updated way diff --git a/domain-packs/override-foundations/pack.yaml b/domain-packs/override-foundations/pack.yaml new file mode 100644 index 0000000..3413311 --- /dev/null +++ b/domain-packs/override-foundations/pack.yaml @@ -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 diff --git a/domain-packs/override-foundations/projects.yaml b/domain-packs/override-foundations/projects.yaml new file mode 100644 index 0000000..a60771b --- /dev/null +++ b/domain-packs/override-foundations/projects.yaml @@ -0,0 +1,8 @@ +projects: + - id: override-project + title: Override Project + difficulty: intermediate + prerequisites: + - descriptive-statistics + deliverables: + - comparison memo diff --git a/domain-packs/override-foundations/roadmap.yaml b/domain-packs/override-foundations/roadmap.yaml new file mode 100644 index 0000000..bba15e6 --- /dev/null +++ b/domain-packs/override-foundations/roadmap.yaml @@ -0,0 +1,7 @@ +stages: + - id: stage-1 + title: Override Stage + concepts: + - descriptive-statistics + checkpoint: + - compare revised explanations diff --git a/domain-packs/override-foundations/rubrics.yaml b/domain-packs/override-foundations/rubrics.yaml new file mode 100644 index 0000000..5756bf3 --- /dev/null +++ b/domain-packs/override-foundations/rubrics.yaml @@ -0,0 +1,6 @@ +rubrics: + - id: override-rubric + title: Override Rubric + criteria: + - correctness + - comparison diff --git a/src/didactopus/__init__.py b/src/didactopus/__init__.py index 7720d99..a6dfeb4 100644 --- a/src/didactopus/__init__.py +++ b/src/didactopus/__init__.py @@ -8,6 +8,8 @@ __all__ = [ "curriculum", "domain_map", "evaluation", + "graph_merge", + "learning_graph", "main", "mentor", "model_provider", diff --git a/src/didactopus/artifact_registry.py b/src/didactopus/artifact_registry.py index 7bac28a..5fd7c07 100644 --- a/src/didactopus/artifact_registry.py +++ b/src/didactopus/artifact_registry.py @@ -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)) diff --git a/src/didactopus/artifact_schemas.py b/src/didactopus/artifact_schemas.py index f462513..8c85946 100644 --- a/src/didactopus/artifact_schemas.py +++ b/src/didactopus/artifact_schemas.py @@ -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): diff --git a/src/didactopus/curriculum.py b/src/didactopus/curriculum.py index 24ed1a9..363d670 100644 --- a/src/didactopus/curriculum.py +++ b/src/didactopus/curriculum.py @@ -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) ] diff --git a/src/didactopus/domain_map.py b/src/didactopus/domain_map.py index 56bcf77..57a0d24 100644 --- a/src/didactopus/domain_map.py +++ b/src/didactopus/domain_map.py @@ -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 diff --git a/src/didactopus/graph_merge.py b/src/didactopus/graph_merge.py new file mode 100644 index 0000000..aacfdc8 --- /dev/null +++ b/src/didactopus/graph_merge.py @@ -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 diff --git a/src/didactopus/learning_graph.py b/src/didactopus/learning_graph.py new file mode 100644 index 0000000..9d595e5 --- /dev/null +++ b/src/didactopus/learning_graph.py @@ -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 diff --git a/src/didactopus/main.py b/src/didactopus/main.py index 2fcab7a..addad71 100644 --- a/src/didactopus/main.py +++ b/src/didactopus/main.py @@ -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)) diff --git a/tests/test_config.py b/tests/test_config.py index 0612e88..ddffd57 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_learning_graph.py b/tests/test_learning_graph.py new file mode 100644 index 0000000..17c0542 --- /dev/null +++ b/tests/test_learning_graph.py @@ -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)