"""Concept-level learning graph. Data-driven registry of teachable concepts, keyed by the SAME ``topic_key`` convention the product already persists mastery under: ``"{chapter_catalog_id}:{mission_id}"`` (see ``topic_mastery.topic_key`` and GuidedClass's assessment sync). For v1 each curated Sound Waves mission is one concept node; the shape is chapter-agnostic so later chapters extend the data, not the code. Honesty rules encoded here: - Only Sound Waves (``phy-p1-c1``) is a complete curated class; its nodes carry real objectives/misconceptions lifted from the hand-authored, source-backed lesson seeds (``src/lib/teaching/physicsLessonSeeds.ts``). - ``pyq`` resources are ``False`` everywhere because no PYQ evidence is curated yet (``pyqEvidenceStatus: "not_curated"`` in the seeds). - Unknown chapters/missions simply are not in the graph — callers must treat absence as "content unavailable", never invent coverage. """ from __future__ import annotations from dataclasses import dataclass, field SOUND_WAVES_CHAPTER_ID = "phy-p1-c1" @dataclass(frozen=True) class ConceptResources: lesson: bool = False quiz: bool = False flashcards: bool = False board_answer: bool = False numericals: bool = False pyq: bool = False @dataclass(frozen=True) class ConceptNode: concept_key: str # "{chapter_catalog_id}:{mission_id}" chapter_catalog_id: str mission_id: str subject: str title: str objective: str prerequisites: tuple[str, ...] # concept_keys exam_importance: float # 0..1, relative weight inside the chapter typical_misconceptions: tuple[str, ...] practice_types: tuple[str, ...] estimated_minutes: int resources: ConceptResources = field(default_factory=ConceptResources) availability: str = "curated" # curated | notes_only | locked textbook_reference: str = "" def _sw(mission_id: str) -> str: return f"{SOUND_WAVES_CHAPTER_ID}:{mission_id}" _SW_REFERENCE = "Kerala SCERT Physics, Standard X (2025) — Sound Waves, pp. 7-26" _SW_RESOURCES = ConceptResources(lesson=True, quiz=True, flashcards=True, board_answer=True, pyq=False) _SW_NUMERIC_RESOURCES = ConceptResources(lesson=True, quiz=True, flashcards=True, board_answer=True, numericals=True, pyq=False) SOUND_WAVES_CONCEPTS: tuple[ConceptNode, ...] = ( ConceptNode( concept_key=_sw("M1"), chapter_catalog_id=SOUND_WAVES_CHAPTER_ID, mission_id="M1", subject="Physics", title="Oscillation, Amplitude, Period and Frequency", objective="Understand the basic terms of oscillatory motion using a pendulum and swing.", prerequisites=(), exam_importance=0.8, typical_misconceptions=( "One oscillation is just going forward — it must return to complete one.", ), practice_types=("recall", "mcq", "board_answer"), estimated_minutes=25, resources=_SW_RESOURCES, textbook_reference=_SW_REFERENCE, ), ConceptNode( concept_key=_sw("M2"), chapter_catalog_id=SOUND_WAVES_CHAPTER_ID, mission_id="M2", subject="Physics", title="Natural Frequency, Forced Vibration & Resonance", objective="Understand natural frequency and how forced vibrations lead to resonance.", prerequisites=(_sw("M1"),), exam_importance=0.7, typical_misconceptions=( "Not recognising that the table is forced to vibrate by the mixie (forced vibration).", "Forgetting that resonance needs matching frequencies AND produces maximum amplitude.", ), practice_types=("recall", "mcq", "board_answer"), estimated_minutes=25, resources=_SW_RESOURCES, textbook_reference=_SW_REFERENCE, ), ConceptNode( concept_key=_sw("M3"), chapter_catalog_id=SOUND_WAVES_CHAPTER_ID, mission_id="M3", subject="Physics", title="Wave Motion & Types of Waves", objective="Learn how waves transfer energy without transferring particles, and differentiate longitudinal and transverse waves.", prerequisites=(_sw("M1"),), exam_importance=0.85, typical_misconceptions=( "Thinking the material itself moves forward with the wave — only energy moves.", ), practice_types=("recall", "mcq", "diagram", "board_answer"), estimated_minutes=30, resources=_SW_RESOURCES, textbook_reference=_SW_REFERENCE, ), ConceptNode( concept_key=_sw("M4"), chapter_catalog_id=SOUND_WAVES_CHAPTER_ID, mission_id="M4", subject="Physics", title="Frequency, Wavelength and Wave Speed (v = fλ)", objective="Define wavelength, speed of a wave, and understand the relationship v = fλ.", prerequisites=(_sw("M3"),), exam_importance=1.0, typical_misconceptions=( "Confusing wavelength with amplitude.", ), practice_types=("recall", "mcq", "formula", "board_answer"), estimated_minutes=30, resources=_SW_NUMERIC_RESOURCES, textbook_reference=_SW_REFERENCE, ), ConceptNode( concept_key=_sw("M5"), chapter_catalog_id=SOUND_WAVES_CHAPTER_ID, mission_id="M5", subject="Physics", title="Numericals using v = fλ", objective="Solve exam-style numericals step-by-step using v = fλ.", prerequisites=(_sw("M4"),), exam_importance=1.0, typical_misconceptions=( "Substituting wavelength in centimetres without converting to metres.", ), practice_types=("numerical", "board_answer"), estimated_minutes=30, resources=_SW_NUMERIC_RESOURCES, textbook_reference=_SW_REFERENCE, ), ConceptNode( concept_key=_sw("M6"), chapter_catalog_id=SOUND_WAVES_CHAPTER_ID, mission_id="M6", subject="Physics", title="Reflection, Echo & Reverberation", objective="Understand how sound reflects, the conditions for an echo, and reverberation.", prerequisites=(_sw("M3"),), exam_importance=0.9, typical_misconceptions=( "Thinking reflection doesn't happen in a small room — it does, we just cannot distinguish the echo.", ), practice_types=("recall", "mcq", "numerical", "board_answer"), estimated_minutes=30, resources=_SW_NUMERIC_RESOURCES, textbook_reference=_SW_REFERENCE, ), ConceptNode( concept_key=_sw("M7"), chapter_catalog_id=SOUND_WAVES_CHAPTER_ID, mission_id="M7", subject="Physics", title="Limits of Audibility & Ultrasonic Uses", objective="Understand audible range, infrasonic and ultrasonic sounds, and their applications.", prerequisites=(_sw("M4"),), exam_importance=0.7, typical_misconceptions=( "Assuming humans can hear everything — dogs and bats hear higher frequencies than us.", ), practice_types=("recall", "mcq", "board_answer"), estimated_minutes=25, resources=_SW_RESOURCES, textbook_reference=_SW_REFERENCE, ), ConceptNode( concept_key=_sw("M8"), chapter_catalog_id=SOUND_WAVES_CHAPTER_ID, mission_id="M8", subject="Physics", title="Seismic Waves and Tsunami", objective="Understand the destructive power of seismic waves and tsunamis.", prerequisites=(_sw("M3"),), exam_importance=0.6, typical_misconceptions=( "Waves are not just sound or light — they can carry massive destructive energy.", ), practice_types=("recall", "mcq", "board_answer"), estimated_minutes=25, resources=_SW_RESOURCES, textbook_reference=_SW_REFERENCE, ), ConceptNode( concept_key=_sw("M9"), chapter_catalog_id=SOUND_WAVES_CHAPTER_ID, mission_id="M9", subject="Physics", title="Sound Waves Chapter Test & Revision", objective="Complete a source-backed final chapter test and turn remaining mistakes into targeted revision.", prerequisites=tuple(_sw(f"M{index}") for index in range(1, 9)), exam_importance=1.0, typical_misconceptions=( "Knowing isolated definitions without connecting the formula, condition and application.", ), practice_types=("revision", "chapter_test", "numerical", "board_answer"), estimated_minutes=40, resources=_SW_NUMERIC_RESOURCES, textbook_reference=_SW_REFERENCE, ), ) _CONCEPTS_BY_KEY: dict[str, ConceptNode] = {node.concept_key: node for node in SOUND_WAVES_CONCEPTS} _CONCEPTS_BY_CHAPTER: dict[str, tuple[ConceptNode, ...]] = {SOUND_WAVES_CHAPTER_ID: SOUND_WAVES_CONCEPTS} def concept_for(topic_key: str) -> ConceptNode | None: return _CONCEPTS_BY_KEY.get(topic_key) def concepts_for_chapter(chapter_catalog_id: str) -> tuple[ConceptNode, ...]: return _CONCEPTS_BY_CHAPTER.get(chapter_catalog_id, ()) def all_concepts() -> tuple[ConceptNode, ...]: return SOUND_WAVES_CONCEPTS def prerequisites_met(concept: ConceptNode, secure_or_developing_keys: set[str]) -> bool: """A concept may be scheduled as NEW learning only when every prerequisite has at least been introduced/learned (present in the provided key set).""" return all(prerequisite in secure_or_developing_keys for prerequisite in concept.prerequisites) def ordered_chapter_concepts(chapter_catalog_id: str) -> list[ConceptNode]: """Concepts in a safe teaching order (prerequisites before dependants). The seed data is already authored in teaching order (M1..M9); this sorts defensively anyway so data edits cannot silently break ordering. """ nodes = list(concepts_for_chapter(chapter_catalog_id)) placed: list[ConceptNode] = [] placed_keys: set[str] = set() remaining = nodes[:] while remaining: progressed = False for node in list(remaining): if all(prerequisite in placed_keys for prerequisite in node.prerequisites): placed.append(node) placed_keys.add(node.concept_key) remaining.remove(node) progressed = True if not progressed: # cycle in data — fail safe by appending as-authored placed.extend(remaining) break return placed def concept_to_dict(node: ConceptNode) -> dict[str, object]: return { "concept_key": node.concept_key, "chapter_catalog_id": node.chapter_catalog_id, "mission_id": node.mission_id, "subject": node.subject, "title": node.title, "objective": node.objective, "prerequisites": list(node.prerequisites), "exam_importance": node.exam_importance, "typical_misconceptions": list(node.typical_misconceptions), "practice_types": list(node.practice_types), "estimated_minutes": node.estimated_minutes, "resources": { "lesson": node.resources.lesson, "quiz": node.resources.quiz, "flashcards": node.resources.flashcards, "board_answer": node.resources.board_answer, "numericals": node.resources.numericals, "pyq": node.resources.pyq, }, "availability": node.availability, "textbook_reference": node.textbook_reference, }