Phase 3: Query Methods

Phase 3: Reading the Graph

Objective

Add methods to query the graph: look up what an entity is associated with, find what points back at it, list all known entities, and enumerate all relation types. The graph can now answer questions.

Python Concepts

Concept Plain English

.get(key, default)

Look up a key in a dict. If the key exists, return its value. If not, return default instead of crashing with KeyError. This is the safe lookup — use it whenever the key might be absent. Equivalent to jq '.key // "default"'.

set()

An unordered collection of unique items. {1, 2, 2, 3} becomes {1, 2, 3} — duplicates vanish. Think of it as the output of sort -u.

| (set union)

Combine two sets: {1, 2} | {2, 3} produces {1, 2, 3}. Everything from both sides, deduplicated. Like cat file1 file2 | sort -u.

sorted()

Return a new list with elements in ascending order. Does not modify the original. Equivalent to piping through sort.

list()

Convert something into a list. list({3, 1, 2}) produces [3, 1, 2] (order not guaranteed with sets — use sorted() if you need order).

Dictionary .keys()

Returns a view of all keys in the dict. {"a": 1, "b": 2}.keys() gives dict_keys(["a", "b"]). You can iterate over it, convert to a list, or use in set operations.

Return type hints

→ dict[str, list[str]] after the parameter list declares what the method returns. This is documentation, not enforcement.

Steps

1. The query method — forward lookup

Add to AssociationGraph in graph.py:

def query(self, source: str) -> dict[str, list[str]]:
    """Return all forward associations for an entity.

    Returns an empty dict if the entity has no associations.

    Example:
        >>> graph.associate("CISSP", "covers", "access-control")
        >>> graph.query("CISSP")
        {"covers": ["access-control"]}
    """
    return dict(self._forward.get(source, {}))

dict(…​) makes a shallow copy. This prevents callers from accidentally mutating the internal state. Without the copy, someone could do graph.query("CISSP")["covers"].append("oops") and corrupt your data.

2. The reverse_query method — backward lookup

def reverse_query(self, target: str) -> dict[str, list[str]]:
    """Return all reverse associations pointing at an entity.

    Example:
        >>> graph.associate("CISSP", "covers", "access-control")
        >>> graph.reverse_query("access-control")
        {"covered-by": ["CISSP"]}
    """
    return dict(self._reverse.get(target, {}))

Same pattern, different dict. query traverses forward edges; reverse_query traverses backward edges.

3. The keys method — all known entities

def keys(self) -> list[str]:
    """Return all entities that appear in the graph, sorted.

    Combines sources (forward keys) and targets (reverse keys)
    to capture every entity regardless of which side it was added from.
    """
    all_keys = set(self._forward.keys()) | set(self._reverse.keys())
    return sorted(all_keys)

Walk through this:

  1. set(self._forward.keys()) — all entities that are sources (left side of an association).

  2. set(self._reverse.keys()) — all entities that are targets (right side of an association).

  3. \| — union. An entity might appear only as a source, only as a target, or both. Union captures all of them.

  4. sorted(…​) — alphabetical order for deterministic output.

4. The relations method — all relation types

def relations(self) -> list[str]:
    """Return all relation types used in the graph, sorted."""
    rels: set[str] = set()
    for source_relations in self._forward.values():
        rels.update(source_relations.keys())
    return sorted(rels)

New concepts here:

  • .values() — returns the values of a dict (not the keys). Here, each value is itself a dict of {relation: [targets]}.

  • .update() — add multiple items to a set at once. Like rels = rels | new_items but modifies in place.

5. Write the tests

Add to tests/test_graph.py:

class TestQuery:
    """Verify query methods."""

    def _populated_graph(self) -> AssociationGraph:
        """Helper: return a graph with test data."""
        g = AssociationGraph()
        g.associate("CISSP", "covers", "access-control")
        g.associate("CISSP", "covers", "cryptography")
        g.associate("CISSP", "requires", "5-years-experience")
        g.associate("CCNP", "covers", "routing")
        return g

    def test_query_returns_all_relations(self) -> None:
        g = self._populated_graph()
        result = g.query("CISSP")
        assert "covers" in result
        assert "requires" in result
        assert "access-control" in result["covers"]
        assert "cryptography" in result["covers"]

    def test_query_unknown_returns_empty(self) -> None:
        g = self._populated_graph()
        assert g.query("nonexistent") == {}

    def test_reverse_query(self) -> None:
        g = self._populated_graph()
        result = g.reverse_query("access-control")
        assert "covered-by" in result
        assert "CISSP" in result["covered-by"]

    def test_reverse_query_unknown_returns_empty(self) -> None:
        g = self._populated_graph()
        assert g.reverse_query("nonexistent") == {}

    def test_keys_includes_all_entities(self) -> None:
        g = self._populated_graph()
        k = g.keys()
        # Sources
        assert "CISSP" in k
        assert "CCNP" in k
        # Targets (only in reverse dict)
        assert "access-control" in k
        assert "cryptography" in k
        assert "routing" in k

    def test_keys_are_sorted(self) -> None:
        g = self._populated_graph()
        k = g.keys()
        assert k == sorted(k)

    def test_relations(self) -> None:
        g = self._populated_graph()
        rels = g.relations()
        assert "covers" in rels
        assert "requires" in rels

    def test_query_returns_copy_not_reference(self) -> None:
        """Mutating the query result must not affect the graph."""
        g = self._populated_graph()
        result = g.query("CISSP")
        result["covers"].append("CORRUPTED")
        # The graph should be unaffected
        assert "CORRUPTED" not in g.query("CISSP")["covers"]

The last test is defensive — it proves your dict() copy in query() works.

6. Run

uv run pytest tests/ -v
uv run ruff check src/ tests/

Checklist

  • query() method returns forward associations

  • reverse_query() method returns reverse associations

  • keys() returns all entities from both dicts

  • relations() returns all relation types

  • All methods return copies, not references

  • Unknown keys return empty dicts/lists, never raise errors

  • Eight new tests pass

  • Linter clean

Verification

uv run pytest tests/ -v --tb=short

The graph can now record associations (Phase 2) and answer queries (Phase 3). Phase 4 builds out the complete test suite before adding more features — tests first, then capabilities.