diff --git a/PYDANTIC_GUIDE.md b/PYDANTIC_GUIDE.md
index ceda6ec01..0d9cfaf66 100644
--- a/PYDANTIC_GUIDE.md
+++ b/PYDANTIC_GUIDE.md
@@ -542,180 +542,229 @@ Inheriting from `str, Enum` makes enum values work as both enums and strings, wh
### Relationship Patterns
-**What are relationships?** Relationships (or "associations") represent connections between different features or models. Think of them like links that connect related pieces of information - for example, a city center that belongs to an administrative area, or a transportation segment that connects to specific intersections.
+#### What are relationships?
-Pydantic provides several ways to express these relationships, each suited to different use cases and complexity levels.
+Relationships represent connections between different features or models. Think of them like links that connect related pieces of information — for example, a building part that is structurally part of a building, or a division area that is administratively nested under a division.
-#### 1. Direct References (Foreign Keys)
+Pydantic provides several ways to express these relationships, each suited to different use cases and complexity levels. Before choosing a pattern, it's important to understand the **semantic type** of the relationship you're modeling.
-The fundamental pattern is a direct reference where one feature "points to" another using an ID field with type safety and semantic information. This creates a one-way relationship - like a building knowing which neighborhood it belongs to, but the neighborhood doesn't automatically know about all its buildings.
+---
-```python
-from typing import Annotated, Literal
-from pydantic import Field
-from overture.schema.core import OvertureFeature
-from overture.schema.system.ref import Id, Reference, Relationship
+#### Semantic Relationship Types
-class DivisionArea(OvertureFeature[Literal["divisions"], Literal["division_area"]]):
- """Area polygon that belongs to a division."""
+Every relationship between two features carries a semantic meaning about coupling strength, lifecycle dependency, and ownership. The schema defines four relationship types, ordered from strongest to weakest coupling. The types describe the *nature* of the link, not which feature is "parent" or "child." Direction is implicit: the feature holding the reference is the source, and the type it references is the destination.
- # Required reference - every division area must belong to a division
- division_id: Annotated[
- Id,
- Reference(Relationship.BELONGS_TO, Division),
- Field(description="Division this area belongs to")
- ]
+##### `COMPOSITION` — Structural Whole-Part
- # Optional reference - may or may not be associated with a place
- place_id: Annotated[
- Id | None,
- Reference(Relationship.CONNECTS_TO, Place),
- Field(description="Place this area is associated with")
- ] = None
-```
+A structural whole-part relationship with lifecycle dependency. The part has no independent meaning outside the whole. Deleting the whole invalidates the part.
-**Available relationship types (see [Relationship](packages/overture-schema-system/src/overture/schema/system/ref/ref.py)):**
+**Test question:** *"If I delete the whole, does keeping the part orphaned make any sense at all?"* If the answer is no, it's `COMPOSITION`.
-- **`BELONGS_TO`**: The referencing feature belongs to the referenced feature (division area belongs to division)
-- **`CONNECTS_TO`**: The referencing feature connects to the referenced feature (segment connects to connector)
-- **`BOUNDARY_OF`**: The referencing feature forms a boundary of the referenced feature (boundary line defines area)
+**Examples:**
+- `BuildingPart` → `Building` — part *is part of* building
+- `DivisionBoundary` → `Division` — boundary line *defines the boundary of* division
-#### 2. Association as a Separate Feature (Complex Relationships)
+##### `AGGREGATION` — Grouping Without Lifecycle Dependency
-When the relationship itself needs to store information, create a dedicated feature to represent that relationship. This is like creating a "relationship record" that describes how two things are connected.
+A grouping or collection relationship where both members are independently viable. No lifecycle dependency — the member survives reassignment to another group or orphaning.
-**Simple relationship (use Pattern 1):**
+**Test question:** *"Can both sides belong to something else or nothing and still be a valid map feature?"* If yes, they form an `AGGREGATION`.
-- "Building A belongs to Neighborhood B" - just needs an ID reference
+**Examples:**
+- `Route` → `Segment` — route *groups* segments
+- `TrailSegment` → `NationalPark` — segment *is grouped by* park
-**Complex relationship (use Pattern 2):**
+##### `HIERARCHY` — Organizational Nesting
-- "Admin Area X has City Center Y as its primary center since 2010 with 85% confidence" - the relationship has properties (`type=primary`, `date=2010`, `confidence=85%`)
+An organizational or classificatory nesting relationship. This is not about structural assembly — it's about administrative parentage, taxonomy, or categorization.
-```python
-class AdminCityCenterAssociation(OvertureFeature[Literal["associations"], Literal["admin_city_center"]]):
- """Describes how an administrative area relates to a city center."""
+**Test question:** *"Is this about organizational subordination rather than structural assembly?"* If yes, it's `HIERARCHY`.
- # The two things being connected
- admin_area_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, AdminArea)]
- city_center_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, CityCenter)]
+**Examples:**
+- `DivisionArea` → `Division` — area *is child of* division
+- `Division` → `Division` — child division nested under parent
- # Information about the relationship itself
- relationship_type: Literal["primary_center", "secondary_center"] = "primary_center"
- established_date: str | None = None
- confidence_score: Annotated[float64, Field(ge=0.0, le=1.0)] | None = None
+##### `ASSOCIATION` — Peer-Level Reference
+
+A peer-level reference with no ownership, containment, or nesting. Neither feature depends on or contains the other. This is the fallback when none of the stronger types apply.
+
+**Test question:** *"Are these just peers that know about each other?"* If yes, it's `ASSOCIATION`.
+
+**Examples:**
+- `Segment` → `Connector` — segment references its start/end connector
+- `Building` → `Address` — a building references its address, neither owns the other
+
+---
+
+#### Selection Priority
+
+When a relationship could fit multiple types, the choice follows a **diamond decision**: start at the top, fork in the middle based on the *kind* of coupling, and fall through to the bottom only when no stronger type applies.
+
+```text
+ COMPOSITION
+ / \
+AGGREGATION HIERARCHY
+ \ /
+ ASSOCIATION
```
-When to use separate association features:
+| If the relationship implies... | Use |
+|---------------------------------------------------------|----------------|
+| Structural whole-part with lifecycle dependency | `COMPOSITION` |
+| Geometric boundary definition (lifecycle dependent) | `COMPOSITION` |
+| Grouping/collection without lifecycle dependency | `AGGREGATION` |
+| Organizational nesting or classification tree | `HIERARCHY` |
+| Peer-level reference, no ownership or nesting | `ASSOCIATION` |
-- The relationship has properties: confidence scores, dates, types, notes
-- Many-to-many connections: one admin area can have multiple city centers, one city center can serve multiple admin areas
-- You need to query the relationships: "show me all primary city center relationships established after 2015"
+---
-This focuses on the core concept: when relationships carry data, they become features themselves.
+#### The `role` Field
-#### 3. Collection References
+The `Reference` annotation accepts an optional `role` parameter — a snake_case string that further qualifies the relationship from the source's perspective. It has no effect on schema validation; it is informational metadata for documentation and tooling.
-When a feature needs to reference multiple other features, use a list of references:
+Use `role` when the semantic type alone is ambiguous. For example, multiple `HIERARCHY` references on the same model can be disambiguated:
```python
-class Route(OvertureFeature[Literal["transportation"], Literal["route"]]):
- """A transportation route that passes through multiple segments."""
+# Without role: two HIERARCHY references to Division — which is which?
+parent_division_id: Annotated[Id, Reference(Relationship.HIERARCHY, Division)]
+capital_division_ids: Annotated[list[Id], Reference(Relationship.HIERARCHY, Division)]
- segment_ids: Annotated[
- list[Id],
- Field(min_length=1, description="Ordered list of segments in this route"),
- UniqueItemsConstraint(), # No duplicate segments
- Reference(Relationship.CONNECTS_TO, TransportationSegment) # All IDs reference segments
- ]
+# With role: unambiguous
+parent_division_id: Annotated[Id, Reference(Relationship.HIERARCHY, Division, role="child_of")]
+capital_division_ids: Annotated[list[Id], Reference(Relationship.HIERARCHY, Division, role="has_as_capital")]
+```
-class Building(OvertureFeature[Literal["buildings"], Literal["building"]]):
- """A building that may contain multiple building parts."""
+The `role` must be a non-empty snake_case string (lowercase letters, digits, underscores). It describes the source's role relative to the target using source-perspective phrasing.
- part_ids: Annotated[
- list[Id] | None,
- Field(description="Building parts that belong to this building"),
- Reference(Relationship.BOUNDARY_OF, BuildingPart) # All IDs reference building parts
- ] = None
-```
+---
-#### Best Practices
+#### Implementation Patterns
-##### Always Use Reference Annotations
+##### 1. Direct References (Foreign Keys)
-Include `Reference` annotations for semantic clarity and documentation:
+The fundamental pattern is a direct reference where one feature "points to" another using an ID field with type safety and semantic information.
```python
-# Good - complete relationship information
-division_id: Annotated[
- Id,
- Reference(Relationship.BELONGS_TO, Division),
- Field(description="Division this area belongs to")
-]
+from typing import Annotated, Literal
+from pydantic import Field
+from overture.schema.core import OvertureFeature
+from overture.schema.system.ref import Id, Reference, Relationship
-# Avoid - missing semantic information
-division_id: Id
+# COMPOSITION — part points to its whole
+class BuildingPart(OvertureFeature[Literal["buildings"], Literal["building_part"]]):
+ """A structural part of a building."""
+ building_id: Annotated[
+ Id,
+ Reference(Relationship.COMPOSITION, Building, role="part_of"),
+ Field(description="The building to which this part belongs")
+ ]
+
+# HIERARCHY — child points to parent
+class DivisionArea(OvertureFeature[Literal["divisions"], Literal["division_area"]]):
+ """Area polygon nested under a division."""
+ division_id: Annotated[
+ Id,
+ Reference(Relationship.HIERARCHY, Division, role="child_of"),
+ Field(description="Division ID of the parent division of this area.")
+ ]
+
+# ASSOCIATION — peer reference, no ownership
+class ConnectorReference(BaseModel):
+ """Reference to a connector feature."""
+ connector_id: Annotated[
+ Id,
+ Reference(Relationship.ASSOCIATION, Connector, role="connects_to")
+ ]
```
-##### Choose the Right Pattern
+##### 2. Association as a Separate Feature (Complex Relationships)
+
+When the relationship itself needs to store information, create a dedicated feature to represent it. This applies regardless of the semantic type — any of the four types can carry metadata.
+
+**Simple relationship (use Pattern 1):**
+- "Building Part A is part of Building B" — just needs an ID reference.
-- **Simple relationships** → Direct references (foreign keys)
-- **Relationships with metadata** → Separate association features
+**Complex relationship (use Pattern 2):**
+- "Admin Area X has City Center Y as its primary center since 2010 with 85% confidence" — the relationship has properties.
```python
-# Simple: just a link
-admin_area_id: Annotated[Id, Reference(Relationship.BELONGS_TO, AdminArea)]
+class AdminCityCenterAssociation(
+ OvertureFeature[Literal["associations"], Literal["admin_city_center"]]
+):
+ """Describes how an administrative area relates to a city center."""
-# Complex: relationship has properties
-class AdminCityCenterAssociation(Feature[...]):
- admin_area_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, AdminArea)]
- city_center_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, CityCenter)]
- relationship_type: Literal["primary", "secondary"] = "primary"
+ admin_area_id: Annotated[
+ Id,
+ Reference(Relationship.ASSOCIATION, AdminArea)
+ ]
+ city_center_id: Annotated[
+ Id,
+ Reference(Relationship.ASSOCIATION, CityCenter)
+ ]
+
+ # Information about the relationship itself
+ relationship_type: Literal["primary_center", "secondary_center"] = "primary_center"
+ established_date: str | None = None
+ confidence_score: Annotated[float64, Field(ge=0.0, le=1.0)] | None = None
```
-#### Association Pattern Examples
+**When to use separate association features:**
+- The relationship has properties (confidence scores, dates, types, notes).
+- Many-to-many connections exist.
+- You need to query the relationships independently.
+
+##### 3. Collection References
-**Transportation Network:**
+When a feature needs to reference multiple other features, use a list of references. The semantic type still matters.
```python
-# Segments connect to connectors (intersection points)
-class Segment(OvertureFeature[Literal["transportation"], Literal["segment"]]):
- from_connector_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, Connector)]
- to_connector_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, Connector)]
+# COMPOSITION — boundary defines two divisions
+class DivisionBoundary(OvertureFeature[Literal["divisions"], Literal["division_boundary"]]):
+ """A boundary line between two divisions."""
+ division_ids: Annotated[
+ list[Annotated[Id, Reference(Relationship.COMPOSITION, Division, role="boundary_of")]],
+ Field(min_length=2, max_length=2, description="Left and right divisions"),
+ ]
-# Routes contain multiple segments in order
-class Route(Feature[Literal["transportation"], Literal["route"]]):
+# AGGREGATION — route groups segments
+class Route(OvertureFeature[Literal["transportation"], Literal["route"]]):
+ """A transportation route passing through multiple segments."""
segment_ids: Annotated[
list[Id],
- Reference(Relationship.CONNECTS_TO, TransportationSegment),
- Field(description="Ordered list of segments in this route")
+ Reference(Relationship.AGGREGATION, TransportationSegment, role="groups"),
+ Field(min_length=1, description="Ordered segments in this route"),
+ UniqueItemsConstraint(),
]
```
-**Administrative Hierarchy:**
+---
-```python
-# Division areas belong to divisions
-class DivisionArea(OvertureFeature[Literal["divisions"], Literal["division_area"]]):
- division_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Division)]
+#### Best Practices
-# Places belong to administrative areas
-class Place(OvertureFeature[Literal["places"], Literal["place"]]):
- admin_area_id: Annotated[Id | None, Reference(Relationship.BELONGS_TO, AdminArea)] = None
-```
+##### Always Use Reference Annotations
-**Building Relationships:**
+Include `Reference` annotations for semantic clarity and documentation:
```python
-# Building parts belong to buildings
-class BuildingPart(OvertureFeature[Literal["buildings"], Literal["building_part"]]):
- building_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Building)]
+# Good — complete relationship information with semantic type and role
+division_id: Annotated[
+ Id,
+ Reference(Relationship.HIERARCHY, Division, role="child_of"),
+ Field(description="Division ID of the parent division of this area.")
+]
-# Buildings can reference their address
-class Building(OvertureFeature[Literal["buildings"], Literal["building"]]):
- address_id: Annotated[Id | None, Reference(Relationship.CONNECTS_TO, Address)] = None
+# Avoid — missing semantic information
+division_id: Id
```
+##### Choose the Right Semantic Type First, Then the Right Pattern
+
+1. **Determine the semantic type** using the selection priority and test questions above.
+2. **Then choose the implementation pattern:**
+ - Simple relationships → Direct references (Pattern 1)
+ - Relationships with metadata → Separate association features (Pattern 2)
+ - One-to-many references → Collection references (Pattern 3)
+
### Discriminated Unions
**What is a discriminated union?** A discriminated union is a type that can be backed by one of several different models, where a specific field (the "discriminator") determines which model it actually is. Think of it like a form that changes its fields based on a category selection.
diff --git a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part.py b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part.py
index 980645101..533f4623a 100644
--- a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part.py
+++ b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part.py
@@ -52,5 +52,5 @@ class BuildingPart(
building_id: Annotated[
Id,
Field(description="The building to which this part belongs"),
- Reference(Relationship.BELONGS_TO, Building),
+ Reference(Relationship.COMPOSITION, Building, role="part_of"),
]
diff --git a/packages/overture-schema-codegen/src/overture/schema/codegen/extraction/field_constraints.py b/packages/overture-schema-codegen/src/overture/schema/codegen/extraction/field_constraints.py
index 5cdc3dcd2..0db927065 100644
--- a/packages/overture-schema-codegen/src/overture/schema/codegen/extraction/field_constraints.py
+++ b/packages/overture-schema-codegen/src/overture/schema/codegen/extraction/field_constraints.py
@@ -96,6 +96,9 @@ def describe_field_constraint(
target = constraint.relatee
target_id = TypeIdentity.of(target)
target_str = link_fn(target_id) if link_fn else f"`{target.__name__}`"
+ if constraint.role:
+ role_label = constraint.role.replace("_", " ")
+ return f"References {target_str} ({rel_label}, {role_label})"
return f"References {target_str} ({rel_label})"
if isinstance(constraint, Interval):
desc = _describe_interval(constraint)
diff --git a/packages/overture-schema-codegen/tests/codegen_test_support.py b/packages/overture-schema-codegen/tests/codegen_test_support.py
index 64facf5a9..4df4c0e04 100644
--- a/packages/overture-schema-codegen/tests/codegen_test_support.py
+++ b/packages/overture-schema-codegen/tests/codegen_test_support.py
@@ -122,7 +122,8 @@ class Venue(
]
capacity: Annotated[int, Field(ge=1)] | None = None
resident_ensemble: (
- Annotated[Id, Reference(Relationship.BELONGS_TO, Instrument)] | None
+ Annotated[Id, Reference(Relationship.AGGREGATION, Instrument, role="part_of")]
+ | None
) = None
diff --git a/packages/overture-schema-codegen/tests/golden/markdown/venue.md b/packages/overture-schema-codegen/tests/golden/markdown/venue.md
index edb0578ef..1c76511a4 100644
--- a/packages/overture-schema-codegen/tests/golden/markdown/venue.md
+++ b/packages/overture-schema-codegen/tests/golden/markdown/venue.md
@@ -15,7 +15,7 @@ A location where musical performances take place.
| `description` | `string` (optional) | *At least one of `name`, `description` must be set* |
| `geometry` | `geometry` | *Allowed geometry types: Point, Polygon* |
| `capacity` | `int64` (optional) | *`≥ 1`* |
-| `resident_ensemble` | `Id` (optional) | A unique identifier
*References `Instrument` (belongs to)* |
+| `resident_ensemble` | `Id` (optional) | A unique identifier
*References `Instrument` (aggregation, part of)* |
## Constraints
diff --git a/packages/overture-schema-codegen/tests/test_constraint_description.py b/packages/overture-schema-codegen/tests/test_constraint_description.py
index 9961ef2b2..cd31f2554 100644
--- a/packages/overture-schema-codegen/tests/test_constraint_description.py
+++ b/packages/overture-schema-codegen/tests/test_constraint_description.py
@@ -382,22 +382,22 @@ def test_geometry_type_all_types(self) -> None:
== "Allowed geometry types: LineString, Point, Polygon"
)
- def test_reference_belongs_to(self) -> None:
+ def test_reference_composition(self) -> None:
class Target(Identified):
pass
- constraint = Reference(Relationship.BELONGS_TO, Target)
+ constraint = Reference(Relationship.COMPOSITION, Target)
assert (
- describe_field_constraint(constraint) == "References `Target` (belongs to)"
+ describe_field_constraint(constraint) == "References `Target` (composition)"
)
- def test_reference_connects_to(self) -> None:
+ def test_reference_association(self) -> None:
class Other(Identified):
pass
- constraint = Reference(Relationship.CONNECTS_TO, Other)
+ constraint = Reference(Relationship.ASSOCIATION, Other)
assert (
- describe_field_constraint(constraint) == "References `Other` (connects to)"
+ describe_field_constraint(constraint) == "References `Other` (association)"
)
def test_reference_link_fn_receives_type_identity(self) -> None:
@@ -412,13 +412,13 @@ def link_fn(tid: TypeIdentity) -> str:
received.append(tid)
return f"[`{tid.name}`](link)"
- constraint = Reference(Relationship.BELONGS_TO, Target)
+ constraint = Reference(Relationship.COMPOSITION, Target)
result = describe_field_constraint(constraint, link_fn=link_fn)
assert len(received) == 1
assert received[0].obj is Target
assert received[0].name == "Target"
- assert result == "References [`Target`](link) (belongs to)"
+ assert result == "References [`Target`](link) (composition)"
def test_reference_link_fn_used_in_output(self) -> None:
"""link_fn return value appears verbatim in the description."""
@@ -426,11 +426,11 @@ def test_reference_link_fn_used_in_output(self) -> None:
class Target(Identified):
pass
- constraint = Reference(Relationship.CONNECTS_TO, Target)
+ constraint = Reference(Relationship.ASSOCIATION, Target)
result = describe_field_constraint(
constraint, link_fn=lambda tid: f"[`{tid.name}`](path/to/target)"
)
- assert result == "References [`Target`](path/to/target) (connects to)"
+ assert result == "References [`Target`](path/to/target) (association)"
class TestConstraintDisplayText:
@@ -442,7 +442,7 @@ def test_link_fn_forwarded_to_reference_constraint(self) -> None:
class Target(Identified):
pass
- constraint = Reference(Relationship.BELONGS_TO, Target)
+ constraint = Reference(Relationship.COMPOSITION, Target)
cs = ConstraintSource(source_ref=None, source_name=None, constraint=constraint)
received: list[TypeIdentity] = []
@@ -455,4 +455,4 @@ def link_fn(tid: TypeIdentity) -> str:
assert len(received) == 1
assert received[0].obj is Target
- assert result == "References [`Target`](link) (belongs to)"
+ assert result == "References [`Target`](link) (composition)"
diff --git a/packages/overture-schema-codegen/tests/test_markdown_renderer.py b/packages/overture-schema-codegen/tests/test_markdown_renderer.py
index 5ebddcab9..698f9d70a 100644
--- a/packages/overture-schema-codegen/tests/test_markdown_renderer.py
+++ b/packages/overture-schema-codegen/tests/test_markdown_renderer.py
@@ -618,7 +618,7 @@ def test_venue_reference_links_when_context_available(self) -> None:
lines = result.splitlines()
ref_line = next(line for line in lines if "| `resident_ensemble` |" in line)
assert "[`Instrument`](instrument.md)" in ref_line
- assert "belongs to" in ref_line
+ assert "aggregation, part of" in ref_line
def test_venue_reference_unlinked_without_context(self) -> None:
"""Reference constraint renders as plain code when no LinkContext."""
@@ -629,7 +629,7 @@ def test_venue_reference_unlinked_without_context(self) -> None:
lines = result.splitlines()
ref_line = next(line for line in lines if "| `resident_ensemble` |" in line)
assert "References `Instrument`" in ref_line
- assert "belongs to" in ref_line
+ assert "aggregation, part of" in ref_line
class TestRenderEnumBasic:
diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division.py
index 865caca4c..955b8d723 100644
--- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division.py
+++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division.py
@@ -116,7 +116,7 @@ class CapitalOfDivisionItem(BaseModel):
division_id: Annotated[
Id,
Field(description="ID of the division whose capital is the current division."),
- Reference(Relationship.CAPITAL_OF, Division),
+ Reference(Relationship.HIERARCHY, Division, role="capital_of"),
]
subtype: DivisionSubtype
@@ -139,7 +139,7 @@ class HierarchyItem(BaseModel):
the division itself, and any other division that is an ancestor of the division's parent.
""").strip()
),
- Reference(Relationship.DESCENDANT_OF, Division),
+ Reference(Relationship.HIERARCHY, Division, role="descendant_of"),
]
subtype: DivisionSubtype
name: Annotated[
@@ -276,7 +276,7 @@ class Division(
parent divisions.
""").strip()
),
- Reference(Relationship.CHILD_OF, Division),
+ Reference(Relationship.HIERARCHY, Division, role="child_of"),
] = None
admin_level: AdminLevel | None = None
@@ -382,7 +382,7 @@ class Division(
list[
Annotated[
Id,
- Reference(Relationship.CAPITALLED_BY, Division),
+ Reference(Relationship.HIERARCHY, Division, role="has_as_capital"),
]
]
| None,
diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area.py
index 677906abb..61a30f7ab 100644
--- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area.py
+++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area.py
@@ -132,9 +132,9 @@ class DivisionArea(
division_id: Annotated[
Id,
Field(
- description="Division ID of the division this area belongs to.",
+ description="Division ID of the parent division of this area.",
),
- Reference(Relationship.BELONGS_TO, Division),
+ Reference(Relationship.HIERARCHY, Division, role="child_of"),
]
country: Annotated[
CountryCodeAlpha2,
diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary.py
index 0fcf751f9..2a6fd886d 100644
--- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary.py
+++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary.py
@@ -125,7 +125,7 @@ class DivisionBoundary(
list[
Annotated[
Id,
- Reference(Relationship.BOUNDARY_OF, Division),
+ Reference(Relationship.COMPOSITION, Division, role="boundary_of"),
]
],
Field(
diff --git a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json
index 8f800c551..3838c20e0 100644
--- a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json
+++ b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json
@@ -517,7 +517,7 @@
"type": "string"
},
"division_id": {
- "description": "Division ID of the division this area belongs to.",
+ "description": "Division ID of the parent division of this area.",
"minLength": 1,
"pattern": "^\\S+$",
"title": "Division Id",
diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py
index 1e4b8be72..26ebf5085 100644
--- a/packages/overture-schema-system/src/overture/schema/system/__init__.py
+++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py
@@ -144,7 +144,7 @@
>>> class Park(Identified):
... pass
>>> class ParkBench(Identified):
-... park_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Park)]
+... park_id: Annotated[Id, Reference(Relationship.COMPOSITION, Park, role="located_in")]
"""
from . import (
diff --git a/packages/overture-schema-system/src/overture/schema/system/ref/id.py b/packages/overture-schema-system/src/overture/schema/system/ref/id.py
index 2ffa5dad8..5e48af68a 100644
--- a/packages/overture-schema-system/src/overture/schema/system/ref/id.py
+++ b/packages/overture-schema-system/src/overture/schema/system/ref/id.py
@@ -44,8 +44,8 @@ class Identified(BaseModel):
... name: str = Field(description = 'Name of the room')
... house_id: Annotated[
... Id,
- ... Reference(Relationship.BELONGS_TO, House)
- ... ] = Field(description = "Unique ID of the house the room belongs to.")
+ ... Reference(Relationship.COMPOSITION, House, role="inside_of")
+ ... ] = Field(description = "Unique ID of the house the room is inside of.")
When combining `Identified` with another Pydantic model that has an `id` field, such as a
:class:`~overture.schema.system.feature.Feature`, you must derive from `Identified` first in
diff --git a/packages/overture-schema-system/src/overture/schema/system/ref/ref.py b/packages/overture-schema-system/src/overture/schema/system/ref/ref.py
index ff712104b..a5e74cd94 100644
--- a/packages/overture-schema-system/src/overture/schema/system/ref/ref.py
+++ b/packages/overture-schema-system/src/overture/schema/system/ref/ref.py
@@ -2,83 +2,111 @@
Relationships and references between related entities.
"""
+import re
from dataclasses import dataclass
-from enum import Enum
+
+from overture.schema.system.doc import DocumentedEnum
from .id import Identified
+_SNAKE_CASE_RE = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$")
+
-class Relationship(Enum):
+class Relationship(str, DocumentedEnum):
"""
- Category of relationship between two values, where the first value refers to the second one.
+ The kind of relationship that exists between two entities.
+
+ Relationships represent connections between different features or models. Think of them as links
+ that connect related pieces of information, like a building part that is structurally part of a
+ building; or a division area that is administratively nested under a division.
+
+ Every kind of relationship between two entities says something about how tightly those entities
+ are connected, for example: whether one depends on the other to exist (composition); whether
+ both members are strongly-connected but independently viable (aggregation); whether one of the
+ two is superior while the other is subordinate (hierarchy); or whether they are simply peers
+ with a loose affiliation to one another (association).
+
+ The kinds of relationship can be thought of as forming a diamond-shaped hierarchy where
+ composition, the strongest form of relationship, appears at the top; aggregation and hierarchy
+ as independent relationship types that are weaker than composition but stronger than
+ association, appears in the middle; and association, the weakest and least well-defined form of
+ relationship, is at the bottom.
+
+ COMPOSITION
+ / \\
+ AGGREGATION HIERARCHY
+ \\ /
+ ASSOCIATION
- If we call the first value, the one that holds the reference, the relator; and the second value,
- value, the one that is referred to, as the relatee; then this value represents the relationship
- from the perspective of the relator.
+ Note that the *kind* of a relationship does not say anything about the *directionality* of the
+ relationship. The fact that F is in a hierarchy relationship with G does not, without outside
+ information, tell you whether F is the parent or the child.
"""
- def __init__(self, value: str, doc: str) -> None:
- self._value_ = value
- self.__doc__ = doc
-
- BELONGS_TO = "belongs_to", "The relator belongs to the relatee"
- BOUNDARY_OF = "boundary_of", "The relator is a boundary of the relatee"
- CAPITAL_OF = "capital_of", "The relator is a capital of the relatee"
- CAPITALLED_BY = "capitalled_by", "The relator has the relatee as its capital"
- CHILD_OF = "child_of", "The relator is a child of the relatee"
- CONNECTS_TO = "connects_to", "The relator connects to the relatee"
- DESCENDANT_OF = (
- "descendant_of",
- "The relator is a hierarchical descendant of the relatee",
+ COMPOSITION = (
+ "composition",
+ "A structural whole-part relationship with lifecycle dependency",
)
+ AGGREGATION = "aggregation", "A grouping relationship without lifecycle dependency"
+ HIERARCHY = (
+ "hierarchy",
+ "A parent/child relationship within a hierarchy such as an organization or taxonomy",
+ )
+ ASSOCIATION = "association", "A peer-level reference without ownership or nesting"
@dataclass(frozen=True, slots=True)
class Reference:
"""
- Annotation class describing a relationship between two values where the relatee is referenced
- by its unique ID.
+ Annotation class describing a relationship between two values where the relator refers to the
+ relatee by the latter's unique ID.
+
+ The relator, which is the subject or source of the relationship, is the type annotated with an
+ instance of this class ("the thing that relates"). The relatee is type that is the object or
+ target of the relationship ("the thing related to").
Parameters
----------
relationship : Relationship
- The kind of relationship between the relator (the type annotated with an instance of this
- class that is said to "hold the reference") and the relatee.
+ The category of the relationship between the relator and the relatee.
relatee : type[Identified]
- The type that is the object or target of the relationship ("the thing related to").
-
- Attributes
- ----------
- relationship : Relationship
- The kind of relationship between the relator (the type annotated with an instance of this
- class that is said to "hold the reference") and the relatee.
- relatee : type[Identifier]
- The type that is the object or target of the relationship ("the thing related to").
+ The type that is the target of the relationship.
+ role : str | None
+ An optional snake_case descriptor that further describes the relationship from the
+ perspective of the relator. This field has no effect on schema validation; it is
+ informational metadata for documentation and tooling.
Examples
--------
- A hypothetical ParkBench model holds a foreign key relationship to the model of the park the
- bench belongs to.
+ A hypothetical ParkBench model holds a foreign key relationship to the model of the park that
+ contains it.
>>> from typing import Annotated
>>> from overture.schema.system.ref import Id, Identified
>>> class Park(Identified):
... pass
>>> class ParkBench(Identified):
- ... park_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Park)]
+ ... park_id: Annotated[Id, Reference(Relationship.COMPOSITION, Park, role="located_in")]
"""
relationship: Relationship
relatee: type[Identified]
+ role: str | None = None
def __post_init__(self) -> None:
if not isinstance(self.relationship, Relationship):
raise TypeError(
- f"`relationship` must be a member of the `Relationship` enumeration, but {self.relationship} is a `{type(self.relationship).__name__}`"
+ f"`relationship` must be a member of the `Relationship` enumeration, "
+ f"but {self.relationship} is a `{type(self.relationship).__name__}`"
)
if not isinstance(self.relatee, type) or not issubclass(
self.relatee, Identified
):
raise TypeError(
- f"`relatee` must be a type derived from `Identified`, but {self.relatee} is a `{type(self.relatee).__name__}`"
+ f"`relatee` must be a type derived from `Identified`, "
+ f"but {self.relatee} is a `{type(self.relatee).__name__}`"
+ )
+ if self.role is not None and not _SNAKE_CASE_RE.match(self.role):
+ raise ValueError(
+ f"`role` must be a non-empty snake_case string, but got {self.role!r}"
)
diff --git a/packages/overture-schema-system/tests/ref/test_ref.py b/packages/overture-schema-system/tests/ref/test_ref.py
index 98bc0d610..b32d858f6 100644
--- a/packages/overture-schema-system/tests/ref/test_ref.py
+++ b/packages/overture-schema-system/tests/ref/test_ref.py
@@ -9,19 +9,53 @@ def test_reference_err_not_a_relationship() -> None:
Reference("foo", Identified) # type: ignore[arg-type]
-def test_reference_err_referee_not_a_feature_type() -> None:
+def test_reference_err_relatee_not_a_feature_type() -> None:
with pytest.raises(TypeError):
- Reference(Relationship.BELONGS_TO, str) # type: ignore[arg-type]
+ Reference(Relationship.COMPOSITION, str) # type: ignore[arg-type]
-def test_reference_err_referee_not_a_type() -> None:
+def test_reference_err_relatee_not_a_type() -> None:
with pytest.raises(TypeError):
- Reference(Relationship.BELONGS_TO, "foo") # type: ignore[arg-type]
+ Reference(Relationship.COMPOSITION, "foo") # type: ignore[arg-type]
+
+
+def test_reference_err_role_not_snake_case() -> None:
+ with pytest.raises(ValueError):
+ Reference(Relationship.COMPOSITION, Identified, role="NotSnakeCase")
+
+
+def test_reference_err_role_empty() -> None:
+ with pytest.raises(ValueError):
+ Reference(Relationship.COMPOSITION, Identified, role="")
+
+
+def test_reference_err_role_has_spaces() -> None:
+ with pytest.raises(ValueError):
+ Reference(Relationship.COMPOSITION, Identified, role="has spaces")
@pytest.mark.parametrize("relationship", tuple(Relationship))
-def test_reference_ok(relationship: Relationship) -> None:
+def test_reference_ok_without_role(relationship: Relationship) -> None:
ref = Reference(relationship, Identified)
assert ref.relationship is relationship
assert ref.relatee is Identified
+ assert ref.role is None
+
+
+@pytest.mark.parametrize("relationship", tuple(Relationship))
+def test_reference_ok_with_role(relationship: Relationship) -> None:
+ ref = Reference(relationship, Identified, role="belongs_to")
+
+ assert ref.relationship is relationship
+ assert ref.relatee is Identified
+ assert ref.role == "belongs_to"
+
+
+@pytest.mark.parametrize(
+ "role",
+ ["part_of", "child_of", "connects_to", "boundary_of", "has_as_capital"],
+)
+def test_reference_valid_roles(role: str) -> None:
+ ref = Reference(Relationship.COMPOSITION, Identified, role=role)
+ assert ref.role == role
diff --git a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py
index 246d2efea..c4fbae42c 100644
--- a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py
+++ b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py
@@ -54,7 +54,9 @@ class ConnectorReference(BaseModel):
# Required
- connector_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, _connector_type())]
+ connector_id: Annotated[
+ Id, Reference(Relationship.ASSOCIATION, _connector_type(), role="connects_to")
+ ]
@no_extra_fields