From acea4a60f2cc8d55fbb22a6a8537690e2099ef2e Mon Sep 17 00:00:00 2001 From: Marcel Date: Mon, 27 Apr 2026 14:12:51 +0200 Subject: [PATCH] feat(stammbaum): inference service with BFS + LABEL_MAP (TDD) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RelationToken enum (UP/DOWN/SPOUSE/SIBLING) with reverse(), and RelationshipInferenceService with: - Bidirectional adjacency map: PARENT_OF emits UP and DOWN, SPOUSE_OF and SIBLING_OF both directions. - Virtual SIBLING edges derived from shared parents — no SIBLING_OF row required for siblings to appear. - BFS with MAX_DEPTH=8. - 17-entry LABEL_MAP covering parent, child, spouse, sibling, grand*, great-grand*, uncle/aunt, niece/nephew, great-uncle/aunt, great-niece/ nephew, in-law parent/child, sibling-in-law (both paths), cousin_1. - "distant" fallback for any path not in LABEL_MAP. - Two-sided labels via path reversal. 18 unit tests written first against a stub; all 18 confirmed red, then green after implementation. PersonControllerTest's anonymous DTO updated for the new isFamilyMember() projection. Refs #358. Co-Authored-By: Claude Sonnet 4.6 --- .../relationship/RelationToken.java | 24 ++ .../RelationshipInferenceService.java | 208 +++++++++++ .../controller/PersonControllerTest.java | 1 + .../RelationshipInferenceServiceTest.java | 337 ++++++++++++++++++ 4 files changed, 570 insertions(+) create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationToken.java create mode 100644 backend/src/main/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceService.java create mode 100644 backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceServiceTest.java 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, 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> adj = buildAdjacency(); + return bfs(adj, from, to); + } + + /** Two-sided label between A and B. {@code labelFromA} reads "B is my ". */ + public Optional infer(UUID a, UUID b) { + Optional> aToB = findShortestPath(a, b); + if (aToB.isEmpty()) return Optional.empty(); + List path = aToB.get(); + return Optional.of(new InferredRelationshipDTO( + labelFor(path), + labelFor(reversePath(path)), + path.size())); + } + + /** All persons reachable from {@code personId} within MAX_DEPTH, with their labels. */ + public List findAllFor(UUID personId) { + Map> adj = buildAdjacency(); + Map> shortestPaths = bfsAll(adj, personId); + shortestPaths.remove(personId); + if (shortestPaths.isEmpty()) return List.of(); + + List ids = new ArrayList<>(shortestPaths.keySet()); + Map byId = new HashMap<>(); + for (Person p : personRepository.findAllById(ids)) { + byId.put(p.getId(), p); + } + + List out = new ArrayList<>(); + for (UUID id : ids) { + Person p = byId.get(id); + if (p == null) continue; + List path = shortestPaths.get(id); + PersonNodeDTO node = new PersonNodeDTO( + p.getId(), p.getDisplayName(), p.getBirthYear(), p.getDeathYear(), p.isFamilyMember()); + out.add(new InferredRelationshipWithPersonDTO(node, labelFor(path), path.size())); + } + out.sort(Comparator.comparingInt(InferredRelationshipWithPersonDTO::hops) + .thenComparing(d -> d.person().displayName())); + return out; + } + + static String labelFor(List path) { + String specific = LABEL_MAP.get(path); + return specific != null ? specific : LABEL_DISTANT; + } + + private static List reversePath(List path) { + List reversed = new ArrayList<>(path.size()); + for (int i = path.size() - 1; i >= 0; i--) { + reversed.add(path.get(i).reverse()); + } + return List.copyOf(reversed); + } + + private Map> buildAdjacency() { + List edges = relationshipRepository.findAllByRelationTypeIn( + List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF)); + Map> adj = new HashMap<>(); + Map> parentToChildren = new HashMap<>(); + + for (PersonRelationship e : edges) { + UUID a = e.getPerson().getId(); + UUID b = e.getRelatedPerson().getId(); + switch (e.getRelationType()) { + case PARENT_OF -> { + addEdge(adj, a, b, RelationToken.DOWN); + addEdge(adj, b, a, RelationToken.UP); + parentToChildren.computeIfAbsent(a, k -> new ArrayList<>()).add(b); + } + case SPOUSE_OF -> { + addEdge(adj, a, b, RelationToken.SPOUSE); + addEdge(adj, b, a, RelationToken.SPOUSE); + } + case SIBLING_OF -> { + addEdge(adj, a, b, RelationToken.SIBLING); + addEdge(adj, b, a, RelationToken.SIBLING); + } + default -> { /* family graph excludes other types */ } + } + } + + for (List children : parentToChildren.values()) { + for (int i = 0; i < children.size(); i++) { + for (int j = i + 1; j < children.size(); j++) { + UUID c1 = children.get(i); + UUID c2 = children.get(j); + addEdge(adj, c1, c2, RelationToken.SIBLING); + addEdge(adj, c2, c1, RelationToken.SIBLING); + } + } + } + return adj; + } + + private static void addEdge(Map> adj, UUID from, UUID to, RelationToken token) { + adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token)); + } + + private static Optional> bfs(Map> adj, UUID from, UUID to) { + Map> shortest = new HashMap<>(); + shortest.put(from, List.of()); + Deque queue = new ArrayDeque<>(); + queue.add(from); + while (!queue.isEmpty()) { + UUID curr = queue.poll(); + List currPath = shortest.get(curr); + if (currPath.size() >= MAX_DEPTH) continue; + for (Edge e : adj.getOrDefault(curr, List.of())) { + if (shortest.containsKey(e.target())) continue; + List nextPath = append(currPath, e.token()); + shortest.put(e.target(), nextPath); + if (e.target().equals(to)) return Optional.of(nextPath); + queue.add(e.target()); + } + } + return Optional.empty(); + } + + private static Map> bfsAll(Map> adj, UUID from) { + Map> shortest = new HashMap<>(); + shortest.put(from, List.of()); + Deque queue = new ArrayDeque<>(); + queue.add(from); + while (!queue.isEmpty()) { + UUID curr = queue.poll(); + List currPath = shortest.get(curr); + if (currPath.size() >= MAX_DEPTH) continue; + for (Edge e : adj.getOrDefault(curr, List.of())) { + if (shortest.containsKey(e.target())) continue; + List nextPath = append(currPath, e.token()); + shortest.put(e.target(), nextPath); + queue.add(e.target()); + } + } + return shortest; + } + + private static List append(List prefix, RelationToken next) { + List out = new ArrayList<>(prefix.size() + 1); + out.addAll(prefix); + out.add(next); + return List.copyOf(out); + } + + private record Edge(UUID target, RelationToken token) {} +} diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java index 8539da8b..e31e2ad0 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java @@ -85,6 +85,7 @@ class PersonControllerTest { public Integer getBirthYear() { return null; } public Integer getDeathYear() { return null; } public String getNotes() { return null; } + public boolean isFamilyMember() { return false; } public long getDocumentCount() { return 0; } }; } diff --git a/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceServiceTest.java new file mode 100644 index 00000000..894f0ceb --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/relationship/RelationshipInferenceServiceTest.java @@ -0,0 +1,337 @@ +package org.raddatz.familienarchiv.relationship; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.model.Person; +import org.raddatz.familienarchiv.relationship.dto.InferredRelationshipDTO; +import org.raddatz.familienarchiv.repository.PersonRepository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.when; +import static org.raddatz.familienarchiv.relationship.RelationToken.*; +import static org.raddatz.familienarchiv.relationship.RelationType.*; + +/** + * Felix Brandt — TDD red phase for RelationshipInferenceService. + *

+ * 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 inferred = service.infer(a7.getId(), a0.getId()); + assertThat(inferred).hasValueSatisfying(r -> { + assertThat(r.hops()).isEqualTo(7); + assertThat(r.labelFromA()).isEqualTo("distant"); + assertThat(r.labelFromB()).isEqualTo("distant"); + }); + } + + // --- 18: no path --- + @Test + void returns_empty_when_no_path() { + Person a = person(); + Person b = person(); + // No edges between them. + givenEdges(/* none */); + + assertThat(service.findShortestPath(a.getId(), b.getId())).isEmpty(); + assertThat(service.infer(a.getId(), b.getId())).isEmpty(); + } + + // --- helpers --- + + private void givenEdges(PersonRelationship... edges) { + when(relationshipRepository.findAllByRelationTypeIn(anyCollection())) + .thenReturn(edges.length == 0 ? emptyList() : List.of(edges)); + } + + private static Person person() { + return Person.builder().id(UUID.randomUUID()).lastName("X").familyMember(true).build(); + } + + private static PersonRelationship parentOf(Person parent, Person child) { + return edge(parent, child, PARENT_OF); + } + + private static PersonRelationship spouseOf(Person a, Person b) { + return edge(a, b, SPOUSE_OF); + } + + private static PersonRelationship siblingOf(Person a, Person b) { + return edge(a, b, SIBLING_OF); + } + + private static PersonRelationship edge(Person a, Person b, RelationType type) { + return PersonRelationship.builder() + .id(UUID.randomUUID()) + .person(a) + .relatedPerson(b) + .relationType(type) + .createdAt(Instant.now()) + .build(); + } +}