Phase 2: The Associate Method

Phase 2: The Associate Method

Objective

Give the graph its core behavior: the ability to record that entity A has a named relation to entity B, and automatically record the inverse — that B has the inverse relation back to A. One call, two entries. The graph is always bidirectional.

Python Concepts

Concept Plain English

Method

A function that belongs to a class. associate() is a method of AssociationGraph. When you call graph.associate(…​), Python automatically passes graph as self. Think of it as: the function knows which object it operates on.

setdefault(key, default)

"Give me the value for this key. If the key does not exist, insert default first, then give me that." This avoids the KeyError you would get from dict[key] on a missing key. Equivalent to: if key not in d: d[key] = default; return d[key].

append(value)

Add an item to the end of a list. ["a", "b"].append("c") produces ["a", "b", "c"].

in operator

Membership test. "x" in some_list returns True if "x" is an element. Same concept as grep -q "x" returning exit code 0.

not in

Negated membership. "x" not in some_list is True when "x" is absent.

Dictionary of inverses

A lookup table that maps each relation to its logical opposite. "blocks" inverts to "blocked-by". "covers" inverts to "covered-by". This is domain knowledge encoded as data, not logic.

Private method (_inverse)

A method prefixed with _ signals "internal use only." Callers should use associate(), not call _inverse() directly. Python does not enforce this — it is a convention, like naming a helper function __helper in a shell script.

Steps

1. Define the inverse relation map

At the top of graph.py, before the class, add the default inverses:

# Default inverse relations.
# Every relation needs a reverse so the graph stays bidirectional.
DEFAULT_INVERSES: dict[str, str] = {
    "covers": "covered-by",
    "covered-by": "covers",
    "blocks": "blocked-by",
    "blocked-by": "blocks",
    "requires": "required-by",
    "required-by": "requires",
    "relates-to": "related-by",
    "related-by": "relates-to",
    "uses": "used-by",
    "used-by": "uses",
    "teaches": "taught-by",
    "taught-by": "teaches",
}

Notice every pair is listed in both directions. If "covers" maps to "covered-by", then "covered-by" maps back to "covers". This is not redundant — it means you can look up the inverse from either side without conditional logic.

2. Accept inverses in the constructor

Update __init__ to accept an optional inverses map:

def __init__(self, inverses: dict[str, str] | None = None) -> None:
    self._forward: dict[str, dict[str, list[str]]] = {}
    self._reverse: dict[str, dict[str, list[str]]] = {}
    self._inverses: dict[str, str] = inverses or dict(DEFAULT_INVERSES)

inverses or dict(DEFAULT_INVERSES) means: use the provided map, or copy the defaults. dict(…​) makes a copy so mutations do not affect the module-level constant.

The | in the type hint dict[str, str] | None means "this can be a dict or None." None is Python’s null — like an unset variable in bash.

3. Write the _inverse helper

def _inverse(self, relation: str) -> str:
    """Return the inverse of a relation, or 'related-by' as fallback."""
    return self._inverses.get(relation, "related-by")

.get(key, default) returns the value for key if it exists, otherwise returns default without raising an error. This means unknown relations do not crash the program — they just get a generic inverse.

4. Write the associate method

def associate(self, source: str, relation: str, target: str) -> None:
    """Record that source has relation to target, and the inverse.

    Example:
        graph.associate("CISSP", "covers", "access-control")
        # Forward: CISSP --covers--> access-control
        # Reverse: access-control --covered-by--> CISSP
    """
    # --- Forward entry ---
    relations = self._forward.setdefault(source, {})
    targets = relations.setdefault(relation, [])
    if target not in targets:
        targets.append(target)

    # --- Reverse entry ---
    inv = self._inverse(relation)
    rev_relations = self._reverse.setdefault(target, {})
    rev_sources = rev_relations.setdefault(inv, [])
    if source not in rev_sources:
        rev_sources.append(source)

Step through the forward entry:

  1. self._forward.setdefault(source, {}) — get or create the entry for source. If "CISSP" does not exist yet, insert {} and return it.

  2. relations.setdefault(relation, []) — get or create the list for this relation. If "covers" does not exist under "CISSP", insert [] and return it.

  3. if target not in targets: targets.append(target) — add the target, but only if it is not already there. This prevents duplicates.

The reverse entry does the same thing, but swaps source/target and uses the inverse relation.

setdefault returns a reference to the dict/list stored inside self._forward. When you append to targets, you are modifying the list inside the dict directly. There is no need to reassign it.

5. Write the tests

Add to tests/test_graph.py:

class TestAssociate:
    """Verify the associate method."""

    def test_forward_entry_created(self) -> None:
        g = AssociationGraph()
        g.associate("CISSP", "covers", "access-control")
        assert "access-control" in g._forward["CISSP"]["covers"]

    def test_reverse_entry_created(self) -> None:
        g = AssociationGraph()
        g.associate("CISSP", "covers", "access-control")
        assert "CISSP" in g._reverse["access-control"]["covered-by"]

    def test_no_duplicate_on_repeat(self) -> None:
        g = AssociationGraph()
        g.associate("CISSP", "covers", "access-control")
        g.associate("CISSP", "covers", "access-control")
        assert g._forward["CISSP"]["covers"].count("access-control") == 1

    def test_inverse_mapping(self) -> None:
        g = AssociationGraph()
        g.associate("Phase-1", "blocks", "Phase-2")
        assert "Phase-1" in g._reverse["Phase-2"]["blocked-by"]

    def test_unknown_relation_gets_generic_inverse(self) -> None:
        g = AssociationGraph()
        g.associate("Python", "inspires", "understanding")
        # "inspires" is not in DEFAULT_INVERSES, so inverse is "related-by"
        assert "Python" in g._reverse["understanding"]["related-by"]

    def test_multiple_targets(self) -> None:
        g = AssociationGraph()
        g.associate("CISSP", "covers", "access-control")
        g.associate("CISSP", "covers", "cryptography")
        assert g._forward["CISSP"]["covers"] == [
            "access-control",
            "cryptography",
        ]

6. Run

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

Checklist

  • DEFAULT_INVERSES dict at module level

  • __init__ accepts optional inverses parameter

  • _inverse() method returns inverse or fallback

  • associate() writes to both _forward and _reverse

  • No duplicates on repeated calls

  • Six new tests pass

  • Linter is clean

Verification

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

You should see the Phase 1 tests and the Phase 2 tests all passing. The graph can now record associations. Phase 3 teaches it to answer questions.