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>
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user