Compare commits
3 Commits
2c18cb8b0d
...
23d93d492d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23d93d492d | ||
|
|
2097dddf3a | ||
|
|
585f28cd23 |
@@ -107,6 +107,16 @@ threshold, so `packBlocks.ts` is **not** yet warranted.
|
|||||||
AND parent.generation IS NOT NULL
|
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
|
- **AC6 — Bundle-impact gate (≤ 40 kB gzipped on `/stammbaum`).** Moot under
|
||||||
this ADR; reactivates only under ADR-027 (dagre adoption).
|
this ADR; reactivates only under ADR-027 (dagre adoption).
|
||||||
- **AC7 — Visual regression at 320 / 768 / 1440.** `toHaveScreenshot()`
|
- **AC7 — Visual regression at 320 / 768 / 1440.** `toHaveScreenshot()`
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { dirname } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import { findAc3Candidates } from '../src/lib/person/genealogy/__fixtures__/findAc3Candidates.mjs';
|
||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080';
|
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080';
|
||||||
const EMAIL = process.env.CAPTURE_EMAIL;
|
const EMAIL = process.env.CAPTURE_EMAIL;
|
||||||
const PASSWORD = process.env.CAPTURE_PASSWORD;
|
const PASSWORD = process.env.CAPTURE_PASSWORD;
|
||||||
@@ -59,9 +61,10 @@ const FIXTURE_PATH = `${HERE}/../src/lib/person/genealogy/__fixtures__/stammbaum
|
|||||||
// nodes, 5 generations, 28 SPOUSE_OF edges, 1 multi-spouse person). The point
|
// nodes, 5 generations, 28 SPOUSE_OF edges, 1 multi-spouse person). The point
|
||||||
// is catching a silently empty backend or a structurally-regressed snapshot,
|
// is catching a silently empty backend or a structurally-regressed snapshot,
|
||||||
// not strict size validation; raise these only if the canonical graph grows
|
// not strict size validation; raise these only if the canonical graph grows
|
||||||
// substantially. The same gates are unit-tested at
|
// substantially. The matching gates in
|
||||||
// src/lib/person/genealogy/__fixtures__/validateFixture.ts — keep both
|
// src/lib/person/genealogy/__fixtures__/validateFixture.ts cover unit-test
|
||||||
// definitions in sync.
|
// coverage; the floors are intentionally duplicated as numeric constants
|
||||||
|
// (the AC3 revisit predicate, by contrast, now lives in one shared module).
|
||||||
const MIN_NODES = 50;
|
const MIN_NODES = 50;
|
||||||
const MIN_GENERATIONS = 5;
|
const MIN_GENERATIONS = 5;
|
||||||
const MIN_SPOUSE_OF_EDGES = 1;
|
const MIN_SPOUSE_OF_EDGES = 1;
|
||||||
@@ -161,47 +164,6 @@ function countMultiSpousePersons(spouseEdges) {
|
|||||||
return count;
|
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) {
|
function mapAddToSet(map, key, value) {
|
||||||
const s = map.get(key);
|
const s = map.get(key);
|
||||||
if (s) s.add(value);
|
if (s) s.add(value);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// 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 canonical-fixture unit suite asserts an empty result against
|
||||||
|
// the committed JSON, and `frontend/scripts/capture-network-fixture.mjs`
|
||||||
|
// soft-warns to stderr (does NOT fail capture) so a human-in-the-loop notices
|
||||||
|
// when the layout branch becomes reachable.
|
||||||
|
//
|
||||||
|
// Lives as a plain ESM .mjs module so both the Node-run capture script and
|
||||||
|
// the TypeScript validator/test suite can import it without a build step —
|
||||||
|
// single source of truth for the predicate.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ relationType?: string, personId?: string, relatedPersonId?: string }} Edge
|
||||||
|
* @typedef {{ id?: string, generation?: number | null }} Node
|
||||||
|
* @typedef {{ nodes?: Node[], edges?: Edge[] }} NetworkShape
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {NetworkShape} network
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import canonicalFixture from './stammbaum.json';
|
|||||||
// buildLayout.test.ts relies on. Adding or removing a gate without updating
|
// buildLayout.test.ts relies on. Adding or removing a gate without updating
|
||||||
// these tests is the failure we want to catch.
|
// these tests is the failure we want to catch.
|
||||||
|
|
||||||
|
// Lets `generation: null` typecheck without an inline `as number | null`
|
||||||
|
// cast — the AC3 predicate cares specifically about the unseeded-node
|
||||||
|
// branch, so the test fixtures need to express it directly.
|
||||||
|
type TestNode = { id: string; generation: number | null };
|
||||||
|
|
||||||
function networkWithNodes(count: number) {
|
function networkWithNodes(count: number) {
|
||||||
return {
|
return {
|
||||||
nodes: Array.from({ length: count }, (_, i) => ({ id: `n${i}`, generation: i % 6 })),
|
nodes: Array.from({ length: count }, (_, i) => ({ id: `n${i}`, generation: i % 6 })),
|
||||||
@@ -87,9 +92,9 @@ describe('findAc3Candidates — AC3 revisit-trigger predicate (#361, Elicit cycl
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('flags_an_unseeded_person_whose_seeded_parent_is_in_graph_and_who_has_a_spouse', () => {
|
it('flags_an_unseeded_person_whose_seeded_parent_is_in_graph_and_who_has_a_spouse', () => {
|
||||||
const nodes = [
|
const nodes: TestNode[] = [
|
||||||
{ id: 'parent', generation: 2 },
|
{ id: 'parent', generation: 2 },
|
||||||
{ id: 'child', generation: null as number | null },
|
{ id: 'child', generation: null },
|
||||||
{ id: 'spouse', generation: 3 }
|
{ id: 'spouse', generation: 3 }
|
||||||
];
|
];
|
||||||
const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
|
const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
|
||||||
@@ -100,9 +105,9 @@ describe('findAc3Candidates — AC3 revisit-trigger predicate (#361, Elicit cycl
|
|||||||
// Unseeded + seeded parent in graph, but no SPOUSE_OF — not AC3 (the
|
// Unseeded + seeded parent in graph, but no SPOUSE_OF — not AC3 (the
|
||||||
// layout branch that hurts is the *loose spouse* branch). The capture
|
// layout branch that hurts is the *loose spouse* branch). The capture
|
||||||
// warn must not fire here.
|
// warn must not fire here.
|
||||||
const nodes = [
|
const nodes: TestNode[] = [
|
||||||
{ id: 'parent', generation: 2 },
|
{ id: 'parent', generation: 2 },
|
||||||
{ id: 'child', generation: null as number | null }
|
{ id: 'child', generation: null }
|
||||||
];
|
];
|
||||||
const edges = [parentEdge('parent', 'child')];
|
const edges = [parentEdge('parent', 'child')];
|
||||||
expect(findAc3Candidates({ nodes, edges })).toEqual([]);
|
expect(findAc3Candidates({ nodes, edges })).toEqual([]);
|
||||||
@@ -112,9 +117,9 @@ describe('findAc3Candidates — AC3 revisit-trigger predicate (#361, Elicit cycl
|
|||||||
// "Parents in graph" in the AC3 sense means at least one *seeded*
|
// "Parents in graph" in the AC3 sense means at least one *seeded*
|
||||||
// parent. If both parent and child are unseeded the heuristic
|
// parent. If both parent and child are unseeded the heuristic
|
||||||
// fallback already handles the case without AC3 ever firing.
|
// fallback already handles the case without AC3 ever firing.
|
||||||
const nodes = [
|
const nodes: TestNode[] = [
|
||||||
{ id: 'parent', generation: null as number | null },
|
{ id: 'parent', generation: null },
|
||||||
{ id: 'child', generation: null as number | null },
|
{ id: 'child', generation: null },
|
||||||
{ id: 'spouse', generation: 3 }
|
{ id: 'spouse', generation: 3 }
|
||||||
];
|
];
|
||||||
const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
|
const edges = [parentEdge('parent', 'child'), spouseEdge('child', 'spouse')];
|
||||||
|
|||||||
@@ -17,45 +17,11 @@ type Edge = { relationType?: string; personId?: string; relatedPersonId?: string
|
|||||||
type Node = { id?: string; generation?: number | null };
|
type Node = { id?: string; generation?: number | null };
|
||||||
export type NetworkShape = { nodes?: Node[]; edges?: Edge[] };
|
export type NetworkShape = { nodes?: Node[]; edges?: Edge[] };
|
||||||
|
|
||||||
// AC3 = "unseeded loose spouse whose parents are in the graph" (ADR-026).
|
// The AC3 revisit-trigger predicate lives in a plain .mjs module so the
|
||||||
// Returns the IDs of every captured person matching the predicate. A non-
|
// Node-run capture script and this TypeScript validator share one
|
||||||
// empty result is the documented revisit trigger for the dagre deferral
|
// implementation. Re-exported here so consumers of the fixture-directory
|
||||||
// decision — the capture script logs a soft-warn (does NOT fail) on every
|
// barrel keep their existing import surface.
|
||||||
// recapture so a human-in-the-loop notices when the layout branch becomes
|
export { findAc3Candidates } from './findAc3Candidates.mjs';
|
||||||
// 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 = {
|
export type FixtureStats = {
|
||||||
nodes: number;
|
nodes: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user