diff --git a/server/routes/pedigree.js b/server/routes/pedigree.js index d00d0ab..5229913 100644 --- a/server/routes/pedigree.js +++ b/server/routes/pedigree.js @@ -3,93 +3,100 @@ const router = express.Router(); const { getDatabase } = require('../db/init'); /** - * calculateCOI — Wright Path Coefficient method - * - * Builds a full ancestor map for BOTH the sire and dam, including - * themselves at generation 0, so that: - * - if sire IS a parent of dam (or vice versa), it shows as a - * common ancestor at gen 0 vs gen 1, giving the correct COI. - * - multiple paths through the same ancestor are ALL counted. - * - * The Wright formula per path: (0.5)^(L+1) * (1 + F_A) - * where L = number of segregation steps sire-side + dam-side. - * F_A (inbreeding of common ancestor) is approximated as 0 here. - * - * For a direct parent-offspring pair (sire is direct parent of dam): - * sireGen = 0, damGen = 1 => (0.5)^(0+1+1) = 0.25 => 25% COI + * getAncestorMap(db, dogId, maxGen) + * Returns Map + * INCLUDES dogId itself at generation 0 so direct parent-offspring + * pairings are correctly detected by calculateCOI. */ -function calculateCOI(sireId, damId, generations = 6) { - const db = getDatabase(); +function getAncestorMap(db, dogId, maxGen = 6) { + const map = new Map(); - // Returns a list of { id, name, generation } for every ancestor of dogId - // INCLUDING dogId itself at generation 0. - function getAncestorMap(dogId) { - const visited = new Map(); // id -> [{ id, name, generation }, ...] - - function recurse(id, gen) { - if (gen > generations) return; - - const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id); - if (!dog) return; - - if (!visited.has(id)) visited.set(id, []); - visited.get(id).push({ id: dog.id, name: dog.name, generation: gen }); - - // Only recurse into parents if we haven't already processed this id - // at this generation (prevents infinite loops on circular data) - if (visited.get(id).length === 1) { - const parents = db.prepare(` - SELECT p.parent_id, d.name - FROM parents p - JOIN dogs d ON p.parent_id = d.id - WHERE p.dog_id = ? - `).all(id); - parents.forEach(p => recurse(p.parent_id, gen + 1)); - } + function recurse(id, gen) { + if (gen > maxGen) return; + const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id); + if (!dog) return; + if (!map.has(id)) map.set(id, []); + map.get(id).push({ id: dog.id, name: dog.name, generation: gen }); + // Only walk parents on first encounter to prevent exponential blowup + if (map.get(id).length === 1) { + const parents = db.prepare(` + SELECT p.parent_id FROM parents p WHERE p.dog_id = ? + `).all(id); + parents.forEach(p => recurse(p.parent_id, gen + 1)); } - - recurse(dogId, 0); - return visited; } - const sireMap = getAncestorMap(sireId); - const damMap = getAncestorMap(damId); + recurse(parseInt(dogId), 0); + return map; +} - // Find all IDs that appear in BOTH ancestor maps (common ancestors) - // Exclude the sire and dam themselves from being listed as common ancestors - // (they can't be common ancestors of the hypothetical offspring with each other) +/** + * isDirectRelation(db, sireId, damId) + * Checks within 3 generations whether one dog is a direct ancestor + * of the other. Returns { related, relationship }. + */ +function isDirectRelation(db, sireId, damId) { + const sid = parseInt(sireId); + const did = parseInt(damId); + const sireMap = getAncestorMap(db, sid, 3); + const damMap = getAncestorMap(db, did, 3); + + if (damMap.has(sid)) { + const gen = damMap.get(sid)[0].generation; + const label = gen === 1 ? 'parent' : gen === 2 ? 'grandparent' : `generation-${gen} ancestor`; + return { related: true, relationship: `Sire is the ${label} of the selected dam` }; + } + if (sireMap.has(did)) { + const gen = sireMap.get(did)[0].generation; + const label = gen === 1 ? 'parent' : gen === 2 ? 'grandparent' : `generation-${gen} ancestor`; + return { related: true, relationship: `Dam is the ${label} of the selected sire` }; + } + return { related: false, relationship: null }; +} + +/** + * calculateCOI(db, sireId, damId) + * Wright Path Coefficient method. + * sire/dam included at gen 0 in their own maps so parent x offspring + * yields the correct 25% COI instead of 0%. + * + * Per path: COI += (0.5)^(sireGen + damGen + 1) + * Parent x offspring: sireGen=0, damGen=1 => (0.5)^2 = 0.25 = 25% + */ +function calculateCOI(db, sireId, damId) { + const sid = parseInt(sireId); + const did = parseInt(damId); + const sireMap = getAncestorMap(db, sid); + const damMap = getAncestorMap(db, did); + + // Common ancestors excluding the pair themselves const commonIds = [...sireMap.keys()].filter( - id => damMap.has(id) && id !== parseInt(sireId) && id !== parseInt(damId) + id => damMap.has(id) && id !== sid && id !== did ); let coi = 0; - const commonAncestorList = []; const processedPaths = new Set(); + const commonAncestorList = []; commonIds.forEach(ancId => { - const sireOccurrences = sireMap.get(ancId); - const damOccurrences = damMap.get(ancId); + const sireOccs = sireMap.get(ancId); + const damOccs = damMap.get(ancId); - // Wright: sum over every combination of paths through this ancestor - sireOccurrences.forEach(sireOcc => { - damOccurrences.forEach(damOcc => { - const pathKey = `${ancId}-${sireOcc.generation}-${damOcc.generation}`; - if (!processedPaths.has(pathKey)) { - processedPaths.add(pathKey); - // L = steps from sire to ancestor + steps from dam to ancestor - // Wright formula: (0.5)^(L+1) where L = sireGen + damGen - const L = sireOcc.generation + damOcc.generation; - coi += Math.pow(0.5, L + 1); + sireOccs.forEach(so => { + damOccs.forEach(do_ => { + const key = `${ancId}-${so.generation}-${do_.generation}`; + if (!processedPaths.has(key)) { + processedPaths.add(key); + coi += Math.pow(0.5, so.generation + do_.generation + 1); } }); }); - // Build display list using closest occurrence per side - const closestSire = sireOccurrences.reduce((a, b) => a.generation < b.generation ? a : b); - const closestDam = damOccurrences.reduce((a, b) => a.generation < b.generation ? a : b); + const closestSire = sireOccs.reduce((a, b) => a.generation < b.generation ? a : b); + const closestDam = damOccs.reduce((a, b) => a.generation < b.generation ? a : b); commonAncestorList.push({ id: ancId, - name: sireOccurrences[0].name, + name: sireOccs[0].name, sireGen: closestSire.generation, damGen: closestDam.generation }); @@ -101,7 +108,7 @@ function calculateCOI(sireId, damId, generations = 6) { }; } -// GET pedigree tree for a dog +// ── GET pedigree tree ───────────────────────────────────────────────────────── router.get('/:id', (req, res) => { try { const db = getDatabase(); @@ -109,21 +116,12 @@ router.get('/:id', (req, res) => { function buildTree(dogId, currentGen = 0) { if (currentGen >= generations) return null; - const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId); if (!dog) return null; - dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; - - const parents = db.prepare(` - SELECT p.parent_type, p.parent_id - FROM parents p - WHERE p.dog_id = ? - `).all(dogId); - + const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(dogId); const sire = parents.find(p => p.parent_type === 'sire'); const dam = parents.find(p => p.parent_type === 'dam'); - return { ...dog, generation: currentGen, @@ -140,7 +138,7 @@ router.get('/:id', (req, res) => { } }); -// GET reverse pedigree (descendants) +// ── GET descendants ─────────────────────────────────────────────────────────── router.get('/:id/descendants', (req, res) => { try { const db = getDatabase(); @@ -148,23 +146,18 @@ router.get('/:id/descendants', (req, res) => { function buildDescendantTree(dogId, currentGen = 0) { if (currentGen >= generations) return null; - const dog = db.prepare('SELECT * FROM dogs WHERE id = ?').get(dogId); if (!dog) return null; - dog.photo_urls = dog.photo_urls ? JSON.parse(dog.photo_urls) : []; - const offspring = db.prepare(` SELECT DISTINCT d.id, d.name, d.sex, d.birth_date - FROM dogs d - JOIN parents p ON d.id = p.dog_id + FROM dogs d JOIN parents p ON d.id = p.dog_id WHERE p.parent_id = ? AND d.is_active = 1 `).all(dogId); - return { ...dog, generation: currentGen, - offspring: offspring.map(child => buildDescendantTree(child.id, currentGen + 1)) + offspring: offspring.map(c => buildDescendantTree(c.id, currentGen + 1)) }; } @@ -176,11 +169,21 @@ router.get('/:id/descendants', (req, res) => { } }); -// POST calculate COI for a trial pairing +// ── GET direct-relation check (used by frontend before simulate) ────────────── +// GET /api/pedigree/relations/:sireId/:damId +router.get('/relations/:sireId/:damId', (req, res) => { + try { + const db = getDatabase(); + res.json(isDirectRelation(db, req.params.sireId, req.params.damId)); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ── POST trial pairing ──────────────────────────────────────────────────────── router.post('/trial-pairing', (req, res) => { try { const { sire_id, dam_id } = req.body; - if (!sire_id || !dam_id) { return res.status(400).json({ error: 'Both sire_id and dam_id are required' }); } @@ -190,16 +193,18 @@ router.post('/trial-pairing', (req, res) => { const dam = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'female'").get(dam_id); if (!sire || !dam) { - return res.status(404).json({ error: 'Invalid sire or dam' }); + return res.status(404).json({ error: 'Invalid sire or dam — check sex values in database' }); } - const result = calculateCOI(sire_id, dam_id); + const relation = isDirectRelation(db, sire_id, dam_id); + const result = calculateCOI(db, sire_id, dam_id); res.json({ sire: { id: sire.id, name: sire.name }, dam: { id: dam.id, name: dam.name }, coi: result.coefficient, commonAncestors: result.commonAncestors, + directRelation: relation.related ? relation.relationship : null, recommendation: result.coefficient < 5 ? 'Low risk' : result.coefficient < 10 ? 'Moderate risk' : 'High risk'