Files
familienarchiv/docs/adr/026-stammbaum-layout-in-house.md
Marcel 4d1a5862d0
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m33s
CI / OCR Service Tests (push) Successful in 24s
CI / Backend Unit Tests (push) Successful in 3m34s
CI / fail2ban Regex (push) Successful in 46s
CI / Semgrep Security Scan (push) Successful in 22s
CI / Compose Bucket Idempotency (push) Successful in 1m8s
docs(stammbaum): ADR-030 tidy-tree layout, supersede ADR-026 packer, refresh glossary (#724)
Review follow-up (Markus/Architect): ADR-026 pre-committed a successor ADR if the
in-house layout stopped converging; its UX stop-trigger (Albert smeared across the
canvas) fired. ADR-030 records the bottom-up tidy-tree, the module split, and the two
maintainer-confirmed decisions (hybrid intra-family, per-bloodline width metric),
superseding ADR-026's block-packer in part (no-dagre + seeded-rank retained). GLOSSARY
replaces the deleted sibling-block / parented / anchor-index vocabulary with the new
family-forest model (unit, tidy tree, structural owner, bloodline, cross-link).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:55:10 +02:00

168 lines
7.8 KiB
Markdown

# ADR-026 — In-House Stammbaum Layout, dagre Evaluated and Deferred
**Date:** 2026-05-28
**Status:** Accepted — superseded in part by [ADR-030](./030-stammbaum-bloodline-tidy-tree-layout.md)
**Issue:** #361
**Supersedes:** _none_
**Superseded-by:** ADR-030 (#724) replaces the **per-generation block packer** below with
a bottom-up tidy-tree after its position-within-rank model stranded ancestors and smeared
bloodlines across the canvas — the UX stop-trigger named in this ADR. The **in-house /
no-dagre** decision and the seeded-rank invariant (#689) are retained.
**Supersedes-on-trigger:** _(triggered)_ The UX stop-trigger fired; see ADR-030.
---
## Context
After #689 shipped the seeded-rank invariant — `buildLayout.ts` treats imported
`persons.generation` as a strict row anchor and the iterative heuristic only
runs for unseeded nodes — the question "should we adopt
[@dagrejs/dagre](https://www.npmjs.com/package/@dagrejs/dagre) for Stammbaum
layout?" had to be re-evaluated.
dagre's headline value is **rank assignment** via `network-simplex` /
`longest-path`. That value is now mostly redundant: curated import data already
pins ranks for the family graph, and the residual heuristic only places
unseeded nodes (today: family members imported without a `generation` column,
spouses with no parents in the graph).
What remains are **position-within-rank** problems:
1. Multi-spouse persons (canonical case: Albert de Gruyter, 4 marriages) whose
secondary marriages were silently dropped by a `Map<string, string>` shape.
2. Intra-family marriages — two persons in different sibling blocks at the
same rank who marry each other (latent; zero cases in current data).
3. Unseeded loose spouses whose parents are also in the graph (latent; zero
cases — 0 of 942 unseeded persons match the predicate in the May-2026
snapshot).
Six persona walkthroughs on #361 (Leonie/UX, Felix/Dev, Markus/Architect,
Nora/Security, Sara/QA, Tobias/DevOps, Elicit/Requirements) converged on the
same recommendation: try the in-house fix path first, against the canonical
dataset, with quantitative exit triggers — adopt dagre only if any acceptance
criterion fails to converge.
---
## Decision
**Keep Stammbaum layout in-house. Do not adopt dagre at this time.**
The fix path lands as six commits on #361:
1. Spec geometry reconcile (`NODE_W=160, NODE_H=56` matches `buildLayout.ts`)
and an explicit seeded-rank-invariant Layout-rules line.
2. Canonical `/api/network` fixture capture script + pinned snapshot for
structural assertions in `buildLayout.test.ts`.
3. `spousePairs: Map<string, string>``Map<string, Set<string>>`. Preserves
all marriages; closes Nora's robustness gap (edges referencing IDs outside
`allNodes` are guarded at ingestion).
4. Multi-spouse ordering: `(fromYear ASC NULLS LAST, displayName ASC)`,
inserted to the right of the parented focal — matches Leonie's UX rule.
5. Intra-family-marriage block merge across same-rank parented sibling blocks
(AC2) — adjacent placement at the join boundary.
6. Marriage-line midpoint dot enlarged from `r=4.5` to `r=6` (WCAG 1.4.11
informational contrast — the dot disambiguates stacked marriages and is
no longer decorative).
The block-packer + AC2 merge stays well under Markus's 80-LoC extraction
threshold, so `packBlocks.ts` is **not** yet warranted.
---
## Consequences
### Accepted today
- **AC1 (multi-spouse preservation)** is now a property of `buildLayout`, verified
by both synthetic and canonical fixture tests.
- **AC2 (intra-family marriage)** ships latent but covered by a synthetic
two-family regression test.
- **AC4 (seeded-rank invariant)** preserved end-to-end by every `buildLayout.test.ts`
case from #689.
- **AC5 (spec ↔ code geometry)** reconciled in commit 1.
### Deferred with revisit triggers
- **AC3 — Unseeded loose spouse with parents-in-graph.** Database verification:
0 of 942 unseeded persons match the predicate today. Structurally, every
realistic case maps to a **curation/import gap** (P's parents were imported
with `generation`, P themselves was not) and belongs in the canonical import
sheet rather than `buildLayout`. **Revisit trigger:** first canonical fixture
containing a parented unseeded spouse — at which point this ADR is updated
in place or superseded by an ADR-027. **Reproducible verification query**
(PostgreSQL — paste into a read-only psql session against
`familienarchiv_archive`):
```sql
-- AC3 reachability probe. Returns one row per unseeded person who has at
-- least one parent edge whose parent IS seeded. A non-zero count means the
-- AC3 layout branch becomes reachable for that person and ADR-026 should
-- be revisited. Last run May 2026: 0 rows.
SELECT p.id, p.display_name
FROM persons p
WHERE p.generation IS NULL
AND EXISTS (
SELECT 1
FROM person_relationships r
JOIN persons parent ON parent.id = r.person_id
WHERE r.relation_type = 'PARENT_OF'
AND r.related_person_id = p.id
AND parent.generation IS NOT NULL
);
```
The same predicate is encoded as a unit-testable JavaScript function — see
`findAc3Candidates()` in
`frontend/src/lib/person/genealogy/__fixtures__/findAc3Candidates.mjs`,
asserted against the committed canonical fixture by
`validateFixture.test.ts`, and emitted as a stderr soft-warn by
`frontend/scripts/capture-network-fixture.mjs` on every recapture. The SQL
is the source-of-truth probe against live data; the function is the
capture-time and fixture-time signal that the predicate's count crossed
zero.
- **AC6 — Bundle-impact gate (≤ 40 kB gzipped on `/stammbaum`).** Moot under
this ADR; reactivates only under ADR-027 (dagre adoption).
- **AC7 — Visual regression at 320 / 768 / 1440.** `toHaveScreenshot()`
permanently dropped (high running cost, speculative coverage). The axe-core
3:1 contrast check for the enlarged marriage dot is verified one-shot at
PR time, not committed; the permanent contrast/breakpoint gate lands with
#692 (mobile pan/zoom epic) alongside the breakpoint visual-regression
infrastructure.
### UX-signal-only stop trigger for dagre adoption
There is **no LoC cap** on the in-house path. The only divergence signal that
warrants reopening the dagre decision is a **UX failure against the canonical
fixture** — specifically, Albert de Gruyter's 4 marriages failing the read
test ("can a 67-year-old researcher unambiguously see all four spouses?").
If that ever happens, Felix posts a divergence-evidence comment on #361 (or
the equivalent successor issue), the team re-runs brainstorming with the
dagre option on the table, and adoption proceeds under the supply-chain
controls already documented in #361's body (`@dagrejs/dagre` exact-pinned, no
auto-merge, try/catch fallback with structured log, deterministic input sort).
### Operational
- **No CI, image, or compose changes.** Pure frontend layout work; standard
frontend rebuild covers the deploy.
- **No service topology changes.** No new env vars, ports, resource limits.
---
## Notes
- `frontend/scripts/capture-network-fixture.mjs` is a **local-only developer
utility**, never invoked from CI. Re-run intentionally; commit the resulting
JSON in one atomic commit when a new structural case appears (new edge type,
new marriage configuration, new generation range).
- The canonical fixture contains real family names. Repository is private;
scrubbing is a single-commit migration if it ever opens.
- Brand-mint enforcement on SVG strokes (Leonie's "all connectors render in
brand-navy, hierarchy comes from shape") stays a **code-review check at PR
time**. No CI grep, no custom ESLint rule.
- **Revisit cadence.** Re-evaluate dagre adoption on the first canonical
fixture refresh that hits AC3, OR by 2027-05-01 at the latest. Owner: Felix
Brandt.