Build a Queryable Kinesiology Graph

Map kinesiology as a typed knowledge graph in Python, then query and quiz it.

Introduction

30 Second Summary

Every complex field has a structure hiding underneath its vocabulary. Kinesiology covers anatomy, physiology, biomechanics, motor control, and career paths, but most learners encounter these as disconnected chapters in a textbook with no visible thread between them.

In this project, you will build a typed knowledge graph that connects the entire field of kinesiology into one queryable structure using Python and NetworkX. You will prove it with passing tests, then generate a quiz from the graph itself and answer it cold.

What You'll Build

Picture typing a question into your terminal and getting back a four-node chain that traces exactly how a muscle fiber produces a jump height, crossing every layer of kinesiology in order.

By the end of this project, you'll have:

  • A terminal-queryable directed graph where you ask "what is the shortest path from a muscle fiber to a jump height?" and get back a traced chain crossing anatomy, physiology, biomechanics, and motor control in order.
  • A passing pytest suite that proves graph integrity and confirms your three queries return their exact expected answers, with wrong answers named and excluded.
  • A 20-card quiz generated from the graph's own structure, answered cold with a scored result, and exported as CSV ready for any spaced repetition tool.
  • Secret Mission: a coverage comparison that maps your graph's sub-discipline nodes against a named survey textbook's chapter structure.

Are there any prerequisites?

You need basic Python familiarity (dicts, list comprehensions, and running scripts from the terminal). No kinesiology background is required. The graph teaches you the field as you build it.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. Kinesiology sits across anatomy, physiology, biomechanics, motor control, psychology, and career pathways. A reading list gives you terms in whatever order you encountered them. A graph gives you one queryable structure where every term has a typed relationship to its neighbours.

Set Up the Project and Declare the Schema

Without a fixed vocabulary, every relationship you add to a graph is a feeling about a connection rather than a queryable claim. The schema is the constraint that makes the graph useful.

In this step, you will set up a Python project, declare the exact node types and edge types your graph allows, and populate a YAML data file with every node and edge the graph needs to answer three target queries.

In this step, get ready to:
  • Install Python dependencies and initialize the project repository.
  • Author a schema file locking 6 node types and 8 edge types.
  • Populate the graph data YAML with all nodes and edges for three target queries.
Install Python dependencies and initialize the project repository

This project requires Python 3.11 or higher because NetworkX 3.6.1 depends on it.

  • Check your Python version by running this command:
python3 --version

✔️ I see version 3.11 or higher

Your Python is ready. Move on to the next instruction below.

ⓧ I see an older version

Your system has Python installed but the version is below 3.11. macOS often ships with an older Python by default, so running python3 may resolve to an outdated version even after installing a newer one.

  • Download and install Python 3.11+ from the official site: https://www.python.org/downloads/.
  • After installing, run python3.11 --version (or python3.12 --version) to confirm the new version is available.
  • If your system still defaults to the old version, use python3.11 explicitly for all commands in this project instead of python3.

ⓧ Command not found

Python is not installed on your system.

  • Download and install Python 3.11+ from the official site: https://www.python.org/downloads/.
  • Run through the installer with default settings.
  • Close and reopen your terminal, then run python3 --version again to confirm.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialize a Git repository by running these commands:
mkdir kinesiology-graph
cd kinesiology-graph
git init
  • Create a virtual environment and activate it by running:

macOS

python3 -m venv venv
source venv/bin/activate

Windows

python3 -m venv venv
.\venv\Scripts\activate

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay local to this project and do not conflict with other Python projects on your machine.

  • Install the three project dependencies by running:
pip install networkx==3.6.1 pyyaml==6.0.3 pytest==9.1.1
  • Confirm the packages installed correctly by running:
python -c "import networkx; import yaml; print(networkx.__version__)"

You should see 3.6.1 printed in your terminal.

Seeing an import error?

  • Make sure your virtual environment is activated. You should see (venv) at the beginning of your terminal prompt.
  • If the pip install command failed earlier, re-run it and check for network errors.

Help me troubleshoot my Python package installation.

  • Create a requirements.txt file to record your dependencies by running:
printf 'networkx==3.6.1\npyyaml==6.0.3\npytest==9.1.1\n' > requirements.txt
  • Create the empty placeholder files for the rest of the project by running:
touch loader.py queries.py quiz.py test_integrity.py test_queries.py schema.yaml graph_data.yaml
  • Confirm all files exist by running:
ls

You should see graph_data.yaml, loader.py, queries.py, quiz.py, requirements.txt, schema.yaml, test_integrity.py, test_queries.py, and a venv folder.

Author the schema file that locks the closed vocabulary

The schema declares which node types and edge types are valid in your graph. Any node or edge that falls outside this vocabulary is a mistake, not a creative choice.

  • Open schema.yaml in your editor and paste the following content:
node_types:
  - subdiscipline
  - layer
  - construct
  - method
  - principle
  - career_route

edge_types:
  - underlies
  - builds_on
  - measures
  - computes
  - models
  - applies_in
  - requires_credential
  - forks_into

node_attributes:
  layer:
    required:
      - level
  method:
    required:
      - mode
  career_route:
    required:
      - route_type

What does this schema do?

  • The 6 node_types define what kinds of things can exist in the graph: sub-disciplines, layers in the anatomy-to-motor-control chain, measurable constructs, measurement methods, modelling principles, and career routes.
  • The 8 edge_types define what relationships are allowed between nodes. An edge typed outside this list is a feeling, not a queryable claim.
  • The node_attributes section enforces that certain node types carry required fields. A layer must have a level for ordering. A method must declare its mode (measured or computed).
  • Save schema.yaml.
  • Verify the file loads cleanly by running:
python -c "import yaml; s = yaml.safe_load(open('schema.yaml')); print(len(s['node_types']), 'node types,', len(s['edge_types']), 'edge types')"

You should see 6 node types, 8 edge types printed.

Seeing a YAML parse error?

  • Check your indentation. YAML uses spaces, not tabs. Each list item should be indented with exactly 2 spaces.
  • Make sure there are no trailing characters after the last line.

Help me fix my schema.yaml file.

Populate the graph data file with nodes and edges

Now you will fill graph_data.yaml with every node and edge the graph needs. This file is the raw knowledge structure that the loader will read into a NetworkX DiGraph in the next step.

  • Open graph_data.yaml and paste the sub-discipline and layer nodes:
nodes:
  # Sub-disciplines
  - id: biomechanics
    type: subdiscipline
    label: Biomechanics
  - id: exercise_physiology
    type: subdiscipline
    label: Exercise Physiology
  - id: motor_control_and_learning
    type: subdiscipline
    label: Motor Control and Learning
  - id: sport_exercise_psychology
    type: subdiscipline
    label: Sport and Exercise Psychology
  - id: athletic_training
    type: subdiscipline
    label: Athletic Training

What are these sub-disciplines?

These five are the core sub-disciplines of kinesiology as defined by the American Kinesiology Association curriculum. Each one will connect to a different layer or career route through typed edges later.

  • Save graph_data.yaml.
  • Below the sub-disciplines (still inside the nodes: list), add the layer nodes that form the central chain:
  # Layers (the chain)
  - id: muscle_fiber
    type: layer
    label: Muscle Fiber
    level: 1
    domain: anatomy
  - id: motor_unit_recruitment
    type: layer
    label: Motor Unit Recruitment
    level: 2
    domain: physiology
  - id: ground_reaction_force
    type: layer
    label: Ground Reaction Force
    level: 3
    domain: biomechanics
  - id: jump_height
    type: layer
    label: Jump Height
    level: 4
    domain: motor_control

What is the chain?

These four nodes represent the anatomy-to-physiology-to-biomechanics-to-motor-control chain. The level attribute orders them so you can query the shortest path from a muscle fiber to a jump height and get exactly four nodes crossing every layer in sequence.

  • Save graph_data.yaml and verify it parses by running:
python -c "import yaml; d = yaml.safe_load(open('graph_data.yaml')); print(len(d['nodes']), 'nodes')"

You should see 9 nodes printed (5 sub-disciplines + 4 layers).

  • Below the layers, add the construct nodes and the first group of measurement method nodes:
  # Constructs
  - id: joint_moment
    type: construct
    label: Joint Moment
  - id: maximal_oxygen_uptake
    type: construct
    label: Maximal Oxygen Uptake (VO2max)
  - id: force
    type: construct
    label: Force
  - id: torque
    type: construct
    label: Torque

  # Methods (measured)
  - id: force_plate
    type: method
    label: Force Plate
    mode: measured
  - id: instrumented_treadmill
    type: method
    label: Instrumented Treadmill
    mode: measured
  - id: load_cell
    type: method
    label: Load Cell / Force Transducer
    mode: measured
  - id: handheld_dynamometer
    type: method
    label: Hand-Held Dynamometer
    mode: measured
  - id: isokinetic_dynamometer
    type: method
    label: Isokinetic Dynamometer
    mode: measured

Why do methods carry a mode attribute?

The mode: measured attribute distinguishes instruments that directly capture a physical quantity from computed methods that derive values from other inputs. This distinction lets you later query "which methods measure force" and provably exclude anything that computes rather than measures.

  • Save graph_data.yaml.
  • Below the isokinetic dynamometer, add the remaining method nodes (these are the ones that do NOT measure force, plus computed and study-only methods):
  - id: surface_emg
    type: method
    label: Surface Electromyography
    mode: measured
    records: muscle_electrical_activity
  - id: pressure_insoles
    type: method
    label: Pressure Insoles
    mode: measured
    records: plantar_pressure
  - id: marker_motion_capture
    type: method
    label: Marker-Based Motion Capture
    mode: measured
    records: kinematics

  # Methods (computed)
  - id: inverse_dynamics
    type: method
    label: Inverse Dynamics
    mode: computed

  # Methods (study-only procedures)
  - id: vo2max_test
    type: method
    label: Maximal Oxygen Uptake Test
    mode: measured
    study_only: true
  - id: one_rep_max_test
    type: method
    label: One-Repetition Maximum Test
    mode: measured
    study_only: true

What does study_only mean?

Nodes with study_only: true name procedures performed on a real body that require health screening and qualified supervision. The graph stores them as study material only. Your integrity tests will later verify that at least two such nodes exist.

  • Save graph_data.yaml and verify the growing node count:
python -c "import yaml; d = yaml.safe_load(open('graph_data.yaml')); print(len(d['nodes']), 'nodes')"

You should see 24 nodes printed.

  • Below the study-only methods, add the principles, career routes, and credential nodes to complete the nodes section:
  # Principles
  - id: hill_muscle_model
    type: principle
    label: Hill Muscle Model
  - id: force_velocity_relationship
    type: principle
    label: Force-Velocity Relationship
  - id: size_principle
    type: principle
    label: Size Principle (Henneman)
  - id: fitts_law
    type: principle
    label: "Fitts's Law"

  # Career routes
  - id: research_route
    type: career_route
    label: Research Route
    route_type: research
  - id: practice_route
    type: career_route
    label: Practice Route
    route_type: practice

  # Credentials
  - id: funded_doctorate
    type: construct
    label: Funded Doctorate
  - id: relevant_degree
    type: construct
    label: Relevant Degree
  - id: professional_certification
    type: construct
    label: Professional Certification
    note: A credential, not a license. Licensure and scope set by jurisdiction.

Why are credentials typed as constructs?

Credentials like funded_doctorate and professional_certification are measurable things a career route requires. They fit the construct type because they are observable qualifications, not sub-disciplines or methods.

  • Save graph_data.yaml and verify the complete node count:
python -c "import yaml; d = yaml.safe_load(open('graph_data.yaml')); print(len(d['nodes']), 'nodes')"

You should see 31 nodes printed. All nodes are in place.

Now you will add the edges that connect these nodes into a queryable structure.

  • Below the last node, add the edges: section starting with the chain and force-measurement edges:
edges:
  # The chain (anatomy -> physiology -> biomechanics -> motor control)
  - source: muscle_fiber
    target: motor_unit_recruitment
    type: underlies
  - source: motor_unit_recruitment
    target: ground_reaction_force
    type: underlies
  - source: ground_reaction_force
    target: jump_height
    type: underlies

  # Methods measuring force
  - source: force_plate
    target: force
    type: measures
    quantity: force
  - source: instrumented_treadmill
    target: force
    type: measures
    quantity: force
  - source: load_cell
    target: force
    type: measures
    quantity: force
  - source: handheld_dynamometer
    target: force
    type: measures
    quantity: force

Why do edges carry a quantity attribute?

The quantity field on measures edges lets you distinguish methods that measure the same construct node but for different physical quantities. Pressure insoles connect to the force node but their quantity is plantar_pressure, not force. This is how the graph provably excludes them from the "methods measuring force" query.

  • Save graph_data.yaml.
  • Below the force-measurement edges, add the torque, exclusion-method, computed, and study-only edges:
  # Method measuring torque
  - source: isokinetic_dynamometer
    target: torque
    type: measures
    quantity: torque

  # Methods that do NOT measure force (typed correctly to exclude them)
  - source: surface_emg
    target: muscle_fiber
    type: measures
    quantity: muscle_electrical_activity
  - source: pressure_insoles
    target: force
    type: measures
    quantity: plantar_pressure
  - source: marker_motion_capture
    target: jump_height
    type: measures
    quantity: kinematics

  # Computed method
  - source: inverse_dynamics
    target: joint_moment
    type: computes

  # Study-only methods
  - source: vo2max_test
    target: maximal_oxygen_uptake
    type: measures
    quantity: oxygen_uptake
  - source: one_rep_max_test
    target: force
    type: measures
    quantity: maximal_strength
  • Save graph_data.yaml and verify the edges are loading:
python -c "import yaml; d = yaml.safe_load(open('graph_data.yaml')); print(len(d['edges']), 'edges')"

You should see 14 edges printed.

  • Below the study-only edges, add the principle-modelling and career-route edges:
  # Principles modelling constructs
  - source: hill_muscle_model
    target: muscle_fiber
    type: models
  - source: force_velocity_relationship
    target: force
    type: models
  - source: size_principle
    target: motor_unit_recruitment
    type: models
  - source: fitts_law
    target: jump_height
    type: models

  # Career routes
  - source: research_route
    target: funded_doctorate
    type: requires_credential
  - source: practice_route
    target: relevant_degree
    type: requires_credential
  - source: practice_route
    target: professional_certification
    type: requires_credential
  • Save graph_data.yaml.
  • Below the career-route edges, add the final group of edges connecting sub-disciplines to layers and career forks:
  # Sub-disciplines apply in layers
  - source: biomechanics
    target: ground_reaction_force
    type: applies_in
  - source: exercise_physiology
    target: motor_unit_recruitment
    type: applies_in
  - source: motor_control_and_learning
    target: jump_height
    type: applies_in
  - source: sport_exercise_psychology
    target: motor_control_and_learning
    type: applies_in
  - source: athletic_training
    target: practice_route
    type: forks_into

  # The honest fork
  - source: biomechanics
    target: research_route
    type: forks_into
  - source: biomechanics
    target: practice_route
    type: forks_into

What is the honest fork?

Biomechanics forks_into both the research route and the practice route. This models the real career choice: after studying biomechanics, you can pursue a funded doctorate (research) or a professional certification (practice). The query in Step 4 will return both paths.

  • Save graph_data.yaml.
  • Before you run the final count, predict: how many total edges should your graph contain?
python -c "import yaml; d = yaml.safe_load(open('graph_data.yaml')); print(len(d['nodes']), 'nodes,', len(d['edges']), 'edges')"

You should see 31 nodes, 28 edges printed. Your complete knowledge structure is ready to load into a graph.

Seeing fewer nodes or edges than expected?

  • Check for duplicate nodes: or edges: keys. YAML only keeps the last occurrence if you accidentally repeat a top-level key.
  • Verify each list item starts with - (dash space) at the correct indentation (2 spaces under the parent key).
  • Make sure quotes surround the value "Fitts's Law" because it contains an apostrophe.

Help me find the issue in my graph_data.yaml file.

✔️ Awesome, I've got everything!

Great. Double check you have saved both schema.yaml and graph_data.yaml.

ⓧ I'd like to double check the full code

Scroll up through your work and confirm these sections are present in graph_data.yaml, in order:

  • The nodes: key with sub-discipline nodes (5 entries).
  • Layer nodes (4 entries, each with level and domain).
  • Construct nodes (4 entries).
  • Method nodes: 5 measured force/torque methods, 3 measured exclusion methods with records attributes, 1 computed method, 2 study-only methods.
  • Principle nodes (4 entries).
  • Career route nodes (2 entries with route_type).
  • Credential nodes (3 entries typed as construct).
  • The edges: key with 28 total edges across all groups.

Run the count command again to confirm 31 nodes, 28 edges.

Your environment is locked in and the vocabulary is declared. Next up, you will write the loader that turns these YAML files into a live NetworkX DiGraph you can query from the terminal.

Load the Graph and Run It Broken

Your schema and data files are written, but right now they are just text sitting on disk. A YAML file full of nodes means nothing until it loads into a DiGraph you can query from Python.

In this step you will build the loader, write integrity tests that enforce the closed vocabulary, and then deliberately break the graph. The tests will catch the flaw. That failure is the point: it proves your checks are real, not decorative.

In this step, get ready to:
  • Write the loader module that reads both YAML files and builds a NetworkX DiGraph.
  • Write an integrity test suite that checks for orphan nodes, out-of-vocabulary edges, and disconnected islands.
  • Seed a deliberate flaw and watch the tests catch it.
Write the loader module

The loader has two jobs: read the schema to know what vocabulary is allowed, and read the graph data to build the actual DiGraph object.

  • Open loader.py in your editor and add the module docstring, imports, and the load_schema function:
"""Load the kinesiology graph from YAML into a NetworkX DiGraph."""

import yaml
import networkx as nx
from pathlib import Path


def load_schema(schema_path: Path = Path("schema.yaml")) -> dict:
    """Load and return the schema defining valid node and edge types."""
    with open(schema_path, "r", encoding="utf-8") as f:
        return yaml.safe_load(f)

What does this code do?

  • The imports bring in yaml for parsing YAML files, networkx for the graph data structure, and Path for cross-platform file paths.
  • load_schema reads schema.yaml and returns it as a Python dictionary. This dictionary holds the closed vocabulary your graph must obey.
  • Below load_schema, add the load_graph function that builds the DiGraph:
def load_graph(
    data_path: Path = Path("graph_data.yaml"),
    schema_path: Path = Path("schema.yaml"),
) -> nx.DiGraph:
    """Load graph data from YAML into a validated NetworkX DiGraph."""
    schema = load_schema(schema_path)
    valid_node_types = set(schema["node_types"])
    valid_edge_types = set(schema["edge_types"])

    with open(data_path, "r", encoding="utf-8") as f:
        data = yaml.safe_load(f)

    G = nx.DiGraph()

    for node in data["nodes"]:
        node_id = node["id"]
        attrs = {k: v for k, v in node.items() if k != "id"}
        G.add_node(node_id, **attrs)

    for edge in data["edges"]:
        source = edge["source"]
        target = edge["target"]
        attrs = {k: v for k, v in edge.items() if k not in ("source", "target")}
        G.add_edge(source, target, **attrs)

    return G

What does this code do?

  • load_graph opens graph_data.yaml, iterates through every node entry, and calls G.add_node(node_id, **attrs) to attach all YAML attributes (type, label, level, mode) directly to the node.
  • Edges are added the same way. Each edge carries its type attribute plus any extras like quantity.
  • The schema is loaded but not enforced here. Enforcement happens in the test suite, so the loader stays simple and the tests stay authoritative.
  • Save loader.py and verify the graph loads by running:
python -c "from loader import load_graph; G = load_graph(); print(G.number_of_nodes(), 'nodes,', G.number_of_edges(), 'edges')"

You should see 30 nodes, 30 edges printed. That confirms the YAML parsed cleanly and every entry landed in the graph.

Seeing a FileNotFoundError?

  • Make sure you are running the command from inside your project directory (the folder containing schema.yaml and graph_data.yaml).
  • Check that graph_data.yaml is spelled exactly as written, with an underscore.
  • help me debug a FileNotFoundError when loading my YAML graph
  • Now add the helper functions and the __main__ block at the bottom of loader.py:
def get_valid_edge_types(schema_path: Path = Path("schema.yaml")) -> set:
    """Return the set of valid edge types from the schema."""
    schema = load_schema(schema_path)
    return set(schema["edge_types"])


def get_valid_node_types(schema_path: Path = Path("schema.yaml")) -> set:
    """Return the set of valid node types from the schema."""
    schema = load_schema(schema_path)
    return set(schema["node_types"])


if __name__ == "__main__":
    G = load_graph()
    print(f"Loaded graph: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
    print(f"Node types present: {set(d['type'] for _, d in G.nodes(data=True))}")
    edge_types = set(d["type"] for _, _, d in G.edges(data=True))
    print(f"Edge types present: {edge_types}")

What does this code do?

  • get_valid_edge_types and get_valid_node_types extract the allowed vocabulary from the schema. The test suite will use these to check every edge and node against the closed list.
  • The __main__ block prints a diagnostic summary so you can run python loader.py anytime to confirm the graph loaded.
  • Save loader.py and run the full diagnostic:
python loader.py

You should see the node count, the set of 6 node types, and the set of edge types present in your graph.

✔️ Awesome, I've got everything!

Great. Double check you have saved loader.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete loader.py at this point. Scroll up and confirm each section is present in order: imports, load_schema, load_graph, get_valid_edge_types, get_valid_node_types, and the __main__ block.

Write the integrity test suite

The loader does not enforce the schema. That is the job of the test suite. Each test names what it expects and what a wrong answer would mean, so a failure is never ambiguous.

  • Open test_integrity.py and add the module docstring, imports, fixtures, and the first test:
"""Integrity tests for the kinesiology graph.

Each test names its expected value and what a wrong answer means.
"""

import pytest
from loader import load_graph, get_valid_edge_types, get_valid_node_types
import networkx as nx


@pytest.fixture
def graph():
    return load_graph()


@pytest.fixture
def valid_edge_types():
    return get_valid_edge_types()


@pytest.fixture
def valid_node_types():
    return get_valid_node_types()


def test_no_orphan_nodes(graph):
    """Every node must have at least one edge (in or out).

    Expected: zero orphan nodes.
    Wrong answer means: a term was collected but never placed in the structure.
    """
    orphans = [n for n in graph.nodes() if graph.degree(n) == 0]
    assert orphans == [], f"Orphan nodes found (collected, never placed): {orphans}"

What does this code do?

  • The three @pytest.fixture functions provide the loaded graph and vocabulary sets to every test that asks for them by parameter name.
  • test_no_orphan_nodes checks that every node has at least one connection. A node with zero edges is a term you collected but never placed in any relationship.
  • Below the first test, add the vocabulary and reachability checks:
def test_all_edges_in_vocabulary(graph, valid_edge_types):
    """Every edge type must be in the schema's closed vocabulary.

    Expected: zero out-of-vocabulary edge types.
    Wrong answer means: an edge is a feeling about a relationship, not a queryable claim.
    """
    invalid = []
    for u, v, data in graph.edges(data=True):
        edge_type = data.get("type")
        if edge_type not in valid_edge_types:
            invalid.append((u, v, edge_type))
    assert invalid == [], f"Out-of-vocabulary edges: {invalid}"


def test_all_nodes_reachable(graph):
    """The graph must be weakly connected (every node reachable from some other).

    Expected: exactly one weakly connected component.
    Wrong answer means: the graph has islands that cannot be traversed.
    """
    undirected = graph.to_undirected()
    components = list(nx.connected_components(undirected))
    assert len(components) == 1, (
        f"Graph has {len(components)} components. "
        f"Islands: {[c for c in components if len(c) < len(max(components, key=len))]}"
    )

What does this code do?

  • test_all_edges_in_vocabulary loops through every edge and checks its type attribute against the 8 allowed edge types from your schema. An edge typed outside the vocabulary is a feeling, not a queryable claim.
  • test_all_nodes_reachable converts the directed graph to undirected, then checks for a single connected component. If there are islands, some nodes cannot be reached from any other.
  • Save test_integrity.py and run the first three tests to see them pass on the clean graph:
pytest test_integrity.py -v

You should see 3 passed. All three checks hold because graph_data.yaml has no orphans, no bad edge types, and no islands right now.

Seeing an ImportError?

  • Confirm your virtual environment is activated. The pytest and networkx packages live inside it.
  • Make sure loader.py is saved in the same directory as test_integrity.py.
  • help me debug an ImportError in my pytest integrity tests
  • Add the final two tests at the bottom of test_integrity.py:
def test_all_node_types_in_vocabulary(graph, valid_node_types):
    """Every node type must be in the schema's closed vocabulary.

    Expected: zero out-of-vocabulary node types.
    """
    invalid = []
    for node, data in graph.nodes(data=True):
        node_type = data.get("type")
        if node_type not in valid_node_types:
            invalid.append((node, node_type))
    assert invalid == [], f"Out-of-vocabulary node types: {invalid}"


def test_study_only_nodes_exist(graph):
    """At least two nodes must carry study_only=True.

    These name procedures on a real body that require screening and supervision.
    """
    study_only = [n for n, d in graph.nodes(data=True) if d.get("study_only")]
    assert len(study_only) >= 2, (
        f"Expected at least 2 study-only nodes, found {len(study_only)}: {study_only}"
    )

What does this code do?

  • test_all_node_types_in_vocabulary mirrors the edge-vocabulary check but for nodes. Any node typed outside the 6 allowed types is invalid.
  • test_study_only_nodes_exist asserts that at least two nodes carry study_only: true. These name procedures on a real body that require health screening and supervision. The graph must flag them so no path-printing function outputs instructions for them.
  • Save test_integrity.py and run the full suite:
pytest test_integrity.py -v

You should see 5 passed. Every check holds on the clean graph.

✔️ Awesome, I've got everything!

Great. Double check you have saved test_integrity.py before moving on.

ⓧ I'd like to double check the full code

Scroll up through test_integrity.py and confirm these sections are present in order: module docstring, imports, three fixtures (graph, valid_edge_types, valid_node_types), then five test functions: test_no_orphan_nodes, test_all_edges_in_vocabulary, test_all_nodes_reachable, test_all_node_types_in_vocabulary, test_study_only_nodes_exist.

Seed a deliberate flaw and watch the tests catch it

The tests all pass, which means the graph is clean. But a test suite that has never failed is a test suite you cannot trust.

You will now add a deliberate orphan node to graph_data.yaml. This node has no edges, so it sits disconnected from the rest of the structure.

  • Open graph_data.yaml and find the last sub-discipline entry:
  - id: athletic_training
    type: subdiscipline
    label: Athletic Training
  • Directly below it, add this orphan node with the comment marking it as deliberate:
  # DELIBERATE FLAW: orphan_node has no edges
  - id: orphan_node
    type: subdiscipline
    label: Orphan Node (deliberate flaw)

Why seed a flaw on purpose?

A test that has never caught a real mistake gives you false confidence. By planting a known defect, you prove the test suite can actually catch errors before you rely on it to validate your graph.

  • Save graph_data.yaml.

Before you run the tests, predict: which test do you think will fail, and why?

  • Run the integrity suite:
pytest test_integrity.py -v

You should see at least one FAILED test. The test_no_orphan_nodes test catches orphan_node because it has zero edges. The test_all_nodes_reachable test also fails because the orphan creates a second connected component.

What does this failure prove?

The test output names exactly which node is orphaned and which component is disconnected. A graph that passes integrity is a structure you can trust to answer questions. A graph that fails tells you exactly where it broke.

In the next step, you will fix this flaw and watch the full suite go green.

All tests still passing?

  • Make sure you saved graph_data.yaml after adding the orphan node.
  • Check that the orphan node entry is inside the nodes: list (indented with two spaces like the other entries) and that you did NOT add any edges referencing orphan_node.
  • help me figure out why my orphan node flaw is not being caught by the tests

Your integrity checks are real. They caught a planted defect on the first run. Next up, you will fix the flaw, add the study-only flag validation, and watch the entire suite go green.

Fix the Graph and Prove Integrity

In the last step, you ran the integrity tests and watched them catch the deliberate flaw. The graph has an orphan node sitting disconnected from everything else.

A graph that passes integrity is a structure you can trust to answer questions. A graph with orphans and misspelled edges is a pile of terms with arrows drawn after the fact. In this step, you fix the flaw and prove the full suite goes green.

In this step, get ready to:
  • Remove the deliberate orphan node from the graph data.
  • Confirm the study-only flag validation passes.
  • Run the full integrity suite green and commit.
Remove the orphan node from the graph data

The test output from Step 2 told you exactly which node is orphaned: orphan_node. It has no edges, which means it was collected as a term but never placed in the structure.

  • In graph_data.yaml, find this block near the top of the nodes section:
  # DELIBERATE FLAW: orphan_node has no edges
  - id: orphan_node
    type: subdiscipline
    label: Orphan Node (deliberate flaw)
  • Delete these four lines entirely (the comment and the three attribute lines).
  • Save graph_data.yaml.

After removal, the sub-disciplines section should go straight from athletic_training to the # Layers (the chain) comment.

  • Verify the orphan is gone by running just the orphan test:
pytest test_integrity.py -k test_no_orphan_nodes

You should see 1 passed in the output. The node that was collected but never placed is gone.

Still seeing orphan nodes?

Make sure you deleted all four lines, including the comment line starting with #. Check that you did not accidentally delete the athletic_training node above it.

help me debug why my orphan test is still failing

Confirm the study-only flag validation

Two nodes in your graph name procedures performed on a real body: vo2max_test and one_rep_max_test. Both carry study_only: true in the YAML.

What does study_only mean?

These nodes represent testing protocols that involve a real human body. A VO2max test pushes someone to maximal exertion. A one-rep max test loads them to structural limit. Both require health screening, qualified supervision, and clinician oversight.

The study_only flag marks these nodes so that when you write query functions in the next step, the graph prints a screening note instead of instructions. The integrity test enforces that at least two such nodes exist.

  • Run the study-only test to confirm the flags are in place:
pytest test_integrity.py -k test_study_only_nodes_exist

You should see 1 passed. The graph carries at least two nodes that require screening before anyone acts on them.

Run the full integrity suite and commit

Individual tests pass. Now the question is whether the full suite agrees.

  • Before you run this, predict: will all five tests pass, or will one still fail? Which one might trip?
  • Run the full integrity suite:
pytest test_integrity.py

You should see 5 passed with zero warnings. Every node has edges. Every edge type is in the closed vocabulary. The graph is weakly connected. All node types are valid. At least two study-only nodes exist.

What did the five tests just prove?

  • test_no_orphan_nodes: zero terms collected but never placed.
  • test_all_edges_in_vocabulary: every relationship is a queryable claim, not a feeling.
  • test_all_nodes_reachable: no islands. You can traverse from any node to any other.
  • test_all_node_types_in_vocabulary: every node's type matches the schema's six valid types.
  • test_study_only_nodes_exist: at least two nodes flag real-body procedures that require screening.

Seeing a failure on reachability?

The test_all_nodes_reachable test checks weak connectivity. If it fails, you have a node that cannot be reached from any other node even when ignoring edge direction. Check that every construct, principle, and credential node has at least one edge pointing to or from it.

help me find the disconnected island in my graph

  • Commit the passing state by running these commands:
git add .
git commit -m "graph passes integrity"

You should see the commit confirmation with your changed files listed.

✔️ Awesome, I've got everything!

Confirm you have saved graph_data.yaml with the orphan node removed and your commit is recorded.

ⓧ I'd like to double check the full code

The only file that changed in this step is graph_data.yaml. Scroll to the sub-disciplines section and confirm it looks like this (no orphan_node entry):

nodes:
  # Sub-disciplines
  - id: biomechanics
    type: subdiscipline
    label: Biomechanics
  - id: exercise_physiology
    type: subdiscipline
    label: Exercise Physiology
  - id: motor_control_and_learning
    type: subdiscipline
    label: Motor Control and Learning
  - id: sport_exercise_psychology
    type: subdiscipline
    label: Sport and Exercise Psychology
  - id: athletic_training
    type: subdiscipline
    label: Athletic Training

  # Layers (the chain)
  - id: muscle_fiber
    type: layer
    label: Muscle Fiber
    level: 1
    domain: anatomy

The rest of the file (layers, constructs, methods, principles, career routes, credentials, and all edges) is unchanged from Step 2. Confirm your file has no other additions or deletions.

Your graph is now a validated structure. Every node is placed, every edge is typed from the closed vocabulary, and the whole thing is one connected component. Next up, you will write expected answers for three queries and then run them against this trusted graph.

Write Expected Answers, Then Run the Queries

Your graph passes integrity. Zero orphan nodes, zero out-of-vocabulary edges, full reachability. That means you can trust it to answer questions.

But there is a trap. A query you interpret after seeing its output can never fail. You will always find a way to explain what came back. Writing the expected answer first turns the query into a test: if the graph returns something different, the graph is wrong, not your interpretation.

In this step, get ready to:
  • Write expected answers as pytest fixtures before any query code exists.
  • Implement three query functions that interrogate the graph.
  • Run the test suite and verify the graph returns exactly what you predicted.
Write the expected answers as pytest fixtures

You will declare the exact values you expect from three queries before writing a single line of query code. If the graph later returns something different, you fix the graph, not the expected answer.

Why write expected answers first?

This is the test-driven pattern applied to knowledge: commit to what you believe is true, then verify. If you run the query first and then decide whether the output looks right, you will always convince yourself it does.

  • Open test_queries.py in your editor and add the imports, fixture, and first test class by pasting this code:
"""Query tests with expected answers written BEFORE the queries run."""

import pytest
from loader import load_graph
from queries import shortest_path_query, methods_measuring, route_difference


@pytest.fixture
def graph():
    return load_graph()


class TestShortestPath:
    EXPECTED_PATH = ["muscle_fiber", "motor_unit_recruitment", "ground_reaction_force", "jump_height"]

    def test_path_nodes(self, graph):
        path = shortest_path_query(graph, "muscle_fiber", "jump_height")
        assert path == self.EXPECTED_PATH

    def test_path_length(self, graph):
        path = shortest_path_query(graph, "muscle_fiber", "jump_height")
        assert len(path) == 4
        assert len(path) - 1 == 3

    def test_layers_in_order(self, graph):
        path = shortest_path_query(graph, "muscle_fiber", "jump_height")
        levels = [graph.nodes[n].get("level") for n in path]
        assert levels == [1, 2, 3, 4]

What does this test class declare?

  • EXPECTED_PATH commits to a 4-node chain: anatomy level 1, physiology level 2, biomechanics level 3, motor control level 4. If the path is shorter, a shortcut edge flattened the chain.
  • test_layers_in_order asserts the level attribute on each node increases from 1 to 4. If a level is missing, a node attribute is wrong.
  • Add the remaining two test classes below the TestShortestPath class:
class TestForceMethods:
    EXPECTED = {"force_plate", "instrumented_treadmill", "load_cell", "handheld_dynamometer"}
    MUST_BE_ABSENT = {"surface_emg", "pressure_insoles", "inverse_dynamics", "marker_motion_capture"}

    def test_force_methods_exact(self, graph):
        result = methods_measuring(graph, "force")
        assert result == self.EXPECTED

    def test_wrong_answers_absent(self, graph):
        result = methods_measuring(graph, "force")
        assert result & self.MUST_BE_ABSENT == set()

    def test_torque_adds_isokinetic_only(self, graph):
        assert methods_measuring(graph, "torque") == {"isokinetic_dynamometer"}


class TestRouteDifference:
    def test_research_route(self, graph):
        routes = route_difference(graph)
        assert routes["research_route"] == {"funded_doctorate"}

    def test_practice_route(self, graph):
        routes = route_difference(graph)
        assert routes["practice_route"] == {"relevant_degree", "professional_certification"}

    def test_certification_is_credential(self, graph):
        node_data = graph.nodes["professional_certification"]
        assert "credential" in node_data.get("note", "").lower()
        assert "not a license" in node_data.get("note", "").lower()

What do these test classes declare?

TestForceMethods declares exactly four methods that measure force. It also names four wrong answers that MUST be absent: surface_emg records electrical activity, pressure_insoles reports plantar pressure, inverse_dynamics computes (does not measure), and marker_motion_capture records kinematics only.

TestRouteDifference declares that the research route requires a funded doctorate, while the practice route requires a relevant degree plus professional certification. The final test asserts certification is a credential, not a license.

  • Save test_queries.py.

These tests cannot run yet because queries.py is still empty. The next substep implements the query functions they import.

✔️ Awesome, I've got everything!

Great. Double check you have saved test_queries.py with all three test classes.

ⓧ I'd like to double check the full code

Your test_queries.py should contain these sections in order. Scroll up and confirm each one is present:

  • The module docstring, imports (pytest, load_graph, and all three query functions), and the graph fixture.
  • TestShortestPath class with EXPECTED_PATH and three test methods.
  • TestForceMethods class with EXPECTED, MUST_BE_ABSENT, and three test methods.
  • TestRouteDifference class with three test methods including the credential assertion.
Implement the three query functions

Now you write the functions that your tests import. Each function asks the graph a specific question using NetworkX operations.

  • Open queries.py and add the module header, safety note, and the first query function:
"""Query functions for the kinesiology knowledge graph."""

import networkx as nx


# Screening note for nodes that name procedures on a real body
STUDY_ONLY_NOTE = (
    "This node names a procedure performed on a real body. "
    "Real testing requires health screening, qualified supervision, "
    "and clinician oversight where the person is at risk. "
    "This is study material only."
)


def shortest_path_query(G: nx.DiGraph, source: str, target: str) -> list[str]:
    # Use NetworkX shortest_path to traverse the directed chain
    path = nx.shortest_path(G, source=source, target=target)
    # Print a screening note if any node on the path is study-only
    for node in path:
        if G.nodes[node].get("study_only"):
            print(STUDY_ONLY_NOTE)
            break
    return path

What does shortest_path_query do?

  • nx.shortest_path(G, source, target) returns the shortest directed path as a list of node IDs. For our chain, that means following the underlies edges from anatomy through to motor control.
  • The study_only check ensures that any path touching a real-body procedure prints a screening note rather than instructions.
  • Save queries.py and verify the first function works by running this quick test:
python -c "from loader import load_graph; from queries import shortest_path_query; G = load_graph(); print(shortest_path_query(G, 'muscle_fiber', 'jump_height'))"

You should see ['muscle_fiber', 'motor_unit_recruitment', 'ground_reaction_force', 'jump_height'] printed. That is the four-node chain crossing every layer in order.

Seeing a NetworkXNoPath error?

  • This means no directed path exists between the source and target. Check that your graph_data.yaml has the three underlies edges connecting muscle_fiber to motor_unit_recruitment to ground_reaction_force to jump_height.
  • Remember that a DiGraph only follows edges in the defined direction. Make sure source points to target in each edge definition, not the other way around.
  • Why can't NetworkX find a path from muscle_fiber to jump_height?
  • Add the second query function below shortest_path_query in queries.py:
def methods_measuring(G: nx.DiGraph, quantity: str) -> set[str]:
    # Collect method nodes whose 'measures' edge carries the target quantity
    methods = set()
    for u, v, data in G.edges(data=True):
        if (
            data.get("type") == "measures"
            and data.get("quantity") == quantity
            and G.nodes[u].get("type") == "method"
            and G.nodes[u].get("mode") == "measured"
        ):
            methods.add(u)
    return methods

Why four conditions in the filter?

  • Edge type must be measures (excludes computes edges like inverse dynamics).
  • Edge quantity must match the argument (excludes pressure insoles whose quantity is plantar_pressure, not force).
  • Source node must be type method with mode measured (excludes inverse dynamics again, which is mode computed).
  • Add the third query function and the __main__ block below methods_measuring:
def route_difference(G: nx.DiGraph) -> dict[str, set[str]]:
    # Find career_route nodes and collect their required credentials
    routes = {}
    for node, data in G.nodes(data=True):
        if data.get("type") == "career_route":
            credentials = set()
            for _, target, edge_data in G.out_edges(node, data=True):
                if edge_data.get("type") == "requires_credential":
                    credentials.add(target)
            routes[node] = credentials
    return routes


if __name__ == "__main__":
    from loader import load_graph
    G = load_graph()
    print("=== Shortest Path ===")
    path = shortest_path_query(G, "muscle_fiber", "jump_height")
    print(f"Path: {path}")
    print("\n=== Methods Measuring Force ===")
    print(sorted(methods_measuring(G, "force")))
    print("\n=== Route Difference ===")
    for route, creds in route_difference(G).items():
        print(f"  {route}: {sorted(creds)}")

What does route_difference do?

It finds every node typed career_route, then follows its outgoing requires_credential edges to collect the credentials each route demands. The result is a dict mapping route IDs to sets of credential node IDs.

  • Save queries.py and run all three queries together by running:
python queries.py

You should see three sections printed: the 4-node path, a sorted list of four force-measuring methods, and the credentials for each career route.

Seeing an ImportError or empty output?

  • Make sure you are running the command from your project directory (the folder containing queries.py and loader.py).
  • If methods_measuring returns an empty set, check that your edges in graph_data.yaml have both type: measures and quantity: force on the four force-method edges.
  • Help me debug why my queries.py output is wrong or empty.

✔️ Awesome, I've got everything!

Great. Double check you have saved queries.py with all three functions and the __main__ block.

ⓧ I'd like to double check the full code

Your queries.py should contain these sections in order. Scroll up and confirm each one is present:

  • Module docstring, import networkx as nx, and the STUDY_ONLY_NOTE constant.
  • shortest_path_query function using nx.shortest_path with the study-only check.
  • methods_measuring function filtering edges by type, quantity, node type, and mode.
  • route_difference function using G.out_edges to collect credentials.
  • The if __name__ == "__main__" block printing all three query results.
Run the query tests and verify expected answers match

The expected answers are declared. The query functions are implemented. Now you run the tests and find out whether your graph actually returns what you predicted.

  • Before you run the tests, predict: do you think all nine assertions will pass on the first try? Which query is most likely to surprise you?
  • Run the full query test suite by running:
pytest test_queries.py

You should see all nine tests pass. The output ends with a line showing 9 passed in green.

What did passing tests just prove?

  • The shortest path from a muscle fiber to a jump height crosses exactly four nodes, one per layer, in level order.
  • Exactly four methods measure force. EMG, pressure insoles, inverse dynamics, and motion capture are provably excluded.
  • The research route requires a funded doctorate. The practice route requires a relevant degree plus professional certification (a credential, not a license).

One or more tests failing?

If a query returns the wrong answer, fix the graph data (add a missing edge, correct a type), not the expected answer. The expected answer is the claim. The graph is the evidence.

  • For path failures: check that each underlies edge in graph_data.yaml has the correct source and target direction.
  • For force-method failures: confirm that force-method edges have quantity: force (not a typo like Force or forces).
  • For route failures: confirm that practice_route has two outgoing requires_credential edges, not one.
  • Help me figure out why my query test is failing.
  • Once all nine tests pass, commit the working state by running:
git add . && git commit -m "query pack passes"

Your graph is now a verified source of truth. Every answer it gives has been checked against a prediction you committed to before seeing the output. Next up, you will generate quiz cards from this graph structure and answer them cold to prove you own the map.

Generate the Quiz, Run It Cold, and Export for Spaced Repetition

Your graph passes all integrity and query tests. You can ask it structured questions and get reliable, repeatable answers. But a structure you only access by running a Python script is a reference document, not internalized knowledge.

In this step, you'll generate quiz cards directly from the graph's edges and nodes, answer them cold without peeking at the data, and export the cards as CSV for ongoing spaced repetition.

In this step, get ready to:
  • Generate 20 quiz cards from graph structure.
  • Run the quiz cold and score 17/20 or better.
  • Export cards as CSV for spaced repetition import.
Build the quiz generator that creates cards from graph structure

Each card type maps to a different structural feature of the graph: edge-completion, path-tracing, type-membership, exclusion reasoning, career credentials, principles, and sub-discipline application.

  • Open quiz.py in your editor. Paste the following imports and the beginning of generate_cards:
"""Generate, run, and export a 20-card quiz from the kinesiology graph."""

import csv
import random
import sys
from pathlib import Path

from loader import load_graph
from queries import methods_measuring, shortest_path_query, route_difference


def generate_cards(G) -> list[dict]:
    cards = []

    for u, v, data in G.edges(data=True):
        if data.get("type") == "underlies":
            cards.append({
                "front": f"What does '{G.nodes[u].get('label', u)}' underlie in the chain?",
                "back": G.nodes[v].get("label", v),
                "source_node": u,
                "source_edge": "underlies",
                "tags": f"layer,chain,{G.nodes[u].get('domain', '')}",
            })

    random.shuffle(cards)
    return cards[:20]

What does this code do?

The function starts by iterating every edge of type underlies and building an edge-completion card for each one. The learner is asked to name what a given layer leads to in the chain.

The temporary shuffle + return at the bottom lets you run the function right now and see results, even though more card types are coming.

  • Save quiz.py and confirm the imports resolve by running:
python -c "from quiz import generate_cards; from loader import load_graph; print(len(generate_cards(load_graph())))"

You should see 3 printed. That is one card per underlies edge in the graph.

Seeing an ImportError?

Make sure you are running from the project directory that contains loader.py and queries.py. Also confirm your virtual environment is active.

Help me debug my quiz.py import error

Now you will add the remaining card types. Each type exercises a different graph-query skill.

  • In quiz.py, find the line random.shuffle(cards). Add the following code ABOVE that line to create path-tracing, type-membership, and exclusion cards:
    cards.append({
        "front": "What is the shortest path from Muscle Fiber to Jump Height? Name all nodes in order.",
        "back": "Muscle Fiber -> Motor Unit Recruitment -> Ground Reaction Force -> Jump Height",
        "source_node": "muscle_fiber",
        "source_edge": "underlies",
        "tags": "path,chain,anatomy,physiology,biomechanics,motor_control",
    })

    cards.append({
        "front": "Name the four methods that measure force (not torque, not pressure, not electrical activity).",
        "back": "Force plate, Instrumented treadmill, Load cell / Force transducer, Hand-held dynamometer",
        "source_node": "force",
        "source_edge": "measures",
        "tags": "method,force,biomechanics",
    })

    exclusions = [
        ("Surface EMG", "It records muscle electrical activity, not force."),
        ("Pressure insoles", "They report plantar pressure distribution, not force."),
        ("Inverse dynamics", "It computes joint kinetics from measured inputs, it does not measure force directly."),
        ("Marker-based motion capture", "It records kinematics (position and motion), not force."),
    ]
    for method, reason in exclusions:
        cards.append({
            "front": f"Why is {method} NOT in the set of methods measuring force?",
            "back": reason,
            "source_node": method.lower().replace(" ", "_").replace("-", "_"),
            "source_edge": "measures",
            "tags": "exclusion,force,method",
        })

What do these card types test?

  • The path-tracing card asks the learner to recall the full chain from anatomy to motor control.
  • The type-membership card tests whether the learner can name all four force-measuring methods from memory.
  • The exclusion cards test reasoning: why is surface EMG, pressure insoles, inverse dynamics, or motion capture NOT in the force set?
  • Still above random.shuffle(cards), add career-route, principle, and sub-discipline cards:
    cards.append({
        "front": "What credential does the research route require?",
        "back": "A funded doctorate.",
        "source_node": "research_route",
        "source_edge": "requires_credential",
        "tags": "career,research",
    })
    cards.append({
        "front": "What credentials does the practice route require?",
        "back": "A relevant degree plus professional certification. A certification is a credential, not a license.",
        "source_node": "practice_route",
        "source_edge": "requires_credential",
        "tags": "career,practice",
    })

    for u, v, data in G.edges(data=True):
        if data.get("type") == "models":
            cards.append({
                "front": f"What does the {G.nodes[u].get('label', u)} model?",
                "back": G.nodes[v].get("label", v),
                "source_node": u,
                "source_edge": "models",
                "tags": f"principle,{G.nodes[v].get('domain', '')}",
            })

    for u, v, data in G.edges(data=True):
        if data.get("type") == "applies_in" and G.nodes[u].get("type") == "subdiscipline":
            cards.append({
                "front": f"What does {G.nodes[u].get('label', u)} apply to in this graph?",
                "back": G.nodes[v].get("label", v),
                "source_node": u,
                "source_edge": "applies_in",
                "tags": f"subdiscipline,{u}",
            })

What do these card types test?

  • Career cards test whether the learner recalls what separates the research fork from the practice fork.
  • Principle cards ask what each named model (Hill, Force-Velocity, Size Principle, Fitts's Law) targets.
  • Sub-discipline cards ask where each field applies in the graph's layer structure.
  • Now add a __main__ block at the bottom of quiz.py (below the function, outside its indentation) so you can run the generator from the terminal:
if __name__ == "__main__":
    G = load_graph()
    cards = generate_cards(G)

    if "--generate" in sys.argv:
        for i, card in enumerate(cards, 1):
            print(f"[{i}] {card['front']}")
            print(f"    -> {card['back']}\n")
    else:
        print("Usage: python quiz.py --generate")

Before you run this, what do you think will happen? How many card types did you add?

  • Save quiz.py and generate your cards by running:
python quiz.py --generate

You should see 20 cards printed, each with a question on one line and the expected answer indented below it.

Seeing fewer than expected or a traceback?

Check that every card-appending section sits ABOVE the random.shuffle(cards) line. Code below the return never executes.

If you see an IndentationError, make sure all card-generation code is indented at the same level (4 spaces inside the function).

Help me fix my quiz card generation

Implement the cold-run scoring loop

Generating the cards proves they come from structure. Answering them cold, without the graph open, proves whether you internalized the map.

  • In quiz.py, add the run_cold function below generate_cards and above the if __name__ block:
def run_cold(cards: list[dict]) -> tuple[int, list[dict]]:
    score = 0
    misses = []
    print(f"\n{'='*60}")
    print("COLD RUN: 20 cards. Type your answer. Press Enter to skip.")
    print(f"{'='*60}\n")

    for i, card in enumerate(cards, 1):
        print(f"[{i}/20] {card['front']}")
        answer = input("Your answer: ").strip()
        print(f"  Expected: {card['back']}")
        correct = input("  Mark correct? (y/n): ").strip().lower() == "y"
        if correct:
            score += 1
        else:
            misses.append(card)
        print()

    print(f"\nFinal score: {score}/20")
    if misses:
        print(f"\nMisses ({len(misses)}):")
        for miss in misses:
            print(f"  - {miss['front']}")
            print(f"    Answer: {miss['back']}")
            print(f"    Source: {miss['source_node']} [{miss['source_edge']}]")
    return score, misses

What does the cold-run loop do?

For each card, the script prints the question, waits for your typed answer, then reveals the expected answer. You self-mark each card as correct or not. At the end, it prints your score and lists every miss with the graph node that sourced it.

Self-marking is deliberate. Some answers are paraphrases of the expected text. You judge whether your answer demonstrates the same structural understanding.

  • In the if __name__ block, find this line:
    else:
        print("Usage: python quiz.py --generate")
  • Replace it with the following to add the --cold flag:
    elif "--cold" in sys.argv:
        score, misses = run_cold(cards)
    else:
        print("Usage: python quiz.py [--generate | --cold]")
  • Save quiz.py and start the cold run:
python quiz.py --cold

The script prompts you with each card interactively. Type your best answer, check the expected answer, then mark yourself correct or not. Aim for 17/20 or better.

Take it seriously

Close your graph data file and any notes before starting. The point is to prove you internalized the structure, not that you can look it up. Misses are valuable. They show exactly which edges you have not yet committed to memory.

Script exits immediately without prompting?

Make sure you pass the --cold flag. Running python quiz.py without a flag prints the usage message.

Help me get the cold-run quiz working

Export the cards as CSV for spaced repetition

A cold-run score is one data point. For long-term retention, you need to feed the cards into a spaced repetition system that resurfaces misses at increasing intervals.

  • In quiz.py, add the export_csv function below run_cold and above the if __name__ block:
def export_csv(cards: list[dict], output_path: Path = Path("quiz_export.csv")):
    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=["front", "back", "source_node", "source_edge", "tags"])
        writer.writeheader()
        writer.writerows(cards)
    print(f"Exported {len(cards)} cards to {output_path}")

What does export_csv produce?

The output is a standard CSV with columns front, back, source_node, source_edge, and tags. You can import this directly into Anki or any SRS tool that accepts CSV.

  • Now update the if __name__ block to its final form. Find:
    elif "--cold" in sys.argv:
        score, misses = run_cold(cards)
    else:
        print("Usage: python quiz.py [--generate | --cold]")
  • Replace it with:
    elif "--export" in sys.argv:
        export_csv(cards)
    elif "--cold" in sys.argv:
        score, misses = run_cold(cards)
    else:
        print("Usage: python quiz.py [--generate | --export | --cold]")
  • Save quiz.py and export the cards by running:
python quiz.py --export

You should see a confirmation message naming the file and card count.

  • Confirm the CSV file exists by running:
ls quiz_export.csv

You should see quiz_export.csv listed. Open it in any text editor or spreadsheet to see 20 rows of cards with their source nodes and tags.

File not found or empty?

Make sure --export appears in the __main__ block BEFORE the --cold check. Python evaluates elif in order, and --export must come first.

Help me debug my quiz CSV export

  • Commit the working quiz module:
git add . && git commit -m "quiz generator, cold run, and CSV export"

✔️ Awesome, I've got everything!

Great. Make sure you have saved quiz.py and that quiz_export.csv exists in your project directory.

ⓧ I'd like to double check the full code

Your quiz.py is longer than 30 lines, so confirm you have the following sections in this order:

  • Module docstring and imports (csv, random, sys, Path, and imports from loader and queries).
  • The generate_cards(G) function with all 7 card types: edge-completion, path-tracing, type-membership, exclusion, career-route, principle, and sub-discipline. Ends with random.shuffle(cards) and return cards[:20].
  • The run_cold(cards) function that loops through cards, prompts for answers, and prints the final score.
  • The export_csv(cards, output_path) function that writes a CSV with 5 columns.
  • The if __name__ == "__main__" block handling --generate, --export, and --cold flags in that order.

You now have a quiz that generates itself from graph structure, a cold-run score proving what you retained, and a CSV ready for spaced repetition. Next up: the Secret Mission challenges you to test your graph's coverage against a real textbook.

Secret mission

Coverage Comparison Against a Named Survey Text

Your graph claims to represent kinesiology as a field. But how much of the field does it actually cover? In this extension, you anchor the graph against a real textbook's chapter structure and document every gap.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with no paid services, so there are no ongoing costs.

Resources you used:

  • Python virtual environment (inside your project folder on the Desktop).
  • Project files: schema.yaml, graph_data.yaml, loader.py, queries.py, quiz.py, test_integrity.py, test_queries.py, coverage.py, quiz_export.csv, requirements.txt.
  • Local Git repository (inside your project folder).

Keep everything running

No action needed. Choose this if you are still actively building on this graph. The project folder is small and costs nothing to keep.

  • Your graph is ready for future extensions: a force-plate build, a threshold-test build, or a wearable build can all attach to it.
  • Consider archiving the repo on GitHub for portfolio use.

Pause - I'll come back to this later

Deactivate the virtual environment to free up your terminal session. The files stay on disk and you can reactivate later.

  • Deactivate the virtual environment by running:
deactivate
  • When you return, navigate back to your project folder and reactivate with source venv/bin/activate (macOS/Linux) or venv\Scripts\activate (Windows).

Delete - I don't want to use this again

Remove all project files, the virtual environment, and the local Git repository. This frees disk space and leaves no trace.

  • Deactivate the virtual environment first (if it is still active):
deactivate
  • Navigate to your Desktop:
cd ~/Desktop
  • Delete the entire project folder by running this command (macOS/Linux):
rm -rf [[PROJECT_DIR="your project folder name"]]
  • On Windows, use this command instead:
rmdir /s /q [[PROJECT_DIR="your project folder name"]]
  • Confirm the folder is gone by running ls on your Desktop. Your project folder should no longer appear in the output.

Nice Work!

Nice Work!

You just built a queryable knowledge graph of kinesiology from scratch. What started as a pile of disconnected terms is now a validated, test-proven structure you can traverse from the terminal and teach back by tracing a path.

You learned how to:

  • Build a typed knowledge graph with 6 node types and 8 edge types in NetworkX, loaded from plain YAML, and prove its integrity with pytest (zero orphan nodes, zero out-of-vocabulary edges, full reachability).
  • Write expected answers before running queries, then prove the graph returns them: shortest path from muscle fiber to jump height crosses four nodes in layer order, force-methods returns exactly four with EMG provably excluded, and career routes separate on funded doctorate vs. professional certification.
  • Generate a 20-card quiz from graph structure, run it cold to prove you internalized the map, and export the cards as CSV for spaced repetition.
  • Secret Mission: Mapped sub-discipline coverage against a named survey text with at most two gaps in either direction, each documented with a reason.

Ready to quiz yourself?