feat(stammbaum): inference service with BFS + LABEL_MAP (TDD)
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m7s
CI / OCR Service Tests (push) Successful in 38s
CI / Backend Unit Tests (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-27 14:12:51 +02:00
parent d77f0e2203
commit efa020eceb
4 changed files with 570 additions and 0 deletions

View File

@@ -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]}.
*
* <p>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;
};
}
}

View File

@@ -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<List<RelationToken>, String> LABEL_MAP = buildLabelMap();
private final PersonRelationshipRepository relationshipRepository;
private final PersonRepository personRepository;
private static Map<List<RelationToken>, String> buildLabelMap() {
Map<List<RelationToken>, 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<List<RelationToken>> findShortestPath(UUID from, UUID to) {
if (from.equals(to)) return Optional.empty();
Map<UUID, List<Edge>> adj = buildAdjacency();
return bfs(adj, from, to);
}
/** Two-sided label between A and B. {@code labelFromA} reads "B is my <labelFromA>". */
public Optional<InferredRelationshipDTO> infer(UUID a, UUID b) {
Optional<List<RelationToken>> aToB = findShortestPath(a, b);
if (aToB.isEmpty()) return Optional.empty();
List<RelationToken> 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<InferredRelationshipWithPersonDTO> findAllFor(UUID personId) {
Map<UUID, List<Edge>> adj = buildAdjacency();
Map<UUID, List<RelationToken>> shortestPaths = bfsAll(adj, personId);
shortestPaths.remove(personId);
if (shortestPaths.isEmpty()) return List.of();
List<UUID> ids = new ArrayList<>(shortestPaths.keySet());
Map<UUID, Person> byId = new HashMap<>();
for (Person p : personRepository.findAllById(ids)) {
byId.put(p.getId(), p);
}
List<InferredRelationshipWithPersonDTO> out = new ArrayList<>();
for (UUID id : ids) {
Person p = byId.get(id);
if (p == null) continue;
List<RelationToken> 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<RelationToken> path) {
String specific = LABEL_MAP.get(path);
return specific != null ? specific : LABEL_DISTANT;
}
private static List<RelationToken> reversePath(List<RelationToken> path) {
List<RelationToken> 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<UUID, List<Edge>> buildAdjacency() {
List<PersonRelationship> edges = relationshipRepository.findAllByRelationTypeIn(
List.of(RelationType.PARENT_OF, RelationType.SPOUSE_OF, RelationType.SIBLING_OF));
Map<UUID, List<Edge>> adj = new HashMap<>();
Map<UUID, List<UUID>> 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<UUID> 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<UUID, List<Edge>> adj, UUID from, UUID to, RelationToken token) {
adj.computeIfAbsent(from, k -> new ArrayList<>()).add(new Edge(to, token));
}
private static Optional<List<RelationToken>> bfs(Map<UUID, List<Edge>> adj, UUID from, UUID to) {
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
shortest.put(from, List.of());
Deque<UUID> queue = new ArrayDeque<>();
queue.add(from);
while (!queue.isEmpty()) {
UUID curr = queue.poll();
List<RelationToken> 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<RelationToken> 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<UUID, List<RelationToken>> bfsAll(Map<UUID, List<Edge>> adj, UUID from) {
Map<UUID, List<RelationToken>> shortest = new HashMap<>();
shortest.put(from, List.of());
Deque<UUID> queue = new ArrayDeque<>();
queue.add(from);
while (!queue.isEmpty()) {
UUID curr = queue.poll();
List<RelationToken> 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<RelationToken> nextPath = append(currPath, e.token());
shortest.put(e.target(), nextPath);
queue.add(e.target());
}
}
return shortest;
}
private static List<RelationToken> append(List<RelationToken> prefix, RelationToken next) {
List<RelationToken> out = new ArrayList<>(prefix.size() + 1);
out.addAll(prefix);
out.add(next);
return List.copyOf(out);
}
private record Edge(UUID target, RelationToken token) {}
}

View File

@@ -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; }
};
}

View File

@@ -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.
* <p>
* 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<InferredRelationshipDTO> 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();
}
}