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.
|
|
"Give me the value for this key.
If the key does not exist, insert |
|
Add an item to the end of a list.
|
|
Membership test.
|
|
Negated membership.
|
Dictionary of inverses |
A lookup table that maps each relation to its logical opposite.
|
Private method ( |
A method prefixed with |
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:
-
self._forward.setdefault(source, {})— get or create the entry forsource. If"CISSP"does not exist yet, insert{}and return it. -
relations.setdefault(relation, [])— get or create the list for this relation. If"covers"does not exist under"CISSP", insert[]and return it. -
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_INVERSESdict at module level -
__init__accepts optionalinversesparameter -
_inverse()method returns inverse or fallback -
associate()writes to both_forwardand_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.