Files
familienarchiv/docs/adr/026-stammbaum-layout-in-house.md
Marcel 2097dddf3a docs(adr): ADR-026 cross-references findAc3Candidates() predicate (#361)
Names the JavaScript function next to the AC3 SQL probe so a future reader
of ADR-026 has a concrete code anchor for the testable predicate (Markus
cycle-3 cosmetic). The SQL remains the source-of-truth probe against live
data; the function is the capture-time + fixture-time signal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 21:15:40 +02:00

7.4 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
      );
    

    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.