diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationToken.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationToken.java new file mode 100644 index 00000000..fb123589 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationToken.java @@ -0,0 +1,24 @@ +package org.raddatz.familienarchiv.relationship; + +/** + * Abstract direction tokens emitted by the BFS in {@link RelationshipInferenceService}. + * A path is a list of these tokens — e.g. niece-of-me is {@code [SIBLING, DOWN]}. + * + *
Reversing a path swaps {@link #UP} ↔ {@link #DOWN} and leaves the symmetric
+ * tokens ({@link #SPOUSE}, {@link #SIBLING}) untouched.
+ */
+public enum RelationToken {
+ UP,
+ DOWN,
+ SPOUSE,
+ SIBLING;
+
+ public RelationToken reverse() {
+ return switch (this) {
+ case UP -> DOWN;
+ case DOWN -> UP;
+ case SPOUSE -> SPOUSE;
+ case SIBLING -> SIBLING;
+ };
+ }
+}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceService.java b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceService.java
new file mode 100644
index 00000000..b2ef53c3
--- /dev/null
+++ b/backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceService.java
@@ -0,0 +1,208 @@
+package org.raddatz.familienarchiv.relationship;
+
+import lombok.RequiredArgsConstructor;
+import org.raddatz.familienarchiv.model.Person;
+import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO;
+import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipWithPersonDTO;
+import org.raddatz.familienarchiv.relationship.dto.PersonNodeDTO;
+import org.raddatz.familienarchiv.repository.PersonRepository;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+
+/**
+ * Derives indirect family relationships by BFS over the family-graph subset
+ * (PARENT_OF, SPOUSE_OF, SIBLING_OF). Time-ignorant: from_year / to_year are
+ * not consulted. Siblings are also derived from shared parents — no SIBLING_OF
+ * row is required.
+ */
+@Service
+@RequiredArgsConstructor
+public class RelationshipInferenceService {
+
+ static final int MAX_DEPTH = 8;
+
+ /** "distant" is the catch-all label for paths that do not match the LABEL_MAP. */
+ static final String LABEL_DISTANT = "distant";
+
+ private static final Map
+ * 18 unit tests, one per LABEL_MAP entry plus one no-path case. Each setup wires
+ * a small graph through the mocked repository and asserts the exact abstract
+ * token sequence emitted by BFS — except {@code distant_label_for_long_chain}
+ * which asserts the fallback label, and {@code returns_empty_when_no_path}
+ * which asserts no result.
+ */
+@ExtendWith(MockitoExtension.class)
+class RelationshipInferenceServiceTest {
+
+ @Mock PersonRelationshipRepository relationshipRepository;
+ @Mock PersonRepository personRepository;
+ @InjectMocks RelationshipInferenceService service;
+
+ // --- 1: parent ---
+ @Test
+ void parent_path_emits_UP() {
+ Person parent = person();
+ Person child = person();
+ givenEdges(parentOf(parent, child));
+
+ assertThat(service.findShortestPath(child.getId(), parent.getId()))
+ .hasValue(List.of(UP));
+ }
+
+ // --- 2: child ---
+ @Test
+ void child_path_emits_DOWN() {
+ Person parent = person();
+ Person child = person();
+ givenEdges(parentOf(parent, child));
+
+ assertThat(service.findShortestPath(parent.getId(), child.getId()))
+ .hasValue(List.of(DOWN));
+ }
+
+ // --- 3: spouse ---
+ @Test
+ void spouse_path_emits_SPOUSE() {
+ Person a = person();
+ Person b = person();
+ givenEdges(spouseOf(a, b));
+
+ assertThat(service.findShortestPath(a.getId(), b.getId()))
+ .hasValue(List.of(SPOUSE));
+ }
+
+ // --- 4: sibling ---
+ @Test
+ void sibling_path_emits_SIBLING() {
+ Person a = person();
+ Person b = person();
+ givenEdges(siblingOf(a, b));
+
+ assertThat(service.findShortestPath(a.getId(), b.getId()))
+ .hasValue(List.of(SIBLING));
+ }
+
+ // --- 5: grandparent (UP, UP) ---
+ @Test
+ void grandparent_path_emits_UP_UP() {
+ Person grandparent = person();
+ Person parent = person();
+ Person grandchild = person();
+ givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
+
+ assertThat(service.findShortestPath(grandchild.getId(), grandparent.getId()))
+ .hasValue(List.of(UP, UP));
+ }
+
+ // --- 6: grandchild (DOWN, DOWN) ---
+ @Test
+ void grandchild_path_emits_DOWN_DOWN() {
+ Person grandparent = person();
+ Person parent = person();
+ Person grandchild = person();
+ givenEdges(parentOf(grandparent, parent), parentOf(parent, grandchild));
+
+ assertThat(service.findShortestPath(grandparent.getId(), grandchild.getId()))
+ .hasValue(List.of(DOWN, DOWN));
+ }
+
+ // --- 7: great-grandparent (UP, UP, UP) ---
+ @Test
+ void great_grandparent_path_emits_UP_UP_UP() {
+ Person g = person();
+ Person p = person();
+ Person c = person();
+ Person gc = person();
+ givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
+
+ assertThat(service.findShortestPath(gc.getId(), g.getId()))
+ .hasValue(List.of(UP, UP, UP));
+ }
+
+ // --- 8: great-grandchild (DOWN, DOWN, DOWN) ---
+ @Test
+ void great_grandchild_path_emits_DOWN_DOWN_DOWN() {
+ Person g = person();
+ Person p = person();
+ Person c = person();
+ Person gc = person();
+ givenEdges(parentOf(g, p), parentOf(p, c), parentOf(c, gc));
+
+ assertThat(service.findShortestPath(g.getId(), gc.getId()))
+ .hasValue(List.of(DOWN, DOWN, DOWN));
+ }
+
+ // --- 9: uncle/aunt (UP, SIBLING) ---
+ @Test
+ void uncle_aunt_path_emits_UP_SIBLING() {
+ Person grandparent = person();
+ Person parent = person();
+ Person uncle = person();
+ Person me = person();
+ // grandparent has two children: parent and uncle. me is parent's child.
+ givenEdges(
+ parentOf(grandparent, parent),
+ parentOf(grandparent, uncle),
+ parentOf(parent, me));
+
+ assertThat(service.findShortestPath(me.getId(), uncle.getId()))
+ .hasValue(List.of(UP, SIBLING));
+ }
+
+ // --- 10: niece/nephew (SIBLING, DOWN) ---
+ @Test
+ void niece_nephew_path_emits_SIBLING_DOWN() {
+ Person grandparent = person();
+ Person uncle = person();
+ Person sibling = person();
+ Person niece = person();
+ // grandparent has uncle + sibling; sibling has niece.
+ givenEdges(
+ parentOf(grandparent, uncle),
+ parentOf(grandparent, sibling),
+ parentOf(sibling, niece));
+
+ assertThat(service.findShortestPath(uncle.getId(), niece.getId()))
+ .hasValue(List.of(SIBLING, DOWN));
+ }
+
+ // --- 11: great uncle/aunt (UP, UP, SIBLING) ---
+ @Test
+ void great_uncle_aunt_path_emits_UP_UP_SIBLING() {
+ Person ggp = person();
+ Person grandparent = person();
+ Person greatUncle = person();
+ Person parent = person();
+ Person me = person();
+ givenEdges(
+ parentOf(ggp, grandparent),
+ parentOf(ggp, greatUncle),
+ parentOf(grandparent, parent),
+ parentOf(parent, me));
+
+ assertThat(service.findShortestPath(me.getId(), greatUncle.getId()))
+ .hasValue(List.of(UP, UP, SIBLING));
+ }
+
+ // --- 12: great niece/nephew (SIBLING, DOWN, DOWN) ---
+ @Test
+ void great_niece_nephew_path_emits_SIBLING_DOWN_DOWN() {
+ Person grandparent = person();
+ Person sibling = person();
+ Person greatUncle = person();
+ Person niece = person();
+ Person greatNiece = person();
+ givenEdges(
+ parentOf(grandparent, sibling),
+ parentOf(grandparent, greatUncle),
+ parentOf(sibling, niece),
+ parentOf(niece, greatNiece));
+
+ assertThat(service.findShortestPath(greatUncle.getId(), greatNiece.getId()))
+ .hasValue(List.of(SIBLING, DOWN, DOWN));
+ }
+
+ // --- 13: parent-in-law (SPOUSE, UP) ---
+ @Test
+ void inlaw_parent_path_emits_SPOUSE_UP() {
+ Person inlaw = person();
+ Person spouse = person();
+ Person me = person();
+ givenEdges(
+ parentOf(inlaw, spouse),
+ spouseOf(me, spouse));
+
+ assertThat(service.findShortestPath(me.getId(), inlaw.getId()))
+ .hasValue(List.of(SPOUSE, UP));
+ }
+
+ // --- 14: child-in-law (DOWN, SPOUSE) ---
+ @Test
+ void inlaw_child_path_emits_DOWN_SPOUSE() {
+ Person me = person();
+ Person child = person();
+ Person inlawChild = person();
+ givenEdges(
+ parentOf(me, child),
+ spouseOf(child, inlawChild));
+
+ assertThat(service.findShortestPath(me.getId(), inlawChild.getId()))
+ .hasValue(List.of(DOWN, SPOUSE));
+ }
+
+ // --- 15: sibling-in-law via my spouse's sibling (SPOUSE, SIBLING) ---
+ @Test
+ void sibling_inlaw_via_spouse_emits_SPOUSE_SIBLING() {
+ Person me = person();
+ Person spouse = person();
+ Person spouseSibling = person();
+ givenEdges(
+ spouseOf(me, spouse),
+ siblingOf(spouse, spouseSibling));
+
+ assertThat(service.findShortestPath(me.getId(), spouseSibling.getId()))
+ .hasValue(List.of(SPOUSE, SIBLING));
+ }
+
+ // --- 16: cousin (UP, SIBLING, DOWN) ---
+ @Test
+ void cousin_1_path_emits_UP_SIBLING_DOWN() {
+ Person ggp = person();
+ Person parentMine = person();
+ Person uncle = person();
+ Person me = person();
+ Person cousin = person();
+ givenEdges(
+ parentOf(ggp, parentMine),
+ parentOf(ggp, uncle),
+ parentOf(parentMine, me),
+ parentOf(uncle, cousin));
+
+ assertThat(service.findShortestPath(me.getId(), cousin.getId()))
+ .hasValue(List.of(UP, SIBLING, DOWN));
+ }
+
+ // --- 17: distant (label fallback for long chains) ---
+ @Test
+ void distant_label_for_long_chain() {
+ // Seven-generation ancestor: chain of seven PARENT_OF edges.
+ Person a0 = person();
+ Person a1 = person();
+ Person a2 = person();
+ Person a3 = person();
+ Person a4 = person();
+ Person a5 = person();
+ Person a6 = person();
+ Person a7 = person();
+ givenEdges(
+ parentOf(a0, a1),
+ parentOf(a1, a2),
+ parentOf(a2, a3),
+ parentOf(a3, a4),
+ parentOf(a4, a5),
+ parentOf(a5, a6),
+ parentOf(a6, a7));
+
+ Optional, String> LABEL_MAP = buildLabelMap();
+
+ private final PersonRelationshipRepository relationshipRepository;
+ private final PersonRepository personRepository;
+
+ private static Map
, String> buildLabelMap() {
+ Map
, String> m = new HashMap<>();
+ m.put(List.of(RelationToken.UP), "parent");
+ m.put(List.of(RelationToken.DOWN), "child");
+ m.put(List.of(RelationToken.SPOUSE), "spouse");
+ m.put(List.of(RelationToken.SIBLING), "sibling");
+ m.put(List.of(RelationToken.UP, RelationToken.UP), "grandparent");
+ m.put(List.of(RelationToken.DOWN, RelationToken.DOWN), "grandchild");
+ m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.UP), "great_grandparent");
+ m.put(List.of(RelationToken.DOWN, RelationToken.DOWN, RelationToken.DOWN), "great_grandchild");
+ m.put(List.of(RelationToken.UP, RelationToken.SIBLING), "uncle_aunt");
+ m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN), "niece_nephew");
+ m.put(List.of(RelationToken.UP, RelationToken.UP, RelationToken.SIBLING), "great_uncle_aunt");
+ m.put(List.of(RelationToken.SIBLING, RelationToken.DOWN, RelationToken.DOWN), "great_niece_nephew");
+ m.put(List.of(RelationToken.SPOUSE, RelationToken.UP), "inlaw_parent");
+ m.put(List.of(RelationToken.DOWN, RelationToken.SPOUSE), "inlaw_child");
+ m.put(List.of(RelationToken.SPOUSE, RelationToken.SIBLING), "sibling_inlaw");
+ m.put(List.of(RelationToken.SIBLING, RelationToken.SPOUSE), "sibling_inlaw");
+ m.put(List.of(RelationToken.UP, RelationToken.SIBLING, RelationToken.DOWN), "cousin_1");
+ return Collections.unmodifiableMap(m);
+ }
+
+ /**
+ * Shortest token path from {@code from} to {@code to}, or empty if unreachable
+ * within {@link #MAX_DEPTH} hops. Package-private to permit direct path
+ * assertions in unit tests.
+ */
+ Optional
> findShortestPath(UUID from, UUID to) {
+ if (from.equals(to)) return Optional.empty();
+ Map
> aToB = findShortestPath(a, b);
+ if (aToB.isEmpty()) return Optional.empty();
+ List
> bfs(Map