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:
Marcel
2026-05-28 20:58:50 +02:00
parent e7931335ce
commit 655f0c3531
3 changed files with 174 additions and 1 deletions

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) => {