Compare commits

..

4 Commits

Author SHA1 Message Date
Marcel
2c18cb8b0d docs(adr): ADR-026 names assessor + revisit cadence for dagre deferral (#361)
All checks were successful
CI / Unit & Component Tests (pull_request) Successful in 3m18s
CI / OCR Service Tests (pull_request) Successful in 24s
CI / Backend Unit Tests (pull_request) Successful in 3m41s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 22s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m3s
Cycle-2 follow-up from Elicit. The "UX-signal-only stop trigger" wording
was honest about being qualitative but left no named owner and no
cadence — if #361 changes hands in 18 months, "Albert de Gruyter's read
test failing" had no one accountable for running it. Names Felix Brandt
as owner, sets a hard 2027-05-01 fallback so the question can't drift
indefinitely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:59:25 +02:00
Marcel
655f0c3531 test+feat(stammbaum): capture script soft-warns on AC3 revisit trigger (#361)
Cycle-2 follow-up from Elicit. ADR-026 defers AC3 (unseeded loose
spouse with parents-in-graph) with the revisit trigger being "first
canonical fixture containing such a person". The trigger previously
relied on a human spotting the new shape during recapture, with no
automated nudge.

`findAc3Candidates(network)` is the testable predicate (5 unit tests
including the precondition that the *committed* canonical fixture has
zero candidates today — anchors the ADR-026 "0 rows" annotation
against the fixture). The capture script calls it after writing the
fixture and emits a loud non-blocking stderr warning if the count goes
non-zero. The warning is the revisit trigger Elicit asked for.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:58:50 +02:00
Marcel
e7931335ce test(stammbaum): assert r=6 marriage dot fill is var(--c-primary) (#361)
Cycle-2 follow-up from Sara. The radius assertion proves the geometry
side of the WCAG 1.4.11 contract; the fill-token assertion proves the
colour side. Together they catch an accidental "neutralise the dot"
diff (e.g. swap to var(--c-ink-3) or a literal light token) before the
permanent axe-core gate ships in #692.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:56:15 +02:00
Marcel
89bb0b5d65 test(stammbaum): assert no node sits between AC2 spouses on same y (#361)
Cycle-2 follow-up from Sara. The existing assertion
`Math.abs(posA2.x - posB2.x) === NODE_W + COL_GAP` proves adjacency in
the current integer-slot packer but would silently pass if a future
refactor moved to fractional offsets with a third node squatting at a
non-slot x between the spouses. The added loop closes that contract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 20:55:23 +02:00
6 changed files with 197 additions and 1 deletions

View File

@@ -147,3 +147,6 @@ auto-merge, try/catch fallback with structured log, deterministic input sort).
- 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.

View File

@@ -161,6 +161,47 @@ function countMultiSpousePersons(spouseEdges) {
return count;
}
// AC3 revisit-trigger predicate (ADR-026 §Notes). Returns the IDs of every
// captured person matching "unseeded loose spouse whose parents are in graph".
// A non-empty result is the documented revisit trigger for the dagre
// deferral decision — emitted as a soft-warn (does not fail capture) so a
// human-in-the-loop notices when the AC3 layout branch becomes reachable.
// Mirrors the implementation in src/lib/person/genealogy/__fixtures__/
// validateFixture.ts findAc3Candidates(); keep both in sync.
function findAc3Candidates(network) {
const nodes = Array.isArray(network.nodes) ? network.nodes : [];
const edges = Array.isArray(network.edges) ? network.edges : [];
const generationById = new Map();
for (const n of nodes) {
if (n.id) generationById.set(n.id, n.generation);
}
const hasSpouseEdge = new Set();
const seededParentByChild = new Map();
for (const e of edges) {
if (!e.personId || !e.relatedPersonId) continue;
if (e.relationType === 'SPOUSE_OF') {
hasSpouseEdge.add(e.personId);
hasSpouseEdge.add(e.relatedPersonId);
continue;
}
if (e.relationType === 'PARENT_OF') {
const parentGen = generationById.get(e.personId);
if (parentGen != null) seededParentByChild.set(e.relatedPersonId, true);
}
}
const matches = [];
for (const n of nodes) {
if (!n.id) continue;
if (n.generation != null) continue;
if (!seededParentByChild.get(n.id)) continue;
if (!hasSpouseEdge.has(n.id)) continue;
matches.push(n.id);
}
return matches;
}
function mapAddToSet(map, key, value) {
const s = map.get(key);
if (s) s.add(value);
@@ -182,6 +223,27 @@ async function main() {
console.error(
`Wrote ${FIXTURE_PATH}\n nodes: ${stats.nodes}\n edges: ${stats.edges}\n SPOUSE_OF edges: ${stats.spouseEdges}\n multi-spouse persons: ${stats.multiSpousePersons}\n generations: G${stats.generations.join(', G')}`
);
warnIfAc3Reachable(network);
}
function warnIfAc3Reachable(network) {
const candidates = findAc3Candidates(network);
if (candidates.length === 0) return;
// Soft, non-blocking. The ADR-026 deferral decision says: revisit the
// dagre adoption choice as soon as the canonical fixture starts to
// contain a parented unseeded spouse. This warning is that signal —
// fail-open so a recapture still writes the fixture (the human needs
// to see the data to decide), but loud enough that nobody can miss it.
console.error('');
console.error(
`⚠ AC3 revisit trigger reached (ADR-026): ${candidates.length} unseeded ` +
'person(s) with a seeded parent AND a SPOUSE_OF edge in the captured graph.'
);
console.error(` Candidates: ${candidates.join(', ')}`);
console.error(
' Action: re-evaluate the dagre adoption deferral. See ADR-026 §Notes ' +
'for the assessor + cadence.'
);
}
main().catch((err) => {

View File

@@ -342,6 +342,13 @@ describe('StammbaumTree viewBox', () => {
const dot = document.querySelector('svg circle');
expect(dot).not.toBeNull();
expect(dot!.getAttribute('r')).toBe('6');
// Cycle-2 follow-up from Sara: codify the colour-token side of the
// WCAG 1.4.11 contrast contract at the unit level. The permanent axe-
// core gate lives in #692; this assertion prevents an accidental
// "neutralise the dot" diff (e.g. swap to var(--c-ink-3) or a literal
// light token) from stripping the 3:1 contrast guarantee before #692
// ships.
expect(dot!.getAttribute('fill')).toBe('var(--c-primary)');
});
it('centers two spouse nodes within the minimum viewBox', async () => {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { validateFixture } from './validateFixture';
import { validateFixture, findAc3Candidates } from './validateFixture';
import canonicalFixture from './stammbaum.json';
// The fixture validator is the load-bearing contract for the canonical
@@ -25,6 +25,17 @@ function spouseEdge(a: string, b: string) {
};
}
function parentEdge(parent: string, child: string) {
return {
id: `${parent}>${child}`,
personId: parent,
relatedPersonId: child,
personDisplayName: '',
relatedPersonDisplayName: '',
relationType: 'PARENT_OF'
};
}
describe('validateFixture', () => {
it('passes_for_the_canonical_fixture', () => {
expect(() => validateFixture(canonicalFixture)).not.toThrow();
@@ -60,3 +71,63 @@ describe('validateFixture', () => {
expect(() => validateFixture({ nodes, edges })).not.toThrow();
});
});
describe('findAc3Candidates — AC3 revisit-trigger predicate (#361, Elicit cycle-2)', () => {
// AC3 = "unseeded loose spouse whose parents are in the graph". Per ADR-026
// this is the predicate that reopens the dagre decision once it appears
// against the canonical fixture. The capture script runs this on every
// recapture and warns to stderr (soft, non-blocking) so the human-in-the-
// loop notices the moment the deferral is no longer free.
it('finds_no_candidate_in_the_canonical_fixture_today', () => {
// Anchors ADR-026's "Last run May 2026: 0 rows" annotation against the
// committed fixture. Fails the moment the captured graph starts to
// contain the AC3 shape — which is exactly the revisit trigger.
expect(findAc3Candidates(canonicalFixture)).toEqual([]);
});
it('flags_an_unseeded_person_whose_seeded_parent_is_in_graph_and_who_has_a_spouse', () => {
const nodes = [
{ id: 'parent', generation: 2 },
{ id: 'child', generation: null as number | null },
{ id: 'spouse', generation: 3 }
];
const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
expect(findAc3Candidates({ nodes, edges })).toEqual(['child']);
});
it('ignores_an_unseeded_person_with_no_spouse_edge', () => {
// Unseeded + seeded parent in graph, but no SPOUSE_OF — not AC3 (the
// layout branch that hurts is the *loose spouse* branch). The capture
// warn must not fire here.
const nodes = [
{ id: 'parent', generation: 2 },
{ id: 'child', generation: null as number | null }
];
const edges = [parentEdge('parent', 'child')];
expect(findAc3Candidates({ nodes, edges })).toEqual([]);
});
it('ignores_an_unseeded_person_whose_parent_is_also_unseeded', () => {
// "Parents in graph" in the AC3 sense means at least one *seeded*
// parent. If both parent and child are unseeded the heuristic
// fallback already handles the case without AC3 ever firing.
const nodes = [
{ id: 'parent', generation: null as number | null },
{ id: 'child', generation: null as number | null },
{ id: 'spouse', generation: 3 }
];
const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
expect(findAc3Candidates({ nodes, edges })).toEqual([]);
});
it('ignores_a_seeded_person_with_seeded_parents_and_a_spouse', () => {
const nodes = [
{ id: 'parent', generation: 2 },
{ id: 'child', generation: 3 },
{ id: 'spouse', generation: 3 }
];
const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
expect(findAc3Candidates({ nodes, edges })).toEqual([]);
});
});

View File

@@ -17,6 +17,46 @@ type Edge = { relationType?: string; personId?: string; relatedPersonId?: string
type Node = { id?: string; generation?: number | null };
export type NetworkShape = { nodes?: Node[]; edges?: Edge[] };
// AC3 = "unseeded loose spouse whose parents are in the graph" (ADR-026).
// Returns the IDs of every captured person matching the predicate. A non-
// empty result is the documented revisit trigger for the dagre deferral
// decision — the capture script logs a soft-warn (does NOT fail) on every
// recapture so a human-in-the-loop notices when the layout branch becomes
// reachable.
export function findAc3Candidates(network: NetworkShape): string[] {
const nodes = Array.isArray(network.nodes) ? network.nodes : [];
const edges = Array.isArray(network.edges) ? network.edges : [];
const generationById = new Map<string, number | null | undefined>();
for (const n of nodes) {
if (n.id) generationById.set(n.id, n.generation);
}
const hasSpouseEdge = new Set<string>();
const seededParentByChild = new Map<string, boolean>();
for (const e of edges) {
if (!e.personId || !e.relatedPersonId) continue;
if (e.relationType === 'SPOUSE_OF') {
hasSpouseEdge.add(e.personId);
hasSpouseEdge.add(e.relatedPersonId);
continue;
}
if (e.relationType === 'PARENT_OF') {
const parentGen = generationById.get(e.personId);
if (parentGen != null) seededParentByChild.set(e.relatedPersonId, true);
}
}
const matches: string[] = [];
for (const n of nodes) {
if (!n.id) continue;
if (n.generation != null) continue;
if (!seededParentByChild.get(n.id)) continue;
if (!hasSpouseEdge.has(n.id)) continue;
matches.push(n.id);
}
return matches;
}
export type FixtureStats = {
nodes: number;
edges: number;

View File

@@ -266,6 +266,19 @@ describe('buildLayout — multi-spouse ordering (#361)', () => {
const posB2 = layout.positions.get(B2)!;
expect(posA2.y).toBe(posB2.y);
expect(Math.abs(posA2.x - posB2.x)).toBe(NODE_W + COL_GAP);
// Tighter contract (Sara's cycle-2 follow-up): no third node may sit
// at an x strictly between the two spouses on the same y. The integer-
// slot adjacency check above (==NODE_W+COL_GAP) is correct today but
// would silently pass if a future layout change introduced fractional
// offsets and placed a node at a non-slot x between the spouses.
const minX = Math.min(posA2.x, posB2.x);
const maxX = Math.max(posA2.x, posB2.x);
for (const [id, p] of layout.positions) {
if (id === A2 || id === B2) continue;
if (p.y !== posA2.y) continue;
expect(p.x <= minX || p.x >= maxX).toBe(true);
}
});
it('canonical_fixture_multi_spouse_falls_through_to_displayName_when_no_fromYear', () => {