Every Level on One Map

Build a typed, queryable neuroscience concept graph from a YAML source.

Introduction

30 Second Summary

Neuroscience has hundreds of terms scattered across textbooks, papers, and lectures. Without structure, they stay disconnected. You can memorize a definition but not trace how an ion channel relates to spatial navigation, or why an fMRI scan does not directly record the same thing a patch clamp does.

In this project, you will type the structure of neuroscience into a single YAML source file, load it into a queryable graph, prove it sound with automated checks, export a browsable visualization and flashcard deck, then close the map and score yourself cold.

What You'll Build

You will run a query against your own graph, ask "what is the shortest path from an ion channel to spatial navigation," and get back exactly 3 edges crossing 4 scale layers. Then you will close the map, answer 20 cards from memory, and trace every miss to a structural error you can fix.

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

  • A typed directed graph of neuroscience loaded into NetworkX from a single YAML source, with every node connected, every edge drawn from a closed vocabulary of eight, and integrity proven by pytest.
  • A browsable interactive HTML visualization you can pan, zoom, and filter in any browser, plus a 20-card Anki-importable deck exported from the same source file.
  • A scored cold-run result proving at least 17 of 20 cards answered correctly with the map closed, with every miss traced to a structural error and rebuilt.
  • Secret Mission: Thicken one branch of the graph with a follow-on sub-domain and write a teach-back page that explains the structure to a beginner reader.

Are there any prerequisites?

You need a flat-rate AI client (Cursor, Claude Code, Codex via ChatGPT subscription, Gemini via Google subscription, or equivalent), Python 3.11+ installed, a GitHub account, a Linear account, and library access to two anchor texts: Kandel et al., Principles of Neural Science 6th ed. (McGraw-Hill, 2021) and Dayan and Abbott, Theoretical Neuroscience (MIT Press, 2001).

Before We Start

Before any code runs or any file is created, this is the moment to lock in what you are building and why it matters to you. The clearer you can state the question this build will let you answer, the sharper every decision that follows becomes.

Set Up Your Environment, Repository, and Project Board

You have committed to what you are building and why. Now you need the ground under it: a working Python environment, a repository to track every change, and a board to see what remains.

This step installs the four Python packages the project depends on, creates the GitHub repo where every commit lands, and sets up the Linear board that keeps the build on track.

In this step, get ready to:
  • Verify Python 3.11+ and install the four required packages.
  • Initialize a private GitHub repo and a Linear project with tickets.
  • Create and commit the project file skeleton.
Verify the Python environment and install packages

The project uses NetworkX for graph operations, which requires Python 3.11 or newer. You need to confirm the right version is available before anything else.

  • Open a terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Check your Python version by running this command:
python --version

python3 on some systems

If python --version does not work or shows Python 2.x, try python3 --version instead. On macOS and Linux, the command is often python3.

Check the version number printed in your terminal and pick the tab that matches what you see.

✔️ I see version 3.11 or higher

Your Python version is ready. Move on to installing the packages below.

ⓧ I see an older version

Your system has a Python version below 3.11. macOS often ships an older Python by default, so even after installing a newer version, the python or python3 command may still resolve to the old one.

The most reliable fix is to install and activate 3.11 via pyenv, a version manager for Python.

pyenv install 3.11
pyenv global 3.11
  • Close and reopen your terminal, then run python --version again to confirm you now see 3.11 or higher.

ⓧ Command not found

Python is not installed on your system yet.

  • Visit https://www.python.org/downloads/ and download the latest Python 3.11+ installer for your operating system.
  • Run the installer. On Windows, check the box labeled Add Python to PATH before clicking Install Now.
  • Close and reopen your terminal, then run python --version again to confirm you see 3.11 or higher.
  • Install the four required packages by running this command:
pip install networkx==3.6.1 pyvis==0.3.2 PyYAML==6.0.3 pytest

What are these packages?

  • networkx builds and queries the directed graph.
  • pyvis exports the graph as an interactive HTML visualization.
  • PyYAML reads the YAML source file where all nodes and edges live.
  • pytest runs the integrity checks and query assertions.
  • Confirm all four installed by running:
pip show networkx pyvis pyyaml pytest

You should see a block of metadata for each package, including its name and version.

Seeing 'pip: command not found'?

  • Try pip3 instead of pip. On some systems, pip is named pip3 to match python3.
  • If that still fails, run python -m pip install ... to invoke pip through your Python executable directly.

help me fix pip not being found on my system

  • Confirm your AI client plan. Open your subscription dashboard (Cursor Settings, or the equivalent for your client) and confirm you are on a flat-rate plan with no per-token billing within plan limits.
  • Record the plan name and any daily or monthly usage caps in a note. You will use this client heavily throughout the project.
Initialize the GitHub repository and Linear project

Every file in this build lives in a single Git-tracked repository. The Linear board breaks the work into tickets so you can merge each piece as its own pull request.

  • Open GitHub in your browser and sign in.
  • Click New (the green button) to create a new repository.
  • Set the repository name to neuro-concept-graph.
  • Set visibility to Private.
  • Check Add a .gitignore and select the Python template from the dropdown.
  • Check Add a README file.
  • Click Create repository.

You should see your new neuro-concept-graph repo page with a README.md and .gitignore already committed.

  • Switch back to your terminal. Move to your Desktop by running this command:
cd ~/Desktop
  • Clone the repository by running this command (replace the URL with yours from the green Code button on GitHub):
git clone [[REPO_URL="https://github.com/your-username/neuro-concept-graph.git"]]
  • Move into the cloned folder by running:
cd neuro-concept-graph
  • Open README.md in your editor and replace its contents with the project title and a one-line description. Save the file.
  • Commit and push the updated README by running:
git add README.md
git commit -m "Update README with project title and description"
git push

Refresh the GitHub repo page in your browser. You should see your updated README rendered on the main page.

  • Open Linear in your browser and sign in to your workspace.
  • Create a new project. Name it Neuroscience Concept Graph or similar.
  • Create the first ticket. Title it Define schema and populate YAML.
  • Create the second ticket. Title it Build loader and integrity checker.
  • Create the third ticket. Title it Write query pack with expected answers.

Each ticket maps to a branch you will merge via pull request as the build progresses.

Why Linear tickets?

Tickets turn a single large build into visible pieces. Each ticket becomes a branch, each branch a pull request. You can see exactly which piece is done and which is next.

Create the project skeleton

The project uses a fixed set of files. Creating them now means every later step fills an existing file rather than deciding where things go.

  • In your terminal (inside the neuro-concept-graph folder), create the docs/ directory and all empty project files by running these commands:
mkdir docs
touch neuroscience_graph.yaml topology.md loader.py checks.py queries.py export.py cold_run.py test_graph.py
touch docs/what-i-was-learning.md docs/build-and-run.md docs/worked-example.md docs/self-check.md docs/cold-run-result.md docs/source-map.md docs/retrieval-prompts.md docs/decisions.md docs/presentation.md

Windows users

The touch command is available if you use Git Bash (installed with Git). If you are in PowerShell, replace each touch filename with New-Item filename -ItemType File.

  • Confirm the files exist by running:
ls

You should see neuroscience_graph.yaml, topology.md, loader.py, checks.py, queries.py, export.py, cold_run.py, test_graph.py, and a docs/ folder listed.

  • Verify the docs folder contents by running:
ls docs/

You should see nine Markdown files listed inside docs/.

  • Commit and push the skeleton by running:
git add .
git commit -m "Add project skeleton"
git push

Refresh your GitHub repo page. You should see all the new files listed in the repository root.

Seeing 'nothing to commit'?

  • Git does not track empty directories. Make sure you created at least one file inside docs/ with the touch command above.
  • Run git status to see which files Git recognizes. If the list is empty, double-check that you are in the neuro-concept-graph directory.

help me fix my git commit showing nothing to commit

Verify the full setup

Before you move on, think about what you expect to see when you run the version check and package confirmation together. Do you expect all four packages to show their version numbers?

  • Run the final check by running these two commands:
python --version
pip show networkx pyvis pyyaml pytest

You should see a version number of 3.11 or higher from the first command. The second command prints metadata blocks for all four packages with their pinned versions.

  • Open your GitHub repo in a browser and confirm the skeleton files are visible.
  • Open your Linear project and confirm you see at least three tickets.

Your foundation is locked in: verified Python, four packages, a tracked repo, and a board to steer what comes next. In the next step, you will design the graph schema and type every node and edge into the YAML source file.

Design the Schema and Populate the YAML Source File

Your environment is ready and the skeleton is committed. Now the real structure begins.

A graph without a schema is a drawing. A graph with typed nodes and a closed edge vocabulary is a queryable data structure. In this step you will create the single source of truth that every later step reads from: one YAML file that defines what kinds of things exist, what kinds of relationships connect them, and which specific concepts belong where.

In this step, get ready to:
  • Define the schema header with version, anchor texts, inventory, absence list, and the closed edge vocabulary of eight types.
  • Populate approximately 60 nodes across 6 types using your AI client in agent mode.
  • Wire all edges, verify connectivity, and commit the Mermaid topology diagram.
Define the schema header and edge vocabulary

The meta block at the top of neuroscience_graph.yaml records what the graph is built from, what it counts, and what it deliberately excludes. This header is your regression anchor. Every later test checks against the values you record here.

  • Open neuroscience_graph.yaml in your editor and add the meta block with version, date, and anchor texts:
meta:
  version: 1
  date: "2026-08-13"
  anchor_texts:
    - title: "Principles of Neural Science"
      edition: "6th"
      authors: "Kandel, Koester, Mack, Siegelbaum"
      publisher: "McGraw-Hill"
      year: 2021
    - title: "Theoretical Neuroscience"
      edition: "1st (only edition)"
      authors: "Dayan, Abbott"
      publisher: "MIT Press"
      year: 2001

What does the meta block record?

The version and date mark when this schema was authored. The anchor texts name the two books you are drawing from, with exact editions recorded beside them. This means anyone reading the file later knows the source without guessing.

  • Below the anchor texts, add the inventory count, absence list, and anchor parts:
  inventory:
    methods_recording_spike_times: 3
  absence_list:
    - fmri
    - two_photon_calcium_imaging
    - eeg
    - meg
    - extracellular_probe_recording
  anchor_parts:
    kandel_6e:
      - "Overall Perspective"
      - "Cell and Molecular Biology of Cells of the Nervous System"
      - "Synaptic Transmission"
      - "Perception"
      - "Movement"
      - "The Biology of Emotion, Motivation and Homeostasis"
      - "Development and the Emergence of Behavior"
      - "Learning, Memory, Language and Cognition"
      - "Diseases and Disorders of the Nervous System"
    dayan_abbott:
      - "Neural Encoding and Decoding"
      - "Neurons and Neural Circuits"
      - "Plasticity and Learning"

Why record an absence list?

The absence list names five methods that do NOT directly record spike times. Later, a regression test asserts these never appear in a measured-by query result. Recording them here means the test checks against your own stated inventory, not a hardcoded magic number.

The anchor parts are counted off your own contents page. Kandel 6th edition has 9 top-level parts. Dayan and Abbott has 3. A later query will assert every part maps to at least one node.

  • Add the later-build data section and close the meta block:
  later_build_data:
    - name: "International Brain Laboratory repeated site ephys"
      species: "mouse"
      note: "Public release. All procedures under institutional animal care and use committee approval."
    - name: "Public de-identified human neuroimaging datasets"
      species: "human"
      note: "Public and de-identified. Original collection under institutional review board approval."
  • After the meta block, add the closed edge vocabulary section:
edge_vocabulary:
  - is-at-level
  - is-at-scale
  - belongs-to
  - underlies
  - explains
  - measured-by
  - inferred-from
  - reproduced-by

Why only eight edge types?

A closed vocabulary means every relationship is a typed, queryable claim. Free-form edges like "relates-to" or "is-connected-with" cannot be queried structurally because no two edges mean the same thing. Eight is enough to distinguish what a model explains, what scale a concept lives at, what an instrument directly records, and what it only infers.

  • Save neuroscience_graph.yaml and confirm the YAML is valid by running:
python -c "import yaml; d=yaml.safe_load(open('neuroscience_graph.yaml')); print(d['meta']['inventory'])"

You should see {'methods_recording_spike_times': 3} printed. This confirms PyYAML can parse your meta block without errors.

Seeing a YAML parse error?

YAML is whitespace-sensitive. Check that every nested key is indented by exactly two spaces relative to its parent. Colons inside string values must be wrapped in quotes. The absence list items must each start with - at the correct indent level.

help me debug a YAML indentation error in my neuroscience_graph.yaml meta block

Populate nodes across all six types

The graph has six kinds of node, each serving a different structural role. Three Marr levels name what question a model answers. Four scale layers name the physical granularity. Six subfields name the communities that study different questions. Landmarks name discoveries. Methods name instruments. Models name formal explanations.

  • Below the edge_vocabulary section, add the nodes: key and the three Marr level nodes:
nodes:
  # --- Marr's Levels (3 nodes, type: marr_level) ---
  - id: computational_level
    type: marr_level
    label: "Computational Level"
    definition: "What problem is being solved and why"

  - id: algorithmic_level
    type: marr_level
    label: "Algorithmic Level"
    definition: "What representation and procedure solve it"

  - id: implementational_level
    type: marr_level
    label: "Implementational Level"
    definition: "What physical substrate carries it out"

What are Marr's three levels?

Each level answers a different question about a system. They are not a ranking. The computational level asks what problem is solved and why. The algorithmic level asks what representation and procedure solve it. The implementational level asks what physical substrate carries it out. Every model you add later gets assigned to exactly one of these.

  • Add the four scale layer nodes immediately after:
  # --- Scale Layers (4 nodes, type: scale_layer) ---
  - id: molecule_synapse
    type: scale_layer
    label: "Molecule and Synapse"
    order: 1

  - id: neuron_scale
    type: scale_layer
    label: "Neuron"
    order: 2

  - id: circuit_scale
    type: scale_layer
    label: "Circuit"
    order: 3

  - id: behavior_scale
    type: scale_layer
    label: "Behavior"
    order: 4
  • Save and run a quick parse check to confirm both sections loaded:
python -c "import yaml; d=yaml.safe_load(open('neuroscience_graph.yaml')); print(len(d['nodes']))"

You should see 7 printed (3 Marr levels plus 4 scale layers).

The remaining nodes (subfields, landmarks, methods, models) are too numerous to type by hand. This is where your AI client earns its subscription. You will prompt it in agent mode with the structural constraints, and it will populate the YAML while you verify the output meets the rules.

  • In your AI client, prompt it to populate the remaining nodes in neuroscience_graph.yaml with the following constraints:
  • 6 subfield nodes: cellular_molecular, systems_neuro, cognitive_neuro, computational_neuro, developmental_neuro, clinical_neuro.
  • Approximately 40 landmark nodes, each with id, type: landmark, label, date, authors, and scope_flag (animal, human, or null).
  • The scale path nodes must be present as landmarks: ion_channel, action_potential, place_field_circuit, spatial_navigation, and spike_times.
  • 8 method nodes: patch_clamp, sharp_electrode, juxtacellular, fmri, two_photon_calcium_imaging, eeg, meg, extracellular_probe_recording. Each method has a records field naming what the instrument directly records. Absence-list methods also carry a note explaining why they do not directly record spike times.
  • 6 model nodes: hh_model, drift_diffusion, bayesian_decoding, hebb_rule, reinforcement_learning, linear_nonlinear_poisson. Each has a description field.

Guide the AI with structural constraints

Give your AI client the exact node IDs listed above. It does the typing. You do the verifying. After it generates the nodes, check that every landmark naming an animal procedure has scope_flag: animal and every landmark naming a human procedure has scope_flag: human.

Here is the structural pattern your method nodes must follow. Verify the AI client produced this shape for all eight methods:

  - id: patch_clamp
    type: method
    label: "Patch-clamp recording"
    records: "Ionic current through single channels or whole-cell current"

  - id: fmri
    type: method
    label: "Functional magnetic resonance imaging"
    records: "Blood-oxygen-level-dependent signal"
    note: "BOLD signal follows neural activity slowly and indirectly"

Why do some methods have a note field?

The note field appears on the five absence-list methods. It explains why the method does not directly record spike times. This is structural documentation that prevents a future editor from adding a measured-by edge where an inferred-from edge belongs.

  • After the AI client finishes populating nodes, save neuroscience_graph.yaml and count the total:
python -c "import yaml; d=yaml.safe_load(open('neuroscience_graph.yaml')); print(len(d['nodes']))"

You should see 55 or more. If the count is lower, check that the AI client generated all six subfields, all eight methods, all six models, and at least 40 landmarks.

Count is below 55?

Count nodes by type to find the gap. Run this in your terminal:

python -c "import yaml; from collections import Counter; d=yaml.safe_load(open('neuroscience_graph.yaml')); print(Counter(n['type'] for n in d['nodes']))"

The output should show marr_level: 3, scale_layer: 4, subfield: 6, method: 8, model: 6, and landmark at 28 or more. Prompt your AI client to add the missing type.

help me figure out which node types are missing from my neuroscience_graph.yaml

Wire all edges and commit the Mermaid topology diagram

Nodes without edges are terms never placed. The edges are where the structural claims live. A measured-by edge is a falsifiable statement that an instrument directly records a quantity. An inferred-from edge says it does not, and names what stands between.

The most critical constraint is the scale path. From ion_channel to spatial_navigation must be exactly 3 edges through 4 nodes, one at each scale layer in order.

  • Add the edges: section to your YAML file. Start with the scale path, the most structurally critical edges:
edges:
  # --- Scale path: ion_channel -> spatial_navigation (3 edges, 4 nodes) ---
  - source: ion_channel
    target: action_potential
    type: underlies
  - source: action_potential
    target: place_field_circuit
    type: underlies
  - source: place_field_circuit
    target: spatial_navigation
    type: underlies

Why exactly 3 edges?

The path visits one node at each of the four scale layers in order: molecule_synapse, neuron, circuit, behavior. Three underlies edges connect four nodes. If the path is shorter, a shortcut edge flattened the hierarchy. If it is longer, a node was inserted at the wrong scale.

  • Add the three measured-by edges. Only these three methods directly record spike times:
  # --- measured-by edges (direct recordings of spike times) ---
  - source: spike_times
    target: patch_clamp
    type: measured-by
  - source: spike_times
    target: sharp_electrode
    type: measured-by
  - source: spike_times
    target: juxtacellular
    type: measured-by
  • Add the five inferred-from edges. Each must carry intermediate_signal and model attributes naming what stands between the instrument and spike times:
  # --- inferred-from edges (spike times inferred, not directly recorded) ---
  - source: spike_times
    target: fmri
    type: inferred-from
    intermediate_signal: "BOLD response"
    model: "Hemodynamic response function"
  - source: spike_times
    target: two_photon_calcium_imaging
    type: inferred-from
    intermediate_signal: "Calcium fluorescence trace"
    model: "Spike deconvolution algorithm"

What is the measured-by vs inferred-from distinction?

A measured-by edge means the instrument directly records the quantity. Patch-clamp recording produces voltage traces that ARE action potentials.

An inferred-from edge means the instrument records a different signal, and a model estimates the quantity from that signal. fMRI records a blood-oxygen signal that follows neural activity slowly and indirectly. A brain image is not a picture of thought.

  • Use your AI client to add the remaining edges. Give it these constraints:
  • Add inferred-from edges for eeg, meg, and extracellular_probe_recording with their own intermediate signals and models.
  • Add is-at-scale edges assigning every node to its scale layer.
  • Add is-at-level edges assigning each of the 6 models to exactly one Marr level.
  • Add belongs-to edges assigning landmarks, methods, models, and subfields to their parent groupings.
  • Add explains edges connecting models to the landmarks they account for.
  • Every node must have at least one edge. Add remaining connectivity edges so no node is orphaned.
  • Save neuroscience_graph.yaml and verify the full node count:
python -c "import yaml; d=yaml.safe_load(open('neuroscience_graph.yaml')); print(len(d['nodes']), 'nodes,', len(d['edges']), 'edges')"

You should see 55 or more nodes and 50 or more edges.

Now commit the topology diagram alongside the YAML. The diagram is a Mermaid graph that renders in any Markdown viewer (GitHub, VS Code, Obsidian) and shows the architecture at a glance.

  • Open topology.md and add the header and node type clusters:
# Topology: Neuroscience Concept Graph

The graph carries 6 node types connected by 8 edge types from a closed vocabulary.

## Mermaid Diagram

```mermaid
graph TD
    subgraph "Node Types"
        ML[marr_level x3]
        SL[scale_layer x4]
        SF[subfield x6]
        LM[landmark ~40]
        MT[method x8]
        MD[model x6]
    end
  • Continue the Mermaid diagram with the edge vocabulary and relationship arrows:
    subgraph "Edge Vocabulary (closed, 8 types)"
        E1[is-at-level]
        E2[is-at-scale]
        E3[belongs-to]
        E4[underlies]
        E5[explains]
        E6[measured-by]
        E7[inferred-from]
        E8[reproduced-by]
    end

    MD -->|is-at-level| ML
    LM -->|is-at-scale| SL
    LM -->|belongs-to| SF
    LM -->|underlies| LM
    MD -->|explains| LM
    LM -->|measured-by| MT
    LM -->|inferred-from| MT
    LM -->|reproduced-by| MT
  • Add the scale path subgraph and the recorded-vs-inferred subgraph:
    subgraph "Scale Path (3 edges, 4 layers)"
        IC[ion_channel\nmolecule_synapse] -->|underlies| AP[action_potential\nneuron]
        AP -->|underlies| PFC[place_field_circuit\ncircuit]
        PFC -->|underlies| SN[spatial_navigation\nbehavior]
    end

    subgraph "Recorded vs Inferred"
        ST[spike_times] -->|measured-by| PC[patch_clamp]
        ST -->|measured-by| SE[sharp_electrode]
        ST -->|measured-by| JC[juxtacellular]
        ST -.->|inferred-from| FMRI[fmri]
        ST -.->|inferred-from| CA[two_photon_calcium]
        ST -.->|inferred-from| EEG[eeg]
        ST -.->|inferred-from| MEG[meg]
        ST -.->|inferred-from| EP[extracellular_probe]
    end
```
  • Add the counts table at the bottom of topology.md:
## Counts

- 6 node types: marr_level, scale_layer, subfield, landmark, method, model
- 8 edge types: is-at-level, is-at-scale, belongs-to, underlies, explains, measured-by, inferred-from, reproduced-by
- 4 scale layers: molecule_synapse, neuron, circuit, behavior
- 3 levels of explanation: computational, algorithmic, implementational
  • Save topology.md.

Before you commit, predict what you will see when you run the final count command. Will the node count be exactly 55, or higher?

  • Run the verification command one more time:
python -c "import yaml; d=yaml.safe_load(open('neuroscience_graph.yaml')); print(len(d['nodes']))"

You should see 55 or more. This confirms the YAML has all 6 node types populated with sufficient nodes across the hierarchy.

  • Open topology.md in a Markdown viewer (VS Code preview, or push to GitHub and view there). You should see the Mermaid diagram rendering with node type clusters, labeled edge arrows, the 4-node scale path, and the recorded-vs-inferred split with dashed arrows for inferred methods.
  • Commit both files and push:
git add neuroscience_graph.yaml topology.md
git commit -m "Define schema, populate nodes and edges, add topology diagram"
git push

Mermaid diagram not rendering?

GitHub renders Mermaid natively in .md files. If using VS Code, install the Markdown Preview Mermaid Support extension. Check that the opening fence is exactly ```mermaid with no space between the backticks and the word.

my Mermaid diagram in topology.md is not rendering, help me debug the syntax

✔️ Awesome, I've got everything!

Great. Double check that neuroscience_graph.yaml is saved and both files are committed. Move your Linear ticket to in-progress or done.

ⓧ I'd like to double check the full code

Your neuroscience_graph.yaml is large and generated by your AI client, so the exact landmarks will vary. Verify these structural properties instead:

  • Meta block contains version: 1, inventory.methods_recording_spike_times: 3, five items in absence_list, 9 entries in kandel_6e parts, and 3 entries in dayan_abbott parts.
  • Edge vocabulary lists exactly 8 types: is-at-level, is-at-scale, belongs-to, underlies, explains, measured-by, inferred-from, reproduced-by.
  • Nodes include all 3 marr_level, all 4 scale_layer, all 6 subfield, all 8 method, all 6 model, and at least 28 landmark nodes (total 55+).
  • Scale path nodes present: ion_channel, action_potential, place_field_circuit, spatial_navigation.
  • Three measured-by edges from spike_times to patch_clamp, sharp_electrode, juxtacellular.
  • Five inferred-from edges from spike_times each with intermediate_signal and model attributes.
  • All 6 model nodes have an is-at-level edge to exactly one Marr level.
  • Every node has at least one edge (no orphans).

For topology.md, confirm it contains the Mermaid diagram with the Node Types subgraph, Edge Vocabulary subgraph, the Scale Path subgraph (3 edges, 4 layers), and the Recorded vs Inferred subgraph. The Counts section should list all 6 node types, 8 edge types, 4 scale layers, and 3 levels.

Your single source of truth is committed. Every node is typed. Every edge comes from the closed vocabulary. The topology is visible in any Markdown viewer. Next, you will load this into a NetworkX DiGraph and prove it structurally sound with an integrity checker that rejects orphan nodes, illegal edges, and unreachable subgraphs.

Build the Loader and Integrity Checker

Your YAML file now holds approximately 60 nodes and dozens of typed edges. But a text file is not a queryable structure. Loading it into a NetworkX DiGraph turns it into something you can traverse, filter, and assert against.

Loading is not validation, though. The integrity checker is where you discover that the first pass left orphan nodes and illegal edges. No one gets a 60-node graph right on the first write. The checker catches what hand-wiring missed.

In this step, get ready to:
  • Write a Python loader that reads the YAML into a directed graph with all attributes preserved.
  • Write three integrity checks that reject orphan nodes, illegal edge types, and disconnected subgraphs.
  • Wire the checks into pytest and iterate until the graph passes all three.
Write the loader

The loader is the bridge between your YAML text and a traversable data structure. It reads the source file, iterates nodes and edges, and returns a populated DiGraph that every later module imports.

  • Open loader.py in your editor and paste the following code:
"""Load the neuroscience concept graph from YAML into a NetworkX DiGraph."""

import yaml
import networkx as nx
from pathlib import Path

GRAPH_PATH = Path(__file__).parent / "neuroscience_graph.yaml"

def load_graph(path=None):
    """Load YAML source and return a populated NetworkX DiGraph."""
    if path is None:
        path = GRAPH_PATH
    with open(path, "r", encoding="utf-8") as f:
        data = yaml.safe_load(f)

    G = nx.DiGraph()

    # Add every node with its attributes (type, label, date, etc.)
    for node in data["nodes"]:
        node_copy = dict(node)
        node_id = node_copy.pop("id")
        G.add_node(node_id, **node_copy)

    # Add every edge with its type and any extra attributes
    for edge in data["edges"]:
        edge_copy = dict(edge)
        source = edge_copy.pop("source")
        target = edge_copy.pop("target")
        G.add_edge(source, target, **edge_copy)

    return G

What does this code do?

GRAPH_PATH resolves the YAML file relative to the script's own location, so imports work from any working directory.

load_graph() reads the YAML once, then loops through the nodes list adding each to a DiGraph with all its attributes (type, label, date, scope_flag). A second loop adds every edge with its type attribute and any extras like intermediate_signal. The result is a fully attributed directed graph you can query with any NetworkX function.

  • Confirm the loader works by running this command:
python -c "from loader import load_graph; G = load_graph(); print(f'{G.number_of_nodes()} nodes, {G.number_of_edges()} edges')"

You should see a count of 55 or more nodes and a higher number of edges.

Seeing a ModuleNotFoundError?

  • Make sure your terminal is in the neuro-concept-graph directory where loader.py and neuroscience_graph.yaml both live.
  • Confirm your virtual environment is active by running pip show networkx. If it reports nothing, activate the environment you set up in Step 1.

help me debug a ModuleNotFoundError when importing loader.py

Now add the two helper functions below your load_graph function in loader.py:

  • Paste this code at the bottom of loader.py:
def load_meta(path=None):
    """Load and return the meta block from the YAML source."""
    if path is None:
        path = GRAPH_PATH
    with open(path, "r", encoding="utf-8") as f:
        data = yaml.safe_load(f)
    return data["meta"]

def load_edge_vocabulary(path=None):
    """Load and return the closed edge vocabulary."""
    if path is None:
        path = GRAPH_PATH
    with open(path, "r", encoding="utf-8") as f:
        data = yaml.safe_load(f)
    return set(data["edge_vocabulary"])

What do these helpers do?

load_meta() returns the meta block containing the inventory count, absence list, and anchor-text part lists. Later tests use it as their expected-value source.

load_edge_vocabulary() returns a Python set of the 8 allowed edge types. The integrity checker compares every edge in the graph against this set.

  • Save loader.py.
  • Verify the vocabulary loads correctly by running:
python -c "from loader import load_edge_vocabulary; print(load_edge_vocabulary())"

You should see a set of 8 strings matching your YAML edge vocabulary.

✔️ Awesome, I've got everything!

Great. Confirm you have saved loader.py with all three functions.

ⓧ I'd like to double check the full code

Your loader.py should contain two chunks in order. Scroll up and confirm you have:

  • The imports, GRAPH_PATH, and load_graph() function (first chunk).
  • The load_meta() and load_edge_vocabulary() functions (second chunk).
Write the three integrity checks

Each check tests one structural invariant. A node with no edges is a term that was collected but never placed in the structure. An edge outside the 8-type vocabulary is a feeling about a relationship, not a queryable claim. A disconnected subgraph means part of your map is unreachable from the rest.

  • Open checks.py and paste the following code:
"""Integrity checks for the neuroscience concept graph."""

import networkx as nx

def check_no_orphans(G):
    """Every node must have at least one edge. A node with no edges is a term never placed."""
    orphans = list(nx.isolates(G))
    assert orphans == [], f"Orphan nodes found (no edges): {orphans}"

def check_edge_vocabulary(G, vocabulary):
    """Every edge type must come from the closed vocabulary of eight."""
    illegal = []
    for u, v, data in G.edges(data=True):
        edge_type = data.get("type")
        if edge_type is None:
            illegal.append((u, v, "MISSING_TYPE"))
        elif edge_type not in vocabulary:
            illegal.append((u, v, edge_type))
    assert illegal == [], f"Edges outside vocabulary: {illegal}"

def check_reachability(G):
    """Every node must be reachable from every other in the underlying undirected graph."""
    assert nx.is_weakly_connected(G), (
        f"Graph is not weakly connected. "
        f"Components: {nx.number_weakly_connected_components(G)}"
    )

What does each check enforce?

  • check_no_orphans uses nx.isolates(G) to find nodes with zero edges. If any exist, the assertion fails and prints their IDs so you know exactly what to wire.
  • check_edge_vocabulary iterates every edge and checks its type attribute against the 8-item set. Missing or misspelled types fail loudly.
  • check_reachability uses nx.is_weakly_connected(G) to confirm every node can reach every other node when edge direction is ignored. A disconnected component means an island of nodes never linked to the main graph.
  • Save checks.py.

✔️ Awesome, I've got everything!

Great. Confirm checks.py is saved with all three functions.

ⓧ I'd like to double check the full code

"""Integrity checks for the neuroscience concept graph."""

import networkx as nx

def check_no_orphans(G):
    """Every node must have at least one edge. A node with no edges is a term never placed."""
    orphans = list(nx.isolates(G))
    assert orphans == [], f"Orphan nodes found (no edges): {orphans}"

def check_edge_vocabulary(G, vocabulary):
    """Every edge type must come from the closed vocabulary of eight."""
    illegal = []
    for u, v, data in G.edges(data=True):
        edge_type = data.get("type")
        if edge_type is None:
            illegal.append((u, v, "MISSING_TYPE"))
        elif edge_type not in vocabulary:
            illegal.append((u, v, edge_type))
    assert illegal == [], f"Edges outside vocabulary: {illegal}"

def check_reachability(G):
    """Every node must be reachable from every other in the underlying undirected graph."""
    assert nx.is_weakly_connected(G), (
        f"Graph is not weakly connected. "
        f"Components: {nx.number_weakly_connected_components(G)}"
    )
Wire the integrity checks into pytest

The test file imports the loader and checks, loads the graph once per session via fixtures, and runs each check as a separate test function. This means pytest tells you exactly which invariant broke.

  • Open test_graph.py and paste the following code:
"""Pytest assertions for the neuroscience concept graph."""

import pytest

from loader import load_graph, load_meta, load_edge_vocabulary
from checks import check_no_orphans, check_edge_vocabulary, check_reachability

@pytest.fixture(scope="session")
def graph():
    return load_graph()

@pytest.fixture(scope="session")
def meta():
    return load_meta()

@pytest.fixture(scope="session")
def vocabulary():
    return load_edge_vocabulary()

# --- Integrity tests ---

class TestIntegrity:
    def test_no_orphans(self, graph):
        check_no_orphans(graph)

    def test_edge_vocabulary(self, graph, vocabulary):
        check_edge_vocabulary(graph, vocabulary)

    def test_reachability(self, graph):
        check_reachability(graph)

Why session-scoped fixtures?

The scope="session" setting means the YAML is loaded once for the entire test run, not once per test function. This keeps the suite fast as you add more query tests later.

Each test in TestIntegrity calls exactly one check function. When a test fails, the name tells you which invariant broke without reading the traceback.

  • Save test_graph.py.

Before you run the tests, make a prediction. Do you think all three integrity checks will pass on the first try, given that your YAML has approximately 60 hand-wired nodes?

  • Run the integrity tests by executing this command:
pytest test_graph.py::TestIntegrity -v

You will likely see at least one failure. The most common is test_no_orphans printing a list of node IDs that have zero edges. This is the designed obstacle. The checker catches what hand-wiring missed.

Why failures here are the point

No one gets a 60-node graph right on the first write. The integrity checker exists precisely to surface the structural errors your eyes missed. Each failure names the exact node or edge to fix.

If you see orphan nodes, the fix is adding edges in neuroscience_graph.yaml. If you see illegal edge types, the fix is correcting the type attribute to one of the 8 allowed values. If the graph is disconnected, the fix is linking the isolated component to the main graph.

  • Read the failure output carefully. It names the exact nodes or edges that broke the check.
  • Open neuroscience_graph.yaml and fix each issue by adding the missing edges or correcting the edge types.
  • Re-run the tests after each fix:
pytest test_graph.py::TestIntegrity -v
  • Iterate until all three tests pass green.

When all three pass, you should see output like this: three lines each showing PASSED next to the test name, and a summary line reporting 3 passed.

Tests still failing after edits?

  • Check that you saved neuroscience_graph.yaml after each edit. The loader reads the file fresh each session, but pytest caches the fixture within a run.
  • If check_edge_vocabulary fails, look for typos in the type field of the flagged edge. Common mistakes: belongs_to (underscore) instead of belongs-to (hyphen).
  • If check_reachability fails, find the disconnected component by looking at nodes that have no path to any node in the main cluster. Add at least one edge connecting them.

help me trace and fix failing integrity checks in my neuroscience concept graph

✔️ Awesome, I've got everything!

Great. Confirm test_graph.py is saved and all three integrity tests pass green.

ⓧ I'd like to double check the full code

"""Pytest assertions for the neuroscience concept graph."""

import pytest

from loader import load_graph, load_meta, load_edge_vocabulary
from checks import check_no_orphans, check_edge_vocabulary, check_reachability

@pytest.fixture(scope="session")
def graph():
    return load_graph()

@pytest.fixture(scope="session")
def meta():
    return load_meta()

@pytest.fixture(scope="session")
def vocabulary():
    return load_edge_vocabulary()

# --- Integrity tests ---

class TestIntegrity:
    def test_no_orphans(self, graph):
        check_no_orphans(graph)

    def test_edge_vocabulary(self, graph, vocabulary):
        check_edge_vocabulary(graph, vocabulary)

    def test_reachability(self, graph):
        check_reachability(graph)
  • Commit all three files and push to your GitHub repo:
git add loader.py checks.py test_graph.py neuroscience_graph.yaml
git commit -m "Add loader and integrity checker, fix orphans"
git push
  • In Linear, move the "Build loader and integrity checker" ticket to Done (or In Review if merging via PR).

Your graph is now proven structurally sound. Every node is connected, every edge is typed from the closed vocabulary, and no subgraph is stranded. Next up, you will write queries that test whether the edges are correctly typed, not just legal.

Write the Query Pack with Expected Answers First

Your integrity checker proved the graph is structurally sound. Every node has an edge, every edge type comes from the vocabulary, and every node is reachable.

But structural soundness says nothing about whether the edges are correctly typed. A measured-by edge on fMRI would pass the integrity checker (it is a legal vocabulary term), but it would be wrong (fMRI does not directly record spike times). The queries in this step expose typing errors by asserting expected answers first, so a wrong edge cannot quietly pass.

In this step, get ready to:
  • Write a scale-path query that asserts exactly 3 edges from ion channel to spatial navigation.
  • Write a recorded-vs-inferred query that rejects all five absence-list methods from appearing in a direct-recording result.
  • Write landmark-coverage queries that assert zero unlinked landmarks and zero models without exactly one level.
Write the scale-path query functions

The scale path from ion_channel to spatial_navigation must traverse exactly 3 edges through 4 nodes, one at each scale layer in order. If the path length is 2, a shortcut edge flattened the chain. If it skips a layer, a scale attribute is wrong.

  • Open queries.py in your editor and add the following three functions:
"""Query functions for the neuroscience concept graph."""

import networkx as nx


def scale_path(G, source, target):
    """Return the shortest path and its length between two nodes."""
    path = nx.shortest_path(G, source, target)
    return path, len(path) - 1


def scale_of_node(G, node_id):
    """Return the scale layer a node is assigned to, or None."""
    for _, target, data in G.out_edges(node_id, data=True):
        if data.get("type") == "is-at-scale":
            return target
    return None


def path_crosses_scales_in_order(G, path, expected_order):
    """Check that each node on a path belongs to a different scale layer, in order."""
    path_scales = []
    for node in path:
        scale = scale_of_node(G, node)
        if scale is not None:
            path_scales.append(scale)
    return path_scales == expected_order

What does this code do?

These three functions work together to answer one question: does the scale path cross all four layers in order?

  • scale_path wraps nx.shortest_path and returns both the path and the edge count (nodes minus one).
  • scale_of_node looks up which scale layer a node belongs to by following its is-at-scale edge.
  • path_crosses_scales_in_order collects the scale of each node along the path and checks if they match the expected order.
  • Save queries.py.
  • Now open test_graph.py and add the import for the new query functions below the existing imports from checks:
from queries import (
    scale_path,
    path_crosses_scales_in_order,
)
  • Below the TestIntegrity class, add this test class:
class TestScalePath:
    def test_ion_channel_to_spatial_navigation_length(self, graph):
        path, length = scale_path(graph, "ion_channel", "spatial_navigation")
        assert length == 3, (
            f"Expected 3 edges, got {length}. "
            f"Path: {path}. "
            "If shorter, a shortcut edge flattened the chain. "
            "If longer, a scale assignment is wrong."
        )

    def test_path_crosses_all_four_scales(self, graph):
        path, _ = scale_path(graph, "ion_channel", "spatial_navigation")
        expected_order = [
            "molecule_synapse",
            "neuron_scale",
            "circuit_scale",
            "behavior_scale",
        ]
        assert path_crosses_scales_in_order(graph, path, expected_order), (
            f"Path {path} does not cross scale layers in order. "
            "A scale attribute is wrong on one of the intermediate nodes."
        )

Why write the expected answer before running?

The assertion says "length == 3" before you run it. If the test passes, you know the YAML is correctly wired. If it fails, the error message tells you exactly what went wrong: a shortcut edge or a wrong scale attribute. A query without a pre-written expected answer can quietly return the wrong thing and you would never notice.

Before you run these tests, predict: do you think the scale path will be exactly 3 edges, or did your YAML pick up a shortcut somewhere?

  • Save test_graph.py and run the scale-path tests:
pytest test_graph.py::TestScalePath -v

You should see both tests pass green. If a test fails, the assertion message tells you exactly what to fix in neuroscience_graph.yaml: either a shortcut edge that skipped a layer, or a wrong is-at-scale assignment on an intermediate node.

Test failing with length 2 or a skipped scale?

  • If the path length is 2, look for a direct underlies edge from ion_channel to place_field_circuit or spatial_navigation in your YAML. Remove it. The chain must go through action_potential.
  • If scales are out of order, check that each node on the path has the correct is-at-scale edge: ion_channel to molecule_synapse, action_potential to neuron_scale, place_field_circuit to circuit_scale, spatial_navigation to behavior_scale.

Fix the YAML, re-run the test, and confirm it passes before moving on. Help me debug my scale-path test failure

Write the recorded-vs-inferred query and absence-list check

The sharpest distinction in the graph is between methods that directly record spike times and methods that infer them from a different signal. A measured-by edge means the instrument records the quantity directly. An inferred-from edge means a model estimates it from something else.

  • Add these two functions to queries.py below the existing functions:
def methods_recording_spike_times(G, target_node="spike_times"):
    """Return all method nodes joined to target_node by a measured-by edge."""
    recording_methods = []
    for u, v, data in G.edges(data=True):
        if u == target_node and data.get("type") == "measured-by":
            recording_methods.append(v)
    return recording_methods


def methods_inferred_spike_times(G, target_node="spike_times"):
    """Return all method nodes joined to target_node by an inferred-from edge."""
    inferred_methods = []
    for u, v, data in G.edges(data=True):
        if u == target_node and data.get("type") == "inferred-from":
            inferred_methods.append(v)
    return inferred_methods

What does this code do?

Both functions iterate all edges outgoing from spike_times and filter by edge type. The first collects only measured-by edges (direct recordings). The second collects only inferred-from edges (indirect inferences).

If fMRI appears in the measured-by results, its edge type in the YAML is wrong. A BOLD signal follows neural activity slowly and indirectly. The test will catch this.

  • Save queries.py.
  • In test_graph.py, update the import from queries to include the new function:
from queries import (
    scale_path,
    methods_recording_spike_times,
    path_crosses_scales_in_order,
)
  • Below the TestScalePath class, add this test class:
class TestRecordedVsInferred:
    def test_recording_count_matches_inventory(self, graph, meta):
        methods = methods_recording_spike_times(graph)
        expected_count = meta["inventory"]["methods_recording_spike_times"]
        assert len(methods) == expected_count, (
            f"Expected {expected_count} methods recording spike times, "
            f"got {len(methods)}: {methods}. "
            "This is a regression check on your own inventory, "
            "not a fact about the field."
        )

    @pytest.mark.parametrize("absent_method", [
        "fmri",
        "two_photon_calcium_imaging",
        "eeg",
        "meg",
        "extracellular_probe_recording",
    ])
    def test_absence_list(self, graph, meta, absent_method):
        methods = methods_recording_spike_times(graph)
        assert absent_method not in methods, (
            f"{absent_method} appeared in measured-by results but belongs on "
            f"the absence list. Its edge type should be inferred-from. "
            f"A brain image is not a picture of thought."
        )

What does this test prove?

  • The count test checks that exactly 3 methods have a measured-by edge to spike_times. That count comes from your own inventory header in the YAML, not from a textbook.
  • The parametrized absence-list test runs five times, once per method that must NOT appear in the measured-by results. If any of the five appears, its edge type is wrong.
  • The @pytest.mark.parametrize decorator generates a separate test case per method, so you see exactly which one failed.

Before you run this, predict: will any of the five absence-list methods appear in your measured-by results?

  • Save test_graph.py and run the recorded-vs-inferred tests:
pytest test_graph.py::TestRecordedVsInferred -v

You should see 6 tests: one count test and five absence-list assertions. If any absence-list method appears in the measured-by results, the test tells you exactly which method has a wrong edge type.

An absence-list method appeared in measured-by results?

This is the designed catch. If the test says fmri appeared in measured-by results, open neuroscience_graph.yaml and find the edge from spike_times to fmri. Change its type from measured-by to inferred-from and add the intermediate_signal and model attributes. Re-run until all six pass.

Help me fix my measured-by vs inferred-from edges

Write the landmark-coverage and model-level queries

These queries check that no landmark was collected without being attached to the build, and no model sits without a level of explanation assigned.

  • Add this function to queries.py below the existing functions:
def unlinked_landmarks(G):
    """Return landmarks with no content edge (only is-at-scale or belongs-to)."""
    unlinked = []
    for node, attrs in G.nodes(data=True):
        if attrs.get("type") != "landmark":
            continue
        has_content_edge = False
        for _, target, data in G.out_edges(node, data=True):
            if data.get("type") not in ("is-at-scale", "belongs-to"):
                has_content_edge = True
                break
        if not has_content_edge:
            for source, _, data in G.in_edges(node, data=True):
                if data.get("type") not in ("is-at-scale", "belongs-to"):
                    has_content_edge = True
                    break
        if not has_content_edge:
            unlinked.append(node)
    return unlinked

What does this code do?

A landmark with only is-at-scale and belongs-to edges was placed on the map but never connected to anything meaningful. It was collected but never attached to a later build via underlies, explains, or reproduced-by. The function checks both outgoing and incoming edges.

  • Add the remaining two functions below unlinked_landmarks:
def models_without_level(G):
    """Return models not joined to exactly one Marr level via is-at-level."""
    bad_models = []
    for node, attrs in G.nodes(data=True):
        if attrs.get("type") != "model":
            continue
        level_edges = [
            target for _, target, data in G.out_edges(node, data=True)
            if data.get("type") == "is-at-level"
        ]
        if len(level_edges) != 1:
            bad_models.append((node, len(level_edges)))
    return bad_models


def oversight_notes_on_path(G, path):
    """Return oversight notes for any node on the path with a scope_flag."""
    notes = []
    for node in path:
        attrs = G.nodes[node]
        flag = attrs.get("scope_flag")
        if flag == "animal":
            notes.append(
                f"{node}: This node names an animal procedure. "
                "Real animal research requires IACUC approval and proper supervision."
            )
        elif flag == "human":
            notes.append(
                f"{node}: This node names a human procedure. "
                "Real human research requires IRB approval."
            )
    return notes

What do these functions do?

  • models_without_level finds any model node that lacks exactly one is-at-level edge. A model with zero or two levels has an undecided level of explanation.
  • oversight_notes_on_path checks each node on a path for a scope_flag and returns the appropriate ethics oversight note. This function is used by the export step later.
  • Save queries.py.
  • In test_graph.py, update the import from queries to include the two new query functions:
from queries import (
    scale_path,
    methods_recording_spike_times,
    unlinked_landmarks,
    models_without_level,
    path_crosses_scales_in_order,
)
  • Below the TestRecordedVsInferred class, add this final test class:
class TestLandmarkCoverage:
    def test_no_unlinked_landmarks(self, graph):
        unlinked = unlinked_landmarks(graph)
        assert unlinked == [], (
            f"Landmarks with no content edge (only scale/subfield): {unlinked}. "
            "A landmark returned was collected but never attached to a build."
        )

    def test_all_models_have_exactly_one_level(self, graph):
        bad = models_without_level(graph)
        assert bad == [], (
            f"Models without exactly one is-at-level edge: {bad}. "
            "A model returned has an undecided level of explanation."
        )

What do these tests assert?

  • test_no_unlinked_landmarks expects an empty list. Any landmark that only has scale and subfield edges was placed but never wired into the graph's content structure.
  • test_all_models_have_exactly_one_level expects an empty list. Every model must sit at exactly one of Marr's three levels.

Before you run the full suite, predict: do you think every landmark in your YAML has at least one content edge beyond its scale and subfield assignments?

  • Save test_graph.py and run the entire test suite:
pytest test_graph.py -v

You should see all tests pass green: 3 integrity tests, 2 scale-path tests, 6 recorded-vs-inferred tests (1 count + 5 parametrized), and 2 landmark-coverage tests. That is 13 assertions total.

Seeing unlinked landmarks or models without a level?

  • If a landmark shows as unlinked, it needs at least one underlies, explains, measured-by, inferred-from, or reproduced-by edge (incoming or outgoing) in addition to its scale and subfield edges.
  • If a model appears in the bad list with count 0, it has no is-at-level edge. Add one connecting it to computational_level, algorithmic_level, or implementational_level.
  • Fix the YAML, re-run pytest test_graph.py -v, and iterate until all 13 tests pass.

Help me trace my unlinked landmark or missing model level

✔️ Awesome, I've got everything!

Confirm you have saved both queries.py and test_graph.py.

ⓧ I'd like to double check the full code

Both files exceed 30 lines, so confirm each section is present by scrolling through your code:

  • queries.py should contain, in order: the docstring and import networkx as nx, then scale_path, scale_of_node, path_crosses_scales_in_order, methods_recording_spike_times, methods_inferred_spike_times, unlinked_landmarks, models_without_level, oversight_notes_on_path.
  • test_graph.py should contain, in order: imports from loader, checks, and queries, then the three fixtures (graph, meta, vocabulary), then TestIntegrity, TestScalePath, TestRecordedVsInferred, TestLandmarkCoverage.
  • Commit your changes and push to the remote by running:
git add queries.py test_graph.py
git commit -m "Add query pack with expected answers and pytest assertions"
git push

Your query pack now proves the graph is not just structurally sound but correctly typed. Next up, you will export the graph as a browsable visualization and a 20-card deck, then close the map and score yourself cold.

Export the Visualization, Run Cold, and Write Documentation

Your graph passes every integrity check and query assertion. But a graph that stays in the terminal has not proven it teaches anything.

In this step, you will export the graph as a browsable interactive HTML file, generate a 20-card flashcard deck, run those cards cold with the map closed, trace every miss, and write the documentation that ships with the repo.

In this step, get ready to:
  • Export an interactive HTML visualization with colored nodes, hover labels, and scope-flag annotations.
  • Generate a 20-card CSV deck and run the cold-run scorer to hit a bar of 17/20.
  • Write all documentation pages and commit everything to the repo.
Export the browsable HTML visualization

The pyvis library builds self-contained HTML files from a NetworkX DiGraph. Each node gets a color by type, a hover title with its definition, and a scope-flag annotation for oversight.

  • Open export.py in your editor. Add the imports and the color map by pasting this code:
"""Export the graph as a browsable HTML visualization and a 20-card CSV deck."""

import csv
import random
from pathlib import Path

from pyvis.network import Network

from loader import load_graph, load_meta


# One color per node type for visual grouping
NODE_COLORS = {
    "marr_level": "#e63946",
    "scale_layer": "#457b9d",
    "subfield": "#2a9d8f",
    "landmark": "#e9c46a",
    "method": "#f4a261",
    "model": "#264653",
}

What does this code do?

The imports bring in csv for deck export, random for shuffling edges, and Network from pyvis for the interactive visualization.

NODE_COLORS maps each of the 6 node types to a distinct hex color. When you open the HTML, you can instantly tell a landmark (gold) from a model (dark teal) from a method (orange).

  • Below the color map, add the export_html function:
def export_html(G, output_path="neuroscience_map.html"):
    """Export an interactive pyvis HTML visualization."""
    net = Network(
        height="750px",
        width="100%",
        directed=True,
        cdn_resources="remote",
        select_menu=True,
        filter_menu=True,
    )

    for node_id, attrs in G.nodes(data=True):
        node_type = attrs.get("type", "landmark")
        color = NODE_COLORS.get(node_type, "#cccccc")
        label = attrs.get("label", node_id)
        title = f"{label}\nType: {node_type}"
        if attrs.get("definition"):
            title += f"\n{attrs['definition']}"
        if attrs.get("scope_flag") == "animal":
            title += "\n[IACUC approval required for animal research]"
        elif attrs.get("scope_flag") == "human":
            title += "\n[IRB approval required for human research]"
        net.add_node(node_id, label=label, title=title, color=color)

    for source, target, data in G.edges(data=True):
        edge_type = data.get("type", "")
        net.add_edge(source, target, title=edge_type, label=edge_type)

    net.write_html(output_path, open_browser=False)
    print(f"Browsable graph exported to {output_path}")

What does this code do?

The function creates a pyvis Network with direction, selection menu, and filter menu enabled. It iterates every node, assigns a color based on type, and builds a hover title that includes the definition and any ethics oversight note.

Nodes with scope_flag: animal get an IACUC annotation. Nodes with scope_flag: human get an IRB annotation. Edges carry their type as a visible label.

  • Save export.py.
  • Test the HTML export by running this command:
python -c "from loader import load_graph; from export import export_html; export_html(load_graph())"
  • Open neuroscience_map.html in your browser. You should see an interactive graph with colored nodes you can drag, zoom, and filter.

Don't see colored nodes?

  • Confirm NODE_COLORS keys match the exact type strings in your YAML (marr_level, scale_layer, subfield, landmark, method, model).
  • Check that cdn_resources="remote" is set. Without it, pyvis expects local vis.js files that may not exist.
  • help me debug my pyvis export
Export the 20-card deck and run cold

The deck turns your graph into spaced retrieval material. Each card tests one edge: what type connects this node to that one, what level a model sits at, whether a method records or infers.

  • In export.py, below the export_html function, add the first half of generate_deck:
def generate_deck(G, n=20):
    """Generate n flashcard pairs from the graph edges."""
    cards = []
    edges = list(G.edges(data=True))
    random.shuffle(edges)

    for source, target, data in edges:
        if len(cards) >= n:
            break
        edge_type = data.get("type", "")
        source_label = G.nodes[source].get("label", source)
        target_label = G.nodes[target].get("label", target)

        if edge_type == "is-at-level":
            front = f"At what level of analysis does {source_label} sit?"
            back = target_label
        elif edge_type == "is-at-scale":
            front = f"What scale layer is {source_label} at?"
            back = target_label
        elif edge_type == "measured-by":
            front = f"What edge type connects {source_label} to {target_label}?"
            back = "measured-by (direct recording)"
        elif edge_type == "inferred-from":
            front = f"Is {target_label} a direct recording of {source_label}?"
            back = f"No. inferred-from. Intermediate: {data.get('intermediate_signal', 'unknown')}"

What does this code do?

The function shuffles all edges so the deck spans different types. For each edge, it formats a front (question) and back (answer) pair based on the edge type. The measured-by and inferred-from cases directly test the sharpest distinction in the graph: does this method record spike times, or does it infer them through an intermediate signal?

  • Continue the generate_deck function with the remaining edge types, then add export_deck and the __main__ block:
        elif edge_type == "explains":
            front = f"What model explains {target_label}?"
            back = source_label
        elif edge_type == "underlies":
            front = f"What does {source_label} underlie?"
            back = target_label
        elif edge_type == "belongs-to":
            front = f"What subfield does {source_label} belong to?"
            back = target_label
        else:
            front = f"What is the relationship from {source_label} to {target_label}?"
            back = edge_type

        cards.append((front, back))

    return cards[:n]


def export_deck(G, output_path="deck.csv", n=20):
    """Export a semicolon-delimited CSV deck for Anki import."""
    cards = generate_deck(G, n)
    with open(output_path, "w", newline="", encoding="utf-8") as f:
        writer = csv.writer(f, delimiter=";")
        for front, back in cards:
            writer.writerow([front, back])
    print(f"Deck of {len(cards)} cards exported to {output_path}")


if __name__ == "__main__":
    G = load_graph()
    export_html(G)
    export_deck(G)

What does this code do?

The remaining branches handle explains, underlies, belongs-to, and a catch-all for any other edge type. After the loop, the list is trimmed to exactly n cards.

export_deck writes a semicolon-delimited CSV that Anki imports directly. The __main__ block runs both exports in sequence so a single python export.py produces the HTML and the deck.

  • Save export.py.

✔️ Awesome, I've got everything!

Great. Confirm you have saved export.py and the file contains four sections in this order: imports and NODE_COLORS, export_html, generate_deck, export_deck with the __main__ block.

ⓧ I'd like to double check the full code

Scroll up and confirm your export.py contains all four chunks in this order:

  • Chunk 1: Imports (csv, random, Path, Network, load_graph, load_meta) plus the NODE_COLORS dictionary with 6 entries.
  • Chunk 2: export_html(G, output_path) building a pyvis Network, adding colored nodes with scope-flag titles, adding labeled edges, writing HTML.
  • Chunk 3: First half of generate_deck handling is-at-level, is-at-scale, measured-by, inferred-from.
  • Chunk 4: Second half of generate_deck (explains, underlies, belongs-to, catch-all, return), plus export_deck and __main__.
  • Run the full export script:
python export.py
  • You should see two lines printed:
Browsable graph exported to neuroscience_map.html
Deck of 20 cards exported to deck.csv
  • Open deck.csv and confirm it has 20 rows with semicolons separating a front question from a back answer.

Seeing fewer than 20 cards?

  • If your graph has fewer than 20 edges total, generate_deck returns whatever it finds. Check your YAML has enough edges (the Complete Code has well over 60).
  • If the deck file is empty, confirm the __main__ block calls export_deck(G) after export_html(G).
  • help me debug deck generation

Now write the cold-run scorer. This script presents each card in the terminal, waits for your typed answer, and lets you self-judge each response.

  • Open cold_run.py and add the card loader and presentation loop:
"""Interactive cold-run scorer. Presents 20 cards, scores answers, traces misses."""

import csv


def run_cold(deck_path="deck.csv"):
    """Run the cold deck interactively in the terminal."""
    cards = []
    with open(deck_path, "r", encoding="utf-8") as f:
        reader = csv.reader(f, delimiter=";")
        for row in reader:
            if len(row) >= 2:
                cards.append((row[0], row[1]))

    if not cards:
        print("No cards found. Run export.py first.")
        return

    correct = 0
    misses = []

    print(f"\n{'='*60}")
    print("COLD RUN: Answer with the map closed.")
    print(f"{'='*60}\n")

    for i, (front, back) in enumerate(cards, 1):
        print(f"Card {i}/{len(cards)}: {front}")
        answer = input("Your answer: ").strip()
        print(f"Expected:   {back}")

What does this code do?

The script loads the deck CSV, then loops through each card. It prints the front (question), waits for you to type your answer, then reveals the expected back. You have not scored yet. The next chunk handles scoring.

  • Continue cold_run.py with the scoring logic, miss trace, and entry point:
        score = input("Correct? (y/n): ").strip().lower()
        if score == "y":
            correct += 1
            print("  [CORRECT]\n")
        else:
            misses.append((i, front, back, answer))
            print("  [MISS]\n")

    print(f"\n{'='*60}")
    print(f"SCORE: {correct}/{len(cards)}")
    print(f"BAR:   17/{len(cards)}")
    if correct >= 17:
        print("PASSED")
    else:
        print("NOT YET - trace misses and rebuild")
    print(f"{'='*60}\n")

    if misses:
        print("MISSES TO TRACE:")
        for num, front, back, given in misses:
            print(f"  Card {num}: {front}")
            print(f"    Expected: {back}")
            print(f"    Given:    {given}")
            print(f"    Action:   Find the node or edge in neuroscience_graph.yaml and verify.\n")


if __name__ == "__main__":
    run_cold()

What does this code do?

After revealing the expected answer, the script asks you to self-judge with y or n. A y increments the score. An n appends to the miss list with the card number, question, expected answer, and what you gave.

At the end, it prints your score against the bar of 17/20. If you missed any, each miss prints an action item telling you which node or edge to find in neuroscience_graph.yaml and verify.

  • Save cold_run.py.

✔️ Awesome, I've got everything!

Great. Confirm cold_run.py is saved and contains the full run_cold function plus the __main__ block.

ⓧ I'd like to double check the full code

Scroll up and confirm your cold_run.py contains both chunks in order:

  • Chunk 1: Imports, run_cold function start through the for loop that prints each card front, collects the answer, and reveals the expected back.
  • Chunk 2: Scoring (y/n input), final tally with 17/20 bar, miss trace with action items, and the __main__ block.

Before you run the cold deck, close every file showing the graph structure. Close neuroscience_graph.yaml, close topology.md, close the HTML visualization in your browser. The point is retrieval from memory, not recognition from a reference.

Do you think you can answer 17 of 20 questions about a 60-node graph you built today, from memory alone?

  • Run the cold-run scorer:
python cold_run.py

The terminal presents each question one at a time. Type your best answer, then honestly self-judge. The bar is 17 of 20.

What if I score below 17?

The script prints every miss with its card number, expected answer, and an action item. Open neuroscience_graph.yaml and find the node or edge that the missed card referenced. Check: is the edge type correct? Is the scale assignment right? Is the node wired to the right subfield?

Fix any structural errors you find. Then re-run pytest test_graph.py -v to confirm the graph still passes all integrity and query checks. Re-export with python export.py and run python cold_run.py again until you hit 17.

Write the documentation pages

The docs/ folder already has empty files from Step 1. Each page serves a distinct purpose. Use your AI client in agent mode to write them, providing the context below for each.

  • Write docs/what-i-was-learning.md: why this build exists and what problem it solves. State that the graph turns neuroscience from disconnected terms into a queryable structure.
  • Write docs/build-and-run.md: how to install (pip install the four packages), load the graph (python -c "from loader import load_graph; ..."), run tests (pytest test_graph.py -v), and export (python export.py).
  • Write docs/worked-example.md: walk through one query (e.g. the scale path from ion_channel to spatial_navigation) from expected answer to result to interpretation of what a wrong answer would mean.
  • Write docs/self-check.md: every pytest assertion with its expected value and what a wrong answer reveals (orphan means term never placed, illegal edge means feeling not claim, path too short means shortcut edge).
  • Write docs/cold-run-result.md: your actual score, every miss, the trace for each miss (which node or edge was wrong), and what you rebuilt. This must reflect your real run, not a fabricated result.
  • Write docs/source-map.md: which nodes came from which anchor text. Record the edition (Kandel 6e, Dayan and Abbott 1st) and how you accessed it (library copy, personal copy, institutional access).
  • Write docs/retrieval-prompts.md: 10 spaced retrieval prompts derived from graph queries. These are questions you can answer from the graph without opening it.
  • Write docs/decisions.md: decision records for NetworkX over Neo4j, YAML over JSON, pyvis over graphviz, pytest over notebook assertions, closed vocabulary over free-form edges. Each names the alternative, the tradeoff accepted, and what would reverse the choice.
  • Write docs/presentation.md: a two-audience readout (technical and non-technical) with lessons learned, what worked, and what would change on a second pass.

What voice should the documentation use?

Write as a learner: plain, concrete, honest. The graph confers structure and not a credential. State plainly that a doctorate in neural computation is a real, funded, mentored programme conferring apprenticeship, ethics oversight, and a defended dissertation that self-study does not. Nothing here is clinical advice.

A count over cards you authored is evidence of your own recall, not a finding about the field. Keep that distinction clear throughout.

  • Stage and commit everything by running these commands:
git add export.py cold_run.py neuroscience_map.html deck.csv docs/
git commit -m "Add exports, cold-run scorer, and documentation"
  • Push to your GitHub repo:
git push
  • Confirm the push succeeded by opening your repo in a browser. You should see export.py, cold_run.py, neuroscience_map.html, deck.csv, and all 9 documentation pages in the docs/ folder.

Documentation pages feel too generic?

  • Make sure cold-run-result.md contains your actual score and actual misses. A fabricated perfect run teaches nothing about retrieval.
  • For decisions.md, name the reversal condition for each choice. A decision record without a reversal condition is just a preference statement.
  • help me write better documentation

Every node is colored, every edge is labeled, the deck tests the distinctions that matter, and the cold run proved what stuck. Your graph is no longer just text in a YAML file.

Next up: the Secret Mission challenges you to thicken one branch with a follow-on sub-domain and prove the architecture is extensible by keeping all checks green.

Secret mission

Thicken a Branch and Write the Teach-Back

A graph that never grows after its first hour is a one-time exercise, not a living system. Prove your architecture is extensible by adding a sub-domain, keeping all checks green, and writing a plain-language explanation that proves comprehension rather than copying.

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 free-tier GitHub and Linear, so there are no ongoing costs.

Resources you used:

  • Local project folder neuro-concept-graph on your Desktop with all Python files, YAML source, HTML visualization, CSV deck, and documentation.
  • GitHub private repository neuro-concept-graph with all commits and the v1.0.0 tag.
  • Linear project board with completed tickets.
  • Python packages installed: networkx, pyvis, PyYAML, and pytest.

Keep everything running

No action needed. Choose this if you plan to keep building on the graph, adding new branches, or running spaced retrieval from the deck.

  • Your local files stay in ~/Desktop/neuro-concept-graph.
  • Your GitHub repo stays private and tagged.
  • Your Linear project stays available for future tickets.
  • Installed Python packages remain available for any Python project.

Pause - I'll come back to this later

Free up local disk space while keeping your remote repo intact. You can re-clone and reinstall packages any time.

  • Confirm your latest work is pushed to GitHub by running:
cd ~/Desktop/neuro-concept-graph
git status

If you see uncommitted changes, commit and push them before removing the local folder.

  • Remove the local folder by running:
cd ~/Desktop
rm -rf neuro-concept-graph
  • Your GitHub repo and Linear project stay as-is. When you return, clone the repo again and run pip install networkx pyvis PyYAML pytest to restore your environment.

Delete - I don't want to use this again

Remove all project resources completely. This cannot be undone.

  • Delete the local project folder by running:
cd ~/Desktop
rm -rf neuro-concept-graph
  • Delete the GitHub repository. Open your neuro-concept-graph repo in a browser, click the Settings tab, scroll to the bottom to the Danger Zone section, and click Delete this repository. Confirm the deletion when prompted.
  • Delete the Linear project. Open your project in Linear, click the project name to open project settings, and select the option to delete the project. Confirm when prompted.
  • Uninstall the Python packages by running:
pip uninstall networkx pyvis PyYAML pytest -y
  • Confirm the packages are removed by running:
pip show networkx pyvis pyyaml pytest

You should see a warning for each package indicating it is not installed.

Well Done!

Well Done!

You built a validated learning system that turns neuroscience from a pile of disconnected terms into a typed, queryable structure you scored yourself against cold.

You've learned how to:

  • Build a typed directed graph with approximately 60 nodes across 6 types and a closed edge vocabulary of 8, prove it structurally sound with pytest integrity checks that reject orphan nodes, illegal edges, and unreachable subgraphs.
  • Write a query pack with expected answers first that exposes typing errors: a scale path of exactly 3 edges across 4 scale layers, a recorded-vs-inferred distinction that rejects all 5 absence-list methods, and landmark and model coverage assertions that catch every unlinked node.
  • Export a browsable pyvis HTML visualization and a 20-card Anki-importable deck from a single YAML source file, then score a cold run of at least 17/20 with the map closed, trace every miss to a structural error, and rebuild.
  • Secret Mission: Thickened one branch with new nodes, proved all checks still pass, and wrote a teach-back page proving the structure was understood.

Ready to quiz yourself?