Residential Design Knowledge Graph

Build a typed, queryable graph of residential house design in Python.

Introduction

30 Second Summary

A house is a system of interconnected parts. Loads travel from roof to soil through an unbroken chain of members. Credentials unlock routes to practice. Code provisions draw a boundary between what you can size from a table and what needs an engineer. Knowing the names is not the same as being able to answer questions about them cold.

In this project, you will build a typed Python knowledge graph encoding the full scope of residential house design. You will query it, test it, export it, and prove what you know by answering retrieval prompts with the map closed.

What You'll Build

You will type a question into your terminal and watch your graph trace the gravity load path from roof assembly to bearing soil through four members, then run a test suite that proves the answer was written before the query ran.

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

  • A queryable knowledge graph with 9 node kinds and 8 edge types that answers structural questions about houses, from "how does a roof load reach the ground" to "which route lets you design a house" and "what does the prescriptive path exclude."
  • A pytest evidence suite of 5 queries where every expected answer is written first as a fixture, so a query that passes on emptiness is caught and a count over your own inventory is labelled honestly.
  • Exported artifacts you can browse and drill: a Mermaid topology diagram, a retrieval card deck, and documentation pages carrying the life-safety framing, the honest-route statement, and the source map.
  • Secret Mission: Run a scored cold retrieval with 20 prompts answered map-closed, trace every miss to its source node, and teach back what the score proves.

Are there any prerequisites?

You need Python installed (any version that satisfies the NetworkX requirement of >=3.11), pip, a code editor (Cursor recommended), Git, a GitHub account, and a Linear workspace. You should be comfortable with Python dicts, functions, and running tests from the terminal.

Before We Start

Before any code runs, you need to commit to what you are building and why it matters for your learning path. A typed knowledge graph answers structural questions that a flat list of names never could.

Set Up the Workspace

The graph, its tests, and its exports all need a Python environment with NetworkX for the typed graph and pytest for the evidence suite. The delivery needs a repo and a ticket board.

In this step, you'll scaffold the project folder, install both packages, and write the closed vocabulary that constrains what the graph can hold. Nothing enters the graph unless it passes through these frozen sets.

In this step, get ready to:
  • Create the project folder, initialise Git, and push to a private GitHub repo.
  • Install NetworkX and pytest.
  • Define the closed vocabulary schema with 9 node kinds and 8 edge types.
Create the project folder and repository

Your graph will live in a single folder with clear subdirectories for source code, tests, exports, and documentation. You'll track everything with Git and push to a private GitHub repo.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder, move into it, and initialise a Git repository by running these commands:
mkdir whole-house-graph
cd whole-house-graph
git init
  • Create the four subdirectories the project needs by running:
mkdir src tests exports docs
  • Confirm the folders exist by running:
ls

You should see src, tests, exports, and docs in the output.

Don't see the four folders?

Make sure you ran mkdir src tests exports docs from inside the whole-house-graph folder. Run pwd to check your current directory.

Help me figure out why my folders didn't get created.

  • Open your browser and go to GitHub.
  • Click the + icon in the top-right corner and select New repository.
  • Enter whole-house-graph as the repository name.
  • Select Private for the visibility.
  • Leave all other options unchecked (no README, no .gitignore, no license).
  • Click Create repository.
  • Back in your terminal, connect the remote and push by running these commands:
git add .
git commit -m "Initial project structure"
git branch -M main
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/whole-house-graph.git"]]
git push -u origin main

What do these commands do?

  • git add . stages the empty directories (Git tracks them once they have content).
  • git commit saves the current state as a snapshot.
  • git remote add origin connects your local repo to the private GitHub repo you just created.
  • git push -u origin main sends your commit to GitHub and sets the default upstream.

Push rejected or authentication failed?

If Git asks for credentials, GitHub requires a personal access token or SSH key rather than a password. Check that you've set up authentication with GitHub before pushing.

Help me fix my GitHub push authentication issue.

  • Open Linear in your browser.
  • Create a new project called Whole House Graph.
  • Create one ticket for each remaining step: Schema, Inventory, Integrity, Queries, Exports, Cold-run.

Why a ticket board?

Each ticket represents one unit of shipped work. You'll close tickets as their pull requests merge. This gives you a visible trail of progress and forces each step to produce a concrete deliverable.

Install the dependencies

NetworkX gives you the MultiDiGraph that holds typed nodes and edges with per-edge attributes. Pytest gives you the evidence suite that makes each query falsifiable.

  • Install both packages by running:
pip install networkx==3.6.1 pytest==9.1.1

You'll see pip download and install each package along with its dependencies.

  • Confirm both packages are available by running:
python -c "import networkx; import pytest; print(networkx.__version__, pytest.__version__)"

You should see 3.6.1 9.1.1 printed.

Seeing ModuleNotFoundError?

Make sure you ran pip install with the same Python interpreter you're testing with. On macOS, you may need pip3 and python3 instead. Run which python and which pip to confirm they point to the same installation.

Help me figure out why Python cannot find networkx or pytest after I installed them.

Define the closed vocabulary

Every node in the graph will carry exactly one kind from a frozen set. Every edge will carry exactly one type from a frozen set. Nothing outside these sets is allowed.

This is what makes the graph a structure you can query, not a bag of names someone might edit on a whim.

  • Create a new file called schema.py inside the src/ folder and add the following code:
"""Closed vocabulary for the whole-house knowledge graph.

STUDY MOCK-UP. Not a stamped construction document a licensed
professional has sealed and is answerable for. A real house needs
the adopted code and the licensed professionals its jurisdiction
requires.
"""

NODE_KINDS = frozenset([
    "stage",
    "subfield",
    "route",
    "credential",
    "element",
    "design-decision",
    "code-item",
    "method",
    "build",
])

What does this code do?

The docstring at the top is the life-safety header. It declares this is a study mock-up, not professional advice.

NODE_KINDS is a frozenset of the 9 allowed node categories. A frozen set is immutable. Once defined, no code can add or remove kinds at runtime. The builder will reject any node whose kind is not in this set.

  • Below the NODE_KINDS definition, add the 8 edge types:
EDGE_TYPES = frozenset([
    "precedes",
    "belongs-to",
    "carries-load-to",
    "covered-by",
    "needs-engineered-design",
    "requires",
    "permits",
    "exercised-by",
])

What does this code do?

EDGE_TYPES locks down the 8 relationships the graph can express. Each edge type represents a specific structural claim: carries-load-to traces gravity, covered-by maps prescriptive coverage, precedes orders stages. Any edge with a type outside this set will trigger a ValueError at build time.

  • Save src/schema.py and confirm the two frozen sets import correctly by running:
python -c "import sys; sys.path.insert(0, 'src'); from schema import NODE_KINDS, EDGE_TYPES; print(len(NODE_KINDS), 'node kinds,', len(EDGE_TYPES), 'edge types')"

You should see 9 node kinds, 8 edge types printed.

  • Back in src/schema.py, add the element scales below EDGE_TYPES:
ELEMENT_SCALES = frozenset([
    "material",
    "member",
    "assembly",
    "dwelling",
    "site",
])

What does this code do?

ELEMENT_SCALES defines the 5 physical scales an element node can occupy. A rafter is a member. Concrete is a material. The dwelling is the whole building. This lets you filter and query by scale when tracing load paths or counting what's been modelled.

  • Save src/schema.py.

Before you run the final check, predict what len(ELEMENT_SCALES) will return. How many scales did you just define?

  • Run the full vocabulary check:
python -c "import sys; sys.path.insert(0, 'src'); from schema import NODE_KINDS, EDGE_TYPES, ELEMENT_SCALES; print(len(NODE_KINDS), 'kinds,', len(EDGE_TYPES), 'types,', len(ELEMENT_SCALES), 'scales')"

You should see 9 kinds, 8 types, 5 scales printed. All three frozen sets are locked and ready for the builder.

Seeing a SyntaxError or ImportError?

Check that each frozenset block ends with ]) on its own line. A missing comma between string entries or an unclosed bracket are the most common causes.

Help me debug my schema.py import error.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/schema.py and that the import prints 9 kinds, 8 types, 5 scales.

ⓧ I'd like to double check the full code

Your src/schema.py should contain these three sections in order. Scroll up and confirm each is present:

  • The docstring (life-safety header, 7 lines).
  • NODE_KINDS frozen set with exactly 9 strings.
  • EDGE_TYPES frozen set with exactly 8 strings.
  • ELEMENT_SCALES frozen set with exactly 5 strings.

Your workspace is ready. You have a clean repo, typed vocabulary, and a ticket board with six deliverables. Next up, you'll write the source file that fills this vocabulary with real residential design content and build the graph from it.

Write the Source File and Build the Graph

Your graph needs a domain to encode. Right now the project has a closed vocabulary (9 node kinds, 8 edge types) but zero content. The domain knowledge has to live somewhere a human can read, edit, and diff without running code.

In this step you will write that domain into a plain-text source file, then build a parser that converts it into a NetworkX MultiDiGraph with validated types on every node and edge. Until this runs, there is nothing to query.

In this step, get ready to:
  • Create the plain-text inventory file with typed nodes and edges.
  • Build the graph parser that converts the inventory into a typed MultiDiGraph.
  • Commit to a feature branch and open a PR against main.
Create the plain-text inventory file

The inventory file uses a pipe-delimited format with two sections: [nodes] and [edges]. Each node line is id | kind | optional-attrs. Each edge line is source | target | type.

  • Create a new file at src/inventory.txt and paste the following content, starting with the life-safety header and the stage nodes:
# STUDY MOCK-UP. Not a stamped construction document a licensed
# professional has sealed and is answerable for.

[nodes]
# stages
programming | stage
schematic-design | stage
design-development | stage
construction-documents | stage
permitting | stage
construction | stage
occupancy | stage
  • Below the stages, add the element nodes. These represent physical building parts at five scales (site, assembly, member, material, dwelling):
# elements
bearing-soil | element | scale=site
footing | element | scale=assembly
foundation-wall | element | scale=assembly
exterior-stud-wall | element | scale=assembly
floor-platform | element | scale=assembly
roof-assembly | element | scale=assembly
roof-assembly-2f | element | scale=assembly
upper-exterior-stud-wall | element | scale=assembly
lower-exterior-stud-wall | element | scale=assembly
rafter | element | scale=member
top-plate | element | scale=member
stud | element | scale=member
sole-plate | element | scale=member
sheathing | element | scale=material
dimension-lumber | element | scale=material
concrete | element | scale=material
dwelling | element | scale=dwelling
  • Continue adding the professional routes, credentials, code items, and design decisions:
# routes
licensed-architect | route
building-designer | route
academic | route

# credentials
naab-degree | credential
axp-internship | credential
are-exam | credential
state-registration | credential
ncbdc-credential | credential
jurisdiction-exemption | credential
doctoral-degree | credential

# code-items
irc-2024 | code-item
prescriptive-path | code-item
engineered-design-path | code-item
ibc-2024 | code-item

# design-decisions (excluded)
four-story-apartment-block | design-decision
beam-beyond-prescriptive-tables | design-decision
bracing-excluded-by-irregularity | design-decision
footing-on-expansive-soil | design-decision
# design-decisions (covered)
conventional-roof-framing | design-decision
standard-wall-bracing | design-decision
prescriptive-footing-sizing | design-decision
standard-floor-framing | design-decision
  • Add the computational methods, subfields, builds, and landmark nodes to complete the nodes section:
# methods
parametric-modeling | method
performance-simulation | method
model-exchange | method
generative-massing | method
prefab-dfma | method

# subfields
structure | subfield
envelope | subfield
environmental-systems | subfield
site-design | subfield
interior | subfield

# builds
light-frame-load-model | build
envelope-assembly-model | build
daylight-model | build
site-grading-model | build
interior-space-study | build

# landmark nodes
platform-framing-system | element | scale=assembly, landmark=true
irc-prescriptive-tables | code-item | landmark=true
gravity-load-path | element | scale=assembly, landmark=true

What are the 9 node kinds doing here?

Each kind maps to a domain concept. Stages model the design process sequence. Elements are physical building parts at multiple scales. Routes and credentials model who may design a house and what qualifications they need.

Code items are the actual building codes (IRC, IBC). Design decisions are choices the code either covers prescriptively or hands to an engineer. Methods, subfields, and builds model the computational frontier and the practice that exercises each area.

  • Now start the edges section. Add the stage sequence and the gravity load-path chains:
[edges]
# stage precedes
programming | schematic-design | precedes
schematic-design | design-development | precedes
design-development | construction-documents | precedes
construction-documents | permitting | precedes
permitting | construction | precedes
construction | occupancy | precedes

# one-story load path (missing exterior-stud-wall -> foundation-wall intentionally)
roof-assembly | exterior-stud-wall | carries-load-to
# exterior-stud-wall | foundation-wall | carries-load-to  <-- deliberately omitted
foundation-wall | footing | carries-load-to
footing | bearing-soil | carries-load-to

# two-story load path
roof-assembly-2f | upper-exterior-stud-wall | carries-load-to
upper-exterior-stud-wall | floor-platform | carries-load-to
floor-platform | lower-exterior-stud-wall | carries-load-to
lower-exterior-stud-wall | foundation-wall | carries-load-to
foundation-wall | footing | carries-load-to
footing | bearing-soil | carries-load-to

Why is one edge deliberately missing?

The one-story load path is missing the exterior-stud-wall -> foundation-wall edge. This is intentional. In Step 3 you will write an integrity checker, and this gap is what it will catch: a load with nowhere to go.

  • Add the professional-route edges, code-item relationships, and design-decision coverage:
# routes require credentials
licensed-architect | naab-degree | requires
licensed-architect | axp-internship | requires
licensed-architect | are-exam | requires
licensed-architect | state-registration | requires
building-designer | ncbdc-credential | requires
building-designer | jurisdiction-exemption | permits
academic | doctoral-degree | requires

# code-item belongs-to
prescriptive-path | irc-2024 | belongs-to
engineered-design-path | irc-2024 | belongs-to

# excluded design-decisions
four-story-apartment-block | engineered-design-path | needs-engineered-design
beam-beyond-prescriptive-tables | engineered-design-path | needs-engineered-design
bracing-excluded-by-irregularity | engineered-design-path | needs-engineered-design
footing-on-expansive-soil | engineered-design-path | needs-engineered-design

# covered design-decisions
conventional-roof-framing | prescriptive-path | covered-by
standard-wall-bracing | prescriptive-path | covered-by
prescriptive-footing-sizing | prescriptive-path | covered-by
standard-floor-framing | prescriptive-path | covered-by
  • Finish the edges section with subfield and landmark practice links plus element connectivity:
# subfields exercised-by builds
structure | light-frame-load-model | exercised-by
envelope | envelope-assembly-model | exercised-by
environmental-systems | daylight-model | exercised-by
site-design | site-grading-model | exercised-by
interior | interior-space-study | exercised-by

# landmark exercised-by builds
platform-framing-system | light-frame-load-model | exercised-by
irc-prescriptive-tables | light-frame-load-model | exercised-by
gravity-load-path | light-frame-load-model | exercised-by

# element connectivity for orphan prevention
rafter | roof-assembly | belongs-to
top-plate | exterior-stud-wall | belongs-to
stud | exterior-stud-wall | belongs-to
sole-plate | exterior-stud-wall | belongs-to
sheathing | exterior-stud-wall | belongs-to
dimension-lumber | rafter | belongs-to
concrete | footing | belongs-to
dwelling | foundation-wall | belongs-to
platform-framing-system | exterior-stud-wall | belongs-to

# ibc-2024 connectivity
ibc-2024 | engineered-design-path | belongs-to
  • Save src/inventory.txt.

What do the element connectivity edges do?

Members like rafter and stud are physical parts that belong to assemblies. Without a belongs-to edge connecting them to their parent assembly, they would be orphan nodes with no edges. The integrity checker in Step 3 rejects orphans because a node that is typed but never connected cannot be reached by any query.

Build the graph parser

The parser reads inventory.txt line by line, validates every node kind and edge type against the frozen sets in schema.py, and builds a MultiDiGraph where each node carries a kind attribute and each edge carries a type attribute.

  • Create src/build_graph.py and add the imports, function signature, and file-reading loop:
"""Parse inventory.txt and build a typed NetworkX MultiDiGraph."""

import sys
from pathlib import Path

import networkx as nx

from schema import NODE_KINDS, EDGE_TYPES


def parse_inventory(path: Path) -> nx.MultiDiGraph:
    """Read inventory.txt and return a typed MultiDiGraph."""
    G = nx.MultiDiGraph()
    section = None

    with open(path, "r", encoding="utf-8") as f:
        for raw_line in f:
            line = raw_line.strip()
            if not line or line.startswith("#"):
                continue
            if line.startswith("[") and line.endswith("]"):
                section = line[1:-1]
                continue

What does this code do?

  • The function creates an empty MultiDiGraph (a directed graph that allows parallel edges between the same pair of nodes).
  • It reads each line from the file, skipping blanks and comments.
  • Lines wrapped in brackets ([nodes], [edges]) switch the parser between the two sections.
  • Add the node-parsing and edge-parsing logic directly below the continue statement, still inside the for loop:
            if section == "nodes":
                parts = line.split("|")
                node_id = parts[0].strip()
                kind = parts[1].strip()
                attrs = {}
                if len(parts) > 2:
                    for kv in parts[2].split(","):
                        k, v = kv.strip().split("=", 1)
                        attrs[k.strip()] = v.strip()
                if kind not in NODE_KINDS:
                    raise ValueError(
                        f"Node '{node_id}' has invalid kind '{kind}'. "
                        f"Allowed: {sorted(NODE_KINDS)}"
                    )
                G.add_node(node_id, kind=kind, **attrs)

            elif section == "edges":
                parts = line.split("|")
                source = parts[0].strip()
                target = parts[1].strip()
                edge_type = parts[2].strip()
                if edge_type not in EDGE_TYPES:
                    raise ValueError(
                        f"Edge '{source} -> {target}' has invalid type "
                        f"'{edge_type}'. Allowed: {sorted(EDGE_TYPES)}"
                    )
                G.add_edge(source, target, type=edge_type)

    return G

What does this code do?

  • Node lines are split on | to extract the ID, kind, and optional attributes. The kind is validated against NODE_KINDS before the node is added. A typo raises a ValueError immediately.
  • Edge lines extract source, target, and type. The type is validated against EDGE_TYPES. Anything outside the closed vocabulary is rejected.
  • This means the graph can never contain an untyped node or an edge with a kind the schema does not define.
  • Add the main function below parse_inventory to wire up the entry point:
def main():
    inventory_path = Path(__file__).parent / "inventory.txt"
    G = parse_inventory(inventory_path)
    print(f"Graph built: {G.number_of_nodes()} nodes, {G.number_of_edges()} edges")
    return G


if __name__ == "__main__":
    main()

What does this code do?

The main function locates inventory.txt relative to the script's own directory, builds the graph, and prints a summary line with the node and edge counts. This gives you immediate feedback that the parser worked.

  • Save src/build_graph.py.

Before you run this, predict: how many nodes and edges do you expect the graph to have? Count the node lines in your inventory (should be over 40) and the edge lines (should be over 60).

  • Run the graph builder by executing this command from the src/ directory:
cd src && python build_graph.py

You should see output like Graph built: 53 nodes, 62 edges (your exact counts depend on your inventory). The important thing is N > 40 and M > 60.

Seeing a ValueError?

  • The error message names the exact node or edge that failed validation. Check the ID and kind/type for typos against the frozen sets in schema.py.
  • Make sure each line in inventory.txt uses the pipe | character as the delimiter, not a colon or comma.
  • Confirm that schema.py is in the same src/ directory as build_graph.py.

Why is my graph builder raising an error on a valid-looking line?

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/inventory.txt and src/build_graph.py.

ⓧ I'd like to double check the full code

Your src/build_graph.py should contain all three sections you built above, in this order:

  • Docstring, imports, and the parse_inventory function signature with the file-reading loop (skipping blanks, comments, switching sections).
  • The node-parsing branch (split on |, validate kind, add node) and the edge-parsing branch (split on |, validate type, add edge), ending with return G.
  • The main() function that locates inventory.txt, calls parse_inventory, and prints the node/edge count.

For src/inventory.txt, scroll through each section and confirm:

  • The [nodes] section has stages, elements, routes, credentials, code-items, design-decisions (excluded and covered), methods, subfields, builds, and landmark nodes.
  • The [edges] section has stage-precedes, load paths (with the one-story gap), routes-require-credentials, code-item belongs-to, excluded/covered design-decisions, subfield and landmark exercised-by, element connectivity, and ibc-2024 connectivity.
  • The line # exterior-stud-wall | foundation-wall | carries-load-to <-- deliberately omitted is commented out, not active.
Commit to a branch and open a PR

The inventory has one deliberate gap: the missing carries-load-to edge between exterior-stud-wall and foundation-wall. You will leave that gap in place so the integrity checker in Step 3 catches it.

  • Move back to the project root and create a feature branch by running:
cd .. && git checkout -b feat/inventory-and-builder
  • Stage and commit your changes by running:
git add src/inventory.txt src/build_graph.py
git commit -m "Add inventory source file and graph builder"
  • Push the branch and open a PR against main by running:
git push -u origin feat/inventory-and-builder
  • Open your GitHub repository in a browser.
  • Click **Compare & pull request** on the banner that appears.
  • Title the PR Add inventory source file and graph builder and click **Create pull request**.

Leave the PR open

Do not merge yet. In Step 3 you will run the integrity checker against this branch, watch it catch the missing edge, fix it, then merge.

  • In your Linear project, move the inventory/builder ticket to In Review.

Your domain is now encoded as structure, not a pile of names. Every node has exactly one kind from the closed vocabulary. Every edge has exactly one type. Next up, you will write the three integrity invariants that prove this graph holds together.

Run Integrity and Watch It Catch the Gap

Your graph has nodes and edges, but nothing guarantees they form a coherent structure. A node with zero connections is typed and never connected. An edge whose type slips outside the vocabulary is a feeling, not a claim anyone can query. A disconnected island is unreachable by any traversal.

In this step you build an integrity checker that enforces three invariants. You run it against the graph with the missing load edge from Step 2 and watch it catch the gap: the one-story chain from roof-assembly cannot reach bearing-soil because the load has nowhere to go.

In this step, get ready to:
  • Create src/integrity.py with three invariant checks: no orphans, closed vocabulary, full reachability.
  • Run the checker and watch it report the broken load chain.
  • Fix the missing edge, re-run, and see all invariants pass.
Write the integrity checks

The checker tests three properties that separate a queryable structure from a loose pile of nodes. Each function takes the graph and returns a list of failure messages. An empty list means the invariant holds.

  • Create a new file src/integrity.py and paste the imports and the first invariant check:
"""Three integrity invariants for the whole-house graph."""

import sys
from pathlib import Path

import networkx as nx

from build_graph import parse_inventory


def check_no_orphans(G: nx.MultiDiGraph) -> list[str]:
    """Every node must have at least one edge (in or out)."""
    failures = []
    for node in G.nodes():
        if G.degree(node) == 0:
            failures.append(f"Orphan node: '{node}' has no edges.")
    return failures

What does this code do?

  • The imports bring in parse_inventory from the builder you wrote in Step 2, plus networkx and sys for the exit code.
  • check_no_orphans iterates every node and flags any with degree 0. A node with no edges was typed but never connected to the rest of the graph.
  • Save integrity.py.
  • Confirm the file exists by running:
ls src/integrity.py

You should see src/integrity.py listed in the output.

  • Below check_no_orphans in src/integrity.py, add the vocabulary and reachability checks:

def check_closed_vocabulary(G: nx.MultiDiGraph) -> list[str]:
    """Every edge type attribute must be in the closed vocabulary."""
    from schema import EDGE_TYPES

    failures = []
    for u, v, data in G.edges(data=True):
        edge_type = data.get("type")
        if edge_type is None:
            failures.append(f"Edge '{u} -> {v}' has no type attribute.")
        elif edge_type not in EDGE_TYPES:
            failures.append(
                f"Edge '{u} -> {v}' has invalid type '{edge_type}'."
            )
    return failures


def check_reachability(G: nx.MultiDiGraph) -> list[str]:
    """The graph must be weakly connected (every node reachable)."""
    failures = []
    if not nx.is_weakly_connected(G):
        components = list(nx.weakly_connected_components(G))
        for i, comp in enumerate(components):
            if len(comp) < len(max(components, key=len)):
                failures.append(
                    f"Disconnected component {i}: {sorted(comp)}"
                )
    return failures

What do these two checks enforce?

  • check_closed_vocabulary reads every edge and confirms its type attribute is in the frozen set from schema.py. An edge outside the vocabulary is a feeling, not a claim anyone can query.
  • check_reachability uses nx.is_weakly_connected to test whether the underlying undirected graph is one connected piece. A disconnected component is an island that no traversal will ever reach.
  • Save integrity.py.
  • Below check_reachability, add the runner that calls all three checks and reports the result:

def run_all(G: nx.MultiDiGraph) -> bool:
    """Run all invariants. Print results. Return True if all pass."""
    all_failures = []
    all_failures.extend(check_no_orphans(G))
    all_failures.extend(check_closed_vocabulary(G))
    all_failures.extend(check_reachability(G))

    if all_failures:
        print(f"INTEGRITY FAILED ({len(all_failures)} issues):")
        for f in all_failures:
            print(f"  - {f}")
        return False
    else:
        print("All 3 invariants pass.")
        return True


def main():
    inventory_path = Path(__file__).parent / "inventory.txt"
    G = parse_inventory(inventory_path)
    success = run_all(G)
    sys.exit(0 if success else 1)


if __name__ == "__main__":
    main()

What does the runner do?

  • run_all collects failures from all three checks into one list. If any failures exist, it prints each one and returns False.
  • main builds the graph from inventory.txt, runs the checks, and exits with code 0 (success) or 1 (failure). The exit code lets CI pipelines catch integrity failures automatically.
  • Save integrity.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/integrity.py and you are ready to run it.

ⓧ I'd like to double check the full code

Scroll up and confirm these three chunks are present in order inside src/integrity.py:

  • Chunk 1: the docstring, imports (sys, Path, networkx, parse_inventory), and check_no_orphans.
  • Chunk 2: check_closed_vocabulary and check_reachability.
  • Chunk 3: run_all, main, and the if __name__ == "__main__" guard.
Run the checker and watch the gap surface

Your inventory.txt still has the deliberately omitted carries-load-to edge between exterior-stud-wall and foundation-wall. The one-story gravity chain is broken. The load from the roof assembly has nowhere to go after the stud wall.

Before you run the checker, predict: which of the three invariants do you think will catch this gap?

  • Run the integrity checker by running:
cd src && python integrity.py

You will see output reporting a failure. The reachability check catches that exterior-stud-wall is disconnected from the one-story load chain's path to bearing-soil. The graph is not weakly connected because the missing edge splits a component away from the main body.

Why does this matter?

A missing carries-load-to edge means gravity has no path from the stud wall to the foundation. In a real structure, that is a member typed and never connected. The integrity checker surfaced exactly what a list of names could never tell you: the chain is broken and the load has nowhere to go.

Not seeing a failure?

  • Make sure you are running from the src/ directory. The imports (from build_graph import parse_inventory) resolve relative to the working directory.
  • Confirm inventory.txt still has the comment line # exterior-stud-wall | foundation-wall | carries-load-to <-- deliberately omitted and no uncommented copy of that edge.

Why is my integrity checker not reporting a failure?

Fix the missing edge and verify all invariants pass

The checker proved the gap exists. Now you fix it by adding the missing edge back into the source file.

  • In src/inventory.txt, scroll to the one-story load path section.
  • Find this commented line:
# exterior-stud-wall | foundation-wall | carries-load-to  <-- deliberately omitted
  • Replace that comment with the actual edge:
exterior-stud-wall | foundation-wall | carries-load-to
  • Save inventory.txt.

What does this fix?

The one-story load chain now runs unbroken: roof-assembly to exterior-stud-wall to foundation-wall to footing to bearing-soil. Every gravity load from the roof can now reach soil through an unbroken chain of members.

Before you re-run, predict: do you think all three invariants will pass now?

  • Re-run the integrity checker:
python integrity.py

You should see:

All 3 invariants pass.

The graph has no orphans, every edge type is in the closed vocabulary, and every node is reachable from every other node.

Still seeing a failure?

  • Check that you removed the # comment character and the <-- deliberately omitted text. The line must be exactly exterior-stud-wall | foundation-wall | carries-load-to with no leading #.
  • Make sure the line sits inside the [edges] section of inventory.txt, not above it.

Help me fix my inventory so all three invariants pass.

  • Commit, push, and merge the PR to main by running:
cd ..
git add .
git commit -m "Add integrity checker, fix missing load edge"
git push
  • In GitHub, merge the pull request you opened in Step 2.
  • In Linear, close the integrity ticket.

Your graph now has a provable guarantee: every node connects, every edge is typed, and the whole structure is reachable. Next up, you write queries that answer questions the field sets.

Write the Query Pack with Pytest Fixtures

Your graph passes all three integrity invariants. Every node has at least one edge, every edge type is in the closed vocabulary, and the whole structure is reachable. But you still cannot ask it the questions the field sets: how does a roof load reach the ground, what does the prescriptive path cover, and where are the gaps in your build plan.

A query you interpret after the fact can never fail. Writing the expected answer first as a pytest fixture makes each query falsifiable. If a load-path query returns the wrong length, the assertion names the gap. If a coverage query passes on emptiness, the ordered steps catch it.

In this step, get ready to:
  • Write pytest fixtures with expected answers before queries run.
  • Test the gravity load path and prescriptive coverage boundaries.
  • Verify all 5 queries pass and state what each count is evidence of.
Create the shared fixture and load-path tests

Every test in the suite needs the same graph built from inventory.txt. A pytest fixture lets you define this once and inject it into every test function by name.

  • Create tests/conftest.py with the shared graph fixture by pasting this code:
"""Shared fixtures for the whole-house graph test suite."""

from pathlib import Path

import pytest

import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

from build_graph import parse_inventory


@pytest.fixture
def graph():
    """Build the graph from inventory.txt."""
    inventory_path = Path(__file__).parent.parent / "src" / "inventory.txt"
    return parse_inventory(inventory_path)

What does this code do?

The sys.path.insert line adds the src/ folder to Python's import path so tests can find build_graph without installing it as a package.

The @pytest.fixture decorator makes graph available to any test function that names it as a parameter. Every test gets a freshly built graph from inventory.txt.

  • Create tests/test_load_path.py with the module header and a helper function that filters edges by type:
"""Load path query: roof assembly to bearing soil through carries-load-to edges.

These lengths are regression checks on the inventory, not findings about
houses. The fact that every gravity load must reach bearing soil through
an unbroken chain is about houses; the number is about the graph.
"""

import networkx as nx


def edges_of_type(G, edge_type):
    """Return a subgraph containing only edges of the given type."""
    sub = nx.MultiDiGraph()
    sub.add_nodes_from(G.nodes(data=True))
    for u, v, data in G.edges(data=True):
        if data.get("type") == edge_type:
            sub.add_edge(u, v, **data)
    return sub

Why filter into a subgraph?

The graph has 8 different edge types. The load-path query only cares about carries-load-to edges. The edges_of_type helper builds a subgraph containing only edges of the requested type, so shortest_path follows only load-bearing connections.

  • Confirm both files exist by running:
ls tests/

You should see conftest.py and test_load_path.py listed.

  • Add the two test functions below the helper in tests/test_load_path.py:
def test_load_path_one_story(graph):
    """One-story gravity chain: 4 carries-load-to edges from roof to soil."""
    sub = edges_of_type(graph, "carries-load-to")
    path = nx.shortest_path(sub, "roof-assembly", "bearing-soil")
    edge_count = len(path) - 1
    assert edge_count == 4, (
        f"Expected 4 edges in one-story load path, got {edge_count}. "
        f"Path: {' -> '.join(path)}"
    )
    assert "footing" in path, (
        "Load path must pass through footing. A path reaching soil "
        "without a footing skipped the foundation."
    )


def test_load_path_two_story(graph):
    """Two-story gravity chain: 6 carries-load-to edges from roof to soil."""
    sub = edges_of_type(graph, "carries-load-to")
    path = nx.shortest_path(sub, "roof-assembly-2f", "bearing-soil")
    edge_count = len(path) - 1
    assert edge_count == 6, (
        f"Expected 6 edges in two-story load path, got {edge_count}. "
        f"Path: {' -> '.join(path)}"
    )
    assert "floor-platform" in path, (
        "Two-story load path must pass through floor-platform."
    )

What does each test assert?

  • test_load_path_one_story: the shortest chain from roof-assembly to bearing-soil is exactly 4 edges, and footing appears in the path. A path that skips footing means the load jumped over the foundation.
  • test_load_path_two_story: the chain from roof-assembly-2f is exactly 6 edges, and floor-platform appears. A missing floor platform means the second story has nowhere to stand.
  • Both path lengths are regression checks on the inventory. The numbers depend on what you typed. The principle that every gravity load must reach bearing soil through an unbroken chain is about houses.
  • Save tests/test_load_path.py.
  • Run the load-path tests by running:
pytest tests/test_load_path.py -v

You should see both tests pass with PASSED next to each test name.

Seeing ModuleNotFoundError?

  • Check that tests/conftest.py exists and contains the sys.path.insert line pointing at your src/ folder.
  • Make sure you are running pytest from the whole-house-graph project root, not from inside tests/ or src/.
  • Confirm that src/build_graph.py and src/schema.py both exist from the earlier steps.

Why can't pytest find my modules?

✔️ Awesome, I've got everything!

Double check you have saved both tests/conftest.py and tests/test_load_path.py.

ⓧ I'd like to double check the full code

Here is the full tests/conftest.py:

"""Shared fixtures for the whole-house graph test suite."""

from pathlib import Path

import pytest

import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))

from build_graph import parse_inventory


@pytest.fixture
def graph():
    """Build the graph from inventory.txt."""
    inventory_path = Path(__file__).parent.parent / "src" / "inventory.txt"
    return parse_inventory(inventory_path)

For tests/test_load_path.py, scroll up and confirm these sections appear in order: the module docstring, the import networkx as nx line, the edges_of_type helper function, test_load_path_one_story, and test_load_path_two_story.

Write the prescriptive exclusion test

The prescriptive path covers certain design decisions but explicitly hands others to an engineer. This test is ordered in two steps so it cannot pass on an empty graph. Step 1 asserts the excluded decisions exist. Only then does step 2 check that the coverage query omits them.

  • Create tests/test_prescriptive.py with the module header and the exclusion list:
"""Prescriptive vs engineered: two-step exclusion test.

The test is ordered so it cannot pass on an empty graph. Step 1 asserts
the excluded decisions exist and carry engineered-design edges. Only then
does step 2 assert the coverage query omits them.
"""


EXCLUDED_DECISIONS = [
    "four-story-apartment-block",
    "beam-beyond-prescriptive-tables",
    "bracing-excluded-by-irregularity",
    "footing-on-expansive-soil",
]

Why list the exclusions as a constant?

Naming the 4 excluded decisions in a module-level list makes the test self-documenting. Each one represents work the IRC prescriptive path does not cover: a scope violation, a span beyond the tables, an irregularity the bracing rules exclude, or a soil condition the path ignores.

  • Confirm the file exists by running:
ls tests/test_prescriptive.py

You should see the file path printed.

  • Add the test function with step 1 (asserting excluded decisions exist) below the EXCLUDED_DECISIONS list:
def test_exclusions_exist_then_not_covered(graph):
    """Two ordered steps so the test cannot pass on an empty graph.

    Step 1: assert all 4 excluded decisions exist with engineered edges.
    Step 2: assert none appear in the prescriptive coverage set.
    """
    # Step 1: all 4 excluded decisions exist and carry engineered-design edges
    for decision in EXCLUDED_DECISIONS:
        assert decision in graph.nodes(), (
            f"Excluded decision '{decision}' must exist in the graph. "
            f"Test cannot validate coverage without the exclusion nodes."
        )
        assert graph.nodes[decision].get("kind") == "design-decision", (
            f"'{decision}' must have kind 'design-decision', "
            f"got '{graph.nodes[decision].get('kind')}'."
        )
        engineered_edges = [
            (u, v) for u, v, d in graph.out_edges(decision, data=True)
            if d.get("type") == "needs-engineered-design"
        ]
        assert len(engineered_edges) > 0, (
            f"'{decision}' must have a needs-engineered-design edge."
        )

Why can't this test pass on an empty graph?

Step 1 asserts each excluded decision exists AND carries at least one needs-engineered-design edge. If the graph were empty, the assert decision in graph.nodes() line fails immediately. A coverage query that returns nothing because nothing was typed is caught here before step 2 ever runs.

  • Add step 2 inside the same function, directly below the step 1 loop (keep the indentation level matching the for loop above):
    # Step 2: prescriptive coverage returns none of the 4
    covered_by_prescriptive = set()
    for u, v, data in graph.edges(data=True):
        if data.get("type") == "covered-by" and v == "prescriptive-path":
            covered_by_prescriptive.add(u)

    for decision in EXCLUDED_DECISIONS:
        assert decision not in covered_by_prescriptive, (
            f"'{decision}' must NOT be in prescriptive coverage. "
            f"This is work the code hands to an engineer."
        )

What does step 2 prove?

Step 2 gathers every node that has a covered-by edge to prescriptive-path. Then it asserts that none of the 4 excluded decisions appear in that set. If any did, it would mean the graph claims prescriptive coverage for work the code explicitly hands to an engineer.

  • Save tests/test_prescriptive.py.
  • Run the prescriptive test by running:
pytest tests/test_prescriptive.py -v

You should see test_exclusions_exist_then_not_covered PASSED in the output.

Seeing an AssertionError?

  • If the error says a decision "must exist in the graph," check that inventory.txt has all four excluded decisions in the [nodes] section: four-story-apartment-block, beam-beyond-prescriptive-tables, bracing-excluded-by-irregularity, footing-on-expansive-soil.
  • If the error mentions a missing needs-engineered-design edge, confirm each of those four nodes has a corresponding edge line in the [edges] section.
  • Check indentation: all of step 2's code must be inside the function body (indented one level from def).

Why is my prescriptive exclusion test failing?

✔️ Awesome, I've got everything!

Confirm you have saved tests/test_prescriptive.py with both step 1 and step 2 inside the function.

ⓧ I'd like to double check the full code

Scroll up and confirm your tests/test_prescriptive.py contains these sections in order: the module docstring, the EXCLUDED_DECISIONS list, the function definition with its docstring, the step 1 for loop (three assertions per decision), and the step 2 coverage query (building covered_by_prescriptive then asserting none of the 4 appear).

Write the coverage tests and run the full suite

The final two queries hold your builds accountable. Every landmark and every subfield in the graph must link to a build through an exercised-by edge. A landmark that was collected but never practiced, or a subfield that was claimed but never exercised, is a gap.

  • Create tests/test_coverage.py with the module docstring and the landmark coverage test:
"""Build coverage: every landmark and subfield must link to a build.

Route and credential nodes are exempt: no build makes anyone licensed.
"""


def test_landmark_coverage(graph):
    """Every landmark-attributed node must have an exercised-by edge to a build."""
    landmarks = [
        n for n, d in graph.nodes(data=True)
        if d.get("landmark") == "true"
    ]
    assert len(landmarks) > 0, (
        "Graph must contain at least one landmark node. "
        "Coverage check on an empty set proves nothing."
    )
    uncovered = []
    for lm in landmarks:
        has_build = any(
            d.get("type") == "exercised-by"
            for _, _, d in graph.out_edges(lm, data=True)
        )
        if not has_build:
            uncovered.append(lm)
    assert len(uncovered) == 0, (
        f"Landmarks without a build link (collected, never practiced): "
        f"{uncovered}"
    )

What does this test catch?

The first assertion guards against an empty set. A coverage check that passes because no landmarks exist proves nothing. The second assertion finds every landmark node that has no outbound exercised-by edge. A landmark returned here was collected in the graph but never linked to a build. That is knowledge named but never practiced.

  • Add the subfield coverage test below test_landmark_coverage in the same file:
def test_subfield_coverage(graph):
    """Every subfield node must have an exercised-by edge to a build."""
    subfields = [
        n for n, d in graph.nodes(data=True)
        if d.get("kind") == "subfield"
    ]
    assert len(subfields) > 0, (
        "Graph must contain at least one subfield node. "
        "Coverage check on an empty set proves nothing."
    )
    uncovered = []
    for sf in subfields:
        has_build = any(
            d.get("type") == "exercised-by"
            for _, _, d in graph.out_edges(sf, data=True)
        )
        if not has_build:
            uncovered.append(sf)
    assert len(uncovered) == 0, (
        f"Subfields without a build link (area claimed, never exercised): "
        f"{uncovered}"
    )

Why are routes and credentials exempt?

No build makes anyone licensed. Route and credential nodes represent legal and institutional prerequisites (NAAB degrees, AXP internships, state registrations). They are tracked in the graph for completeness but are deliberately excluded from the coverage assertion. Only landmarks and subfields must demonstrate a build link.

  • Save tests/test_coverage.py.

Before you run the full suite, predict: how many tests do you expect to pass across all three files? What would a failure in test_subfield_coverage tell you about your graph?

  • Run the full test suite by running:
pytest tests/ -v

You should see all 5 tests pass green: 2 load-path tests, 1 prescriptive exclusion test, and 2 coverage tests. Each line shows PASSED with a progress percentage.

Seeing a coverage test fail?

  • If test_landmark_coverage fails with "Landmarks without a build link," check that your inventory.txt has exercised-by edges from each landmark node (platform-framing-system, irc-prescriptive-tables, gravity-load-path) to a build node.
  • If test_subfield_coverage fails, verify that all 5 subfields (structure, envelope, environmental-systems, site-design, interior) have exercised-by edges to their respective build nodes.

Why is my coverage test reporting uncovered nodes?

✔️ Awesome, I've got everything!

Confirm you have saved tests/test_coverage.py with both test functions.

ⓧ I'd like to double check the full code

Scroll up and confirm your tests/test_coverage.py contains: the module docstring (noting route/credential exemption), test_landmark_coverage (filters nodes with landmark="true", asserts all have exercised-by edges), and test_subfield_coverage (filters nodes with kind="subfield", same assertion pattern).

  • Commit your test files and push to a new branch by running:
git add tests/
git commit -m "Add query pack: 5 pytest queries with expected answers"
  • Push the branch, open a PR, merge it to main, and close the queries ticket in Linear.

Your evidence suite now has 5 falsifiable queries. Every expected answer was written before the query ran. Next up, you will export the graph to a browsable Mermaid diagram, a card deck for retrieval practice, and documentation pages that carry the self-check and the source map.

Export the Diagram, Card Deck, and Documentation

Your query pack proves the graph holds up to falsifiable questions. Every expected answer was written first, and every test passes green.

A graph locked inside code is a graph nobody can browse, drill, or share. In this step you build three exporters that turn the graph into artifacts that live after the build: a Mermaid topology diagram, a JSON card deck for retrieval practice, and documentation pages carrying the self-check, source map, and life-safety framing.

In this step, get ready to:
  • Write a Mermaid exporter that groups nodes by kind and labels edges by type.
  • Write a card deck exporter that generates one retrieval prompt per node.
  • Write a documentation exporter with life-safety header, worked example, self-check, and source map.
Write the export module constants and Mermaid exporter

Every export carries the life-safety framing. Two string constants hold the language that appears at the top of every generated document.

  • Create a new file src/export.py and add the module docstring, imports, and two framing constants:
"""Export the graph to Mermaid, card deck JSON, and documentation."""

import json
from pathlib import Path

import networkx as nx

from build_graph import parse_inventory


LIFE_SAFETY_HEADER = """\
STUDY MOCK-UP. Not a stamped construction document a licensed
professional has sealed and is answerable for. Everything here is a
study mock-up. A real house needs the adopted code and the licensed
professionals its jurisdiction requires. Nothing here is professional,
engineering, or legal advice. Architect is a legally protected title.
In many US jurisdictions a one- or two-family dwelling is exempt from
the licensed-architect requirement, so a building designer or owner may
design it. Your jurisdiction decides whether its exemption applies.
"""

HONEST_ROUTE = """\
This path builds knowledge, computational craft, and a portfolio.
It does not build a degree, a license, or a track record, and says so
plainly. A professional degree and a license confer the legal right to
practice and to seal documents. The exemption plus a building-designer
credential allow designing small dwellings where a jurisdiction permits.
"""

What do these constants do?

LIFE_SAFETY_HEADER states plainly that this is a study mock-up, not a stamped document. HONEST_ROUTE clarifies what this learning path builds (knowledge and portfolio) and what it does not build (a degree or license).

Both constants appear verbatim in the generated documentation so any reader sees the framing before anything else.

  • Below the constants, add the export_mermaid function:
def export_mermaid(G: nx.MultiDiGraph, out_path: Path) -> None:
    # Build a graph TD diagram grouped by node kind
    lines = ["graph TD"]
    kinds = sorted(set(nx.get_node_attributes(G, "kind").values()))
    for kind in kinds:
        nodes = [n for n, d in G.nodes(data=True) if d.get("kind") == kind]
        lines.append(f"    %% {kind}")
        for node in sorted(nodes):
            safe_id = node.replace("-", "_")
            lines.append(f"    {safe_id}[{node}]")
    lines.append("")
    for u, v, data in G.edges(data=True):
        safe_u = u.replace("-", "_")
        safe_v = v.replace("-", "_")
        edge_type = data.get("type", "unknown")
        lines.append(f"    {safe_u} -->|{edge_type}| {safe_v}")
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text("\n".join(lines), encoding="utf-8")
    print(f"Mermaid exported: {out_path}")

What does this function do?

  • Nodes are grouped by kind with Mermaid comments as section headers.
  • Hyphens are replaced with underscores in node IDs because Mermaid treats hyphens as syntax.
  • Each edge is labelled with its type so you can visually trace carries-load-to chains, covered-by relationships, and the rest.
  • Save src/export.py.
  • Confirm the module parses cleanly by running:
cd src && python -c "from export import export_mermaid; print('Mermaid exporter ready')"

You should see Mermaid exporter ready printed with no errors.

Seeing an ImportError?

  • Make sure you ran the command from inside the src/ directory. The from build_graph import parse_inventory line needs build_graph.py on the same path.
  • Check that networkx is installed in your active Python environment.

Why can't Python find my export module?

Add the card deck exporter

Each node becomes a card with its connections and a retrieval prompt. This deck feeds the cold-run practice later.

  • In src/export.py, add the export_cards function below export_mermaid:
def export_cards(G: nx.MultiDiGraph, out_path: Path) -> None:
    # One card per node with connections and a retrieval prompt
    cards = []
    for node, attrs in G.nodes(data=True):
        connections = []
        for _, target, data in G.out_edges(node, data=True):
            connections.append({"target": target, "edge_type": data.get("type", "unknown")})
        for source, _, data in G.in_edges(node, data=True):
            connections.append({"source": source, "edge_type": data.get("type", "unknown")})
        card = {
            "id": node,
            "kind": attrs.get("kind", "unknown"),
            "label": attrs.get("label", node),
            "connected_to": connections,
            "retrieval_prompt": (
                f"What is {node}, what kind of node is it, "
                f"and what edges connect it to the rest of the graph?"
            ),
        }
        cards.append(card)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(json.dumps(cards, indent=2, ensure_ascii=False), encoding="utf-8")
    print(f"Card deck exported: {out_path} ({len(cards)} cards)")

What does each card contain?

  • id and kind identify the node.
  • connected_to lists every inbound and outbound edge so a retrieval prompt can ask about connections.
  • retrieval_prompt is the question the card answers. You will select 20 of these for the cold run.
  • Save src/export.py.
  • Confirm no syntax errors by running:
python -c "from export import export_cards; print('Card exporter ready')"

You should see Card exporter ready with no traceback.

Add the documentation exporter and run all three

The documentation exporter assembles a single README.md carrying every section the project needs: life-safety header, honest route, build instructions, worked example, self-check with wrong-answer diagnoses, retrieval prompts, teach-back structure, and source map.

  • Below export_cards, add the first half of export_docs which writes the opening sections through the worked example:
def export_docs(G: nx.MultiDiGraph, out_path: Path) -> None:
    # Assemble documentation sections as a single README
    sections = []
    sections.append(f"# The Whole House on One Map\n\n{LIFE_SAFETY_HEADER}")
    sections.append(f"## The Honest Route\n\n{HONEST_ROUTE}")
    sections.append(
        "## What I Was Learning and Why\n\n"
        "Residential house design as a queryable structure, not a pile of "
        "names. The goal: answer questions the field sets, cold, with "
        "evidence. The proof is a query answered cold and a beginner who "
        "understood it."
    )
    sections.append(
        "## The Build and How to Run It\n\n"
        "```\n"
        "pip install networkx==3.6.1 pytest==9.1.1\n"
        "python src/build_graph.py\n"
        "python src/integrity.py\n"
        "pytest tests/ -v\n"
        "python src/export.py\n"
        "```"
    )
    sections.append(
        "## Worked Example: The Load Path Query\n\n"
        "Query: find the shortest path from roof-assembly to bearing-soil "
        "following only carries-load-to edges.\n\n"
        "Expected (one-story): roof-assembly -> exterior-stud-wall -> "
        "foundation-wall -> footing -> bearing-soil (4 edges).\n\n"
        "Wrong answer: no path means a member was typed and never connected. "
        "A path reaching soil without a footing skipped the foundation.\n\n"
        "What this count is evidence of: a regression check on the inventory."
    )

What do these documentation sections cover?

  • The life-safety header and honest route appear first so any reader sees the framing before the content.
  • The build instructions are the exact commands needed to reproduce the graph, tests, and exports from scratch.
  • The worked example shows what a correct answer looks like AND what a wrong answer means. Both matter.
  • Continuing inside the same export_docs function, add the self-check, retrieval prompts, teach-back, source map, and the file-write logic:
    sections.append(
        "## Self-Check\n\n"
        "Each query has an expected value and a wrong-answer diagnosis.\n\n"
        "- Load path length (one-story): 4 edges.\n"
        "- Load path length (two-story): 6 edges.\n"
        "- Prescriptive exclusions step 1: all 4 excluded decisions exist.\n"
        "- Prescriptive exclusions step 2: 0 of the 4 in coverage.\n"
        "- Build coverage landmarks: 0 uncovered.\n"
        "- Build coverage subfields: 0 uncovered."
    )
    sections.append(
        "## Retrieval Prompts\n\n"
        "See exports/cards.json for the full set. Select 20 covering all 9 "
        "node kinds and at least 5 edge types for the cold run."
    )
    sections.append(
        "## The Teach-Back\n\n"
        "After the cold run, write: what you scored, what you got wrong, "
        "which node or edge was the source of each miss, and what you "
        "rebuilt. State what the score is evidence of."
    )
    sections.append(
        "## Source Map\n\n"
        "- IRC 2024 (International Code Council, codes.iccsafe.org)\n"
        "- Building Construction Illustrated, 7th Edition (Francis D.K. Ching, Wiley)\n"
        "- NCARB (ncarb.org)\n"
        "- NCBDC (ncbdc.org)\n\n"
        "No chapter or page number is asserted."
    )
    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text("\n\n".join(sections), encoding="utf-8")
    print(f"Documentation exported: {out_path}")

Why does the source map assert no page numbers?

The code text that binds is the edition your jurisdiction adopted with its amendments. Asserting a page number ties a claim to a specific printing. The source map names the authoritative source so a reader can verify, without pretending the graph is a citation.

  • Finally, add the main function at the bottom of src/export.py:
def main():
    # Build the graph and export all three artifacts
    inventory_path = Path(__file__).parent / "inventory.txt"
    G = parse_inventory(inventory_path)
    project_root = Path(__file__).parent.parent
    export_mermaid(G, project_root / "exports" / "topology.mmd")
    export_cards(G, project_root / "exports" / "cards.json")
    export_docs(G, project_root / "docs" / "README.md")


if __name__ == "__main__":
    main()

What does main do?

It builds the graph from inventory.txt (the same path every other module uses), then calls each exporter with a target path relative to the project root. Running this one script produces all three deliverables.

  • Save src/export.py.

Before you run this, what three files do you expect to see created, and where?

  • Run the exporter:
python export.py

You should see three lines of output confirming each export:

Mermaid exported: ../exports/topology.mmd
Card deck exported: ../exports/cards.json (N cards)
Documentation exported: ../docs/README.md
  • Confirm the files exist by running:
ls ../exports/ ../docs/

You should see topology.mmd and cards.json under exports/, and README.md under docs/.

Seeing a FileNotFoundError?

  • Make sure you are running from inside the src/ directory. The script uses Path(__file__).parent.parent to resolve the project root relative to itself.
  • Verify your folder structure has exports/ and docs/ directories at the project root. The script creates them if missing, but check that no permission error blocked it.

Why is my export script failing with a path error?

  • Open exports/topology.mmd and confirm it starts with graph TD followed by nodes grouped under kind comments.
  • Open exports/cards.json and confirm each object has id, kind, connected_to, and retrieval_prompt fields.
  • Open docs/README.md and confirm the life-safety header appears at the top, followed by the honest route statement.
  • Navigate back to the project root:
cd ..
  • Commit the export module and generated files:
git add src/export.py exports/ docs/
git commit -m "Add export module: Mermaid, card deck, documentation"
  • Tag the release and push everything:
git tag v1.0.0
git push origin main --tags
  • Open your GitHub repo in the browser. Confirm docs/README.md renders with the life-safety header visible at the top.
  • Close the exports ticket in Linear.

Verify the Mermaid renders

GitHub renders .mmd files as diagrams in the repo view. Navigate to exports/topology.mmd in your browser and you should see the graph drawn with nodes grouped by kind and labelled edges.

✔️ Awesome, I've got everything!

Confirm you have saved src/export.py and that all three output files exist.

ⓧ I'd like to double check the full code

Your src/export.py is built from the chunks above. Scroll up and confirm each section is present in this order:

  • Module docstring, imports (json, Path, networkx, build_graph).
  • LIFE_SAFETY_HEADER and HONEST_ROUTE string constants.
  • export_mermaid function (graph TD, grouped by kind, edges labelled).
  • export_cards function (one card per node with retrieval prompt).
  • export_docs function (9 sections appended, then written to file).
  • main function calling all three exporters, plus if __name__ == "__main__" guard.

The graph is no longer locked inside code. You have a browsable diagram, a drillable card deck, and documentation that carries the life-safety framing and source map to any reader. Next up: the scored cold run that tests whether you can answer these questions with the map closed.

Secret mission

The Scored Cold Run

The graph exists to be interrogated, not admired. Run a scored cold retrieval: answer 20 prompts with the map closed, score against the graph, trace every miss to the node or edge that was wrong, and teach back what the score proves. The bar is 17/20.

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 a private GitHub repo and a Linear project, so there are no ongoing costs.

Resources you used:

  • The whole-house-graph project folder on your Desktop (all source files, tests, exports, and docs).
  • Python packages: networkx and pytest installed via pip.
  • A private GitHub repository holding the graph code and documentation.
  • A Linear project with tickets tracking each step.

Keep everything running

No action needed. Choose this if you plan to extend the graph with more nodes, add new queries, or use the card deck for ongoing retrieval practice.

  • Your whole-house-graph folder stays on your Desktop.
  • Your GitHub repo stays available for future pushes.
  • Your Linear project stays as a record of completed tickets.
  • The networkx and pytest packages remain installed in your Python environment.

Pause - I'll come back to this later

Free up local space while keeping your remote backups intact. You can re-clone and reinstall later.

  • Confirm your latest commits are pushed to GitHub.
  • Delete the local project folder by running the command for your operating system (see Delete tab below for the exact commands).
  • Leave your GitHub repo and Linear project in place for when you return.

Delete - I don't want to use this again

Remove all project resources completely. This cannot be undone for the GitHub repo once confirmed.

  • Delete the local project folder on macOS or Linux by running:
cd ~/Desktop
rm -rf whole-house-graph
  • Delete the local project folder on Windows by running:
cd ~\Desktop
Remove-Item -Recurse -Force whole-house-graph
  • Uninstall the Python packages by running:
pip uninstall networkx pytest -y
  • Delete the GitHub repository: open your repo page on GitHub, click Settings, scroll to the Danger Zone section at the bottom, and click Delete this repository. Follow the confirmation prompts and type the repository name to confirm.
  • Delete the Linear project: open your project in Linear, click the three-dot menu next to the project name (beside the Overview and Issues tabs), and select Delete. Confirm the deletion when prompted.

Well Done!

Well Done!

You built a typed knowledge graph that holds the whole field of residential house design as queryable structure. You can now answer questions the field sets, cold, with evidence.

You've learned how to:

  • Build a typed knowledge graph encoding 9 node kinds and 8 edge types from a single plain-text source file, with a closed vocabulary that rejects anything outside it.
  • Enforce three integrity invariants and watch one catch a real gap: a missing carries-load-to edge that broke the gravity chain from roof to soil.
  • Write a pytest evidence suite of 5 queries where every expected answer was written before the query ran, and state what each count is evidence of.
  • Secret Mission: Run a scored cold retrieval, trace every miss to its source node or edge, rebuild what you got wrong, and teach back what the score proves.

Ready to quiz yourself?