Files
familienarchiv/docs/adr/026-stammbaum-layout-in-house.md
Marcel b8ad64dd13
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m41s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m51s
CI / fail2ban Regex (pull_request) Successful in 45s
CI / Semgrep Security Scan (pull_request) Successful in 23s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m4s
docs(stammbaum): layout glossary + AC3 deferral SQL (#361)
@Elicit on PR #693: two doc gaps that block traceability on this PR.

1. docs/GLOSSARY.md: add a Stammbaum section with the layout vocabulary
   introduced by #689 and #361 — Stammbaum, seeded rank, sibling block,
   loose spouse, parented, anchor index, intra-family marriage, marriage
   dot, canonical fixture. Removes the Pending placeholder.

2. docs/adr/026: commit the AC3 reachability probe (the SQL that returned
   "0 of 942 unseeded persons match the predicate" in May 2026) directly
   into the ADR. A future architect re-evaluating the deferral can rerun
   it verbatim — reproducibility of the decision is itself a requirement.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:44:49 +02:00

6.7 KiB

ADR-026 — In-House Stammbaum Layout, dagre Evaluated and Deferred

Date: 2026-05-28 Status: Accepted Issue: #361 Supersedes: none Supersedes-on-trigger: A future ADR-027 if any acceptance criterion below stops converging in-house.


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 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):

    -- 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
      );
    
  • 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.