Custom Relationships#

Relationships represent many-to-many connections between agents with per-edge data. They are stored in a sparse COO (Coordinate) format for memory efficiency, since most agent pairs are not connected at any given time.

Quick Example#

from bamengine import relationship, get_role
from bamengine.typing import Float, Int


@relationship(source=get_role("Worker"), target=get_role("Employer"))
class Employment:
    """Employment relationship between workers and firms."""

    wage: Float
    contract_duration: Int
    start_period: Int

The @relationship Decorator#

The decorator defines a relationship type with source/target roles and per-edge data fields:

@relationship(
    source=get_role("Borrower"),  # Source agent role
    target=get_role("Lender"),  # Target agent role
    cardinality="many-to-many",  # Default
    name="CustomLoanBook",  # Optional name override
)
class CustomLoanBook:
    principal: Float
    rate: Float

Parameters:

  • source: The role type on the “from” side of each edge

  • target: The role type on the “to” side of each edge

  • cardinality: "many-to-many" (default), "one-to-many", or "many-to-one"

  • name: Custom name for registry lookup (defaults to class name)

The decorator registers the relationship in the global registry and converts edge data fields into parallel arrays.

COO Sparse Format#

Relationships store edges as parallel arrays in COO (Coordinate) format:

Index:      0       1       2       3       4
source_ids: [3,     7,      3,      12,     7    ]   ← borrower firm IDs
target_ids: [0,     2,      1,      0,      0    ]   ← lender bank IDs
principal:  [10.5,  25.0,   8.3,    15.0,   12.0 ]   ← per-edge data
rate:       [0.03,  0.02,   0.04,   0.03,   0.02 ]   ← per-edge data

Key attributes:

  • source_ids: array of source agent IDs

  • target_ids: array of target agent IDs

  • size: number of active edges (valid entries in the arrays)

  • capacity: allocated array length (grows dynamically via doubling)

Only indices 0 through size - 1 contain valid data.

Adding and Removing Edges#

Adding edges:

import numpy as np

# Append edges with source/target IDs and component data
rel.append_edges(
    source_ids=np.array([0, 1, 2]),
    target_ids=np.array([5, 5, 3]),
    principal=np.array([10.0, 20.0, 15.0]),
    rate=np.array([0.02, 0.03, 0.025]),
)

The LoanBook has a convenience method for appending loans from a single lender:

loans.append_loans_for_lender(
    lender_idx=np.intp(0),
    borrower_indices=np.array([3, 7]),
    amount=np.array([10.5, 25.0]),
    rate=np.array([0.03, 0.02]),
)

Removing edges:

# Remove by boolean mask (True = remove)
mask = rel.principal < 1.0  # Remove tiny loans
rel.drop_rows(mask[: rel.size])

# Remove all edges from specific sources
rel.purge_sources(np.array([3, 12]))  # Remove all loans from firms 3 and 12

# Remove all edges to specific targets
rel.purge_targets(np.array([0]))  # Remove all loans to bank 0

# Clear all edges
rel.size = 0

Querying Edges#

Find edges by source or target agent:

# Get indices of all loans from firm 7
edge_indices = loans.query_sources(7)
firm_7_rates = loans.rate[edge_indices]

# Get indices of all loans to bank 0
edge_indices = loans.query_targets(0)
bank_0_principal = loans.principal[edge_indices]

Aggregating Edge Data#

Compute per-agent aggregates from edge data:

# Total debt per borrower (firm)
debt_by_firm = loans.aggregate_by_source(
    "debt",
    func="sum",
    n_sources=sim.n_firms,
)

# Total lending per lender (bank)
lending_by_bank = loans.aggregate_by_target(
    "principal",
    func="sum",
    n_targets=sim.n_banks,
)

# Average interest rate per bank
avg_rate_by_bank = loans.aggregate_by_target(
    "rate",
    func="mean",
    n_targets=sim.n_banks,
)

# Number of loans per firm
loan_count_by_firm = loans.aggregate_by_source(
    "principal",
    func="count",
    n_sources=sim.n_firms,
)

Available aggregation functions: "sum", "mean", "count", "min", "max".

Built-in Relationship: LoanBook#

The LoanBook relationship tracks loans between firms (borrowers) and banks (lenders):

Field

Description

principal

Original loan amount at signing

rate

Contractual interest rate

interest

Cached interest amount (rate * principal)

debt

Cached total debt (principal * (1 + rate))

source_ids

Borrower (firm) IDs (also accessible as .borrower)

target_ids

Lender (bank) IDs (also accessible as .lender)

Convenience methods:

loans = sim.lb  # shortcut for sim.get_relationship("LoanBook")

loans.debt_per_borrower(n_borrowers=sim.n_firms)  # Total debt per firm
loans.interest_per_borrower(n_borrowers=sim.n_firms)  # Total interest per firm
loans.principal_per_borrower(n_borrowers=sim.n_firms)  # Total principal per firm

Tips#

  • Edge indices are NOT stable: Adding or removing edges may shift indices. Don’t store edge indices across operations.

  • Use ``size``, not ``len()``: Only relationship.size entries are valid. Arrays may have extra capacity beyond size.

  • COO is efficient for sparse, dynamic graphs: The format excels when most agent pairs are unconnected and the set of connections changes each period.

  • Aggregation is vectorized: aggregate_by_source and aggregate_by_target use np.bincount internally, so they’re fast even with thousands of edges.

See also