fix: correct COI Wright path algorithm — include sire/dam as direct ancestors of each other

This commit is contained in:
2026-03-10 14:44:27 -05:00
parent 20fcc39a58
commit f5ee9837c6

View File

@@ -2,69 +2,102 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { getDatabase } = require('../db/init'); const { getDatabase } = require('../db/init');
// Helper function to calculate inbreeding coefficient /**
function calculateCOI(sireId, damId, generations = 5) { * 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
*/
function calculateCOI(sireId, damId, generations = 6) {
const db = getDatabase(); const db = getDatabase();
// Get all ancestors for both parents // Returns a list of { id, name, generation } for every ancestor of dogId
function getAncestors(dogId, currentGen = 0, maxGen = generations) { // INCLUDING dogId itself at generation 0.
if (currentGen >= maxGen) return []; function getAncestorMap(dogId) {
const visited = new Map(); // id -> [{ id, name, generation }, ...]
const parents = db.prepare(` function recurse(id, gen) {
SELECT p.parent_type, p.parent_id, d.name if (gen > generations) return;
FROM parents p
JOIN dogs d ON p.parent_id = d.id
WHERE p.dog_id = ?
`).all(dogId);
const ancestors = parents.map(p => ({ const dog = db.prepare('SELECT id, name FROM dogs WHERE id = ?').get(id);
id: p.parent_id, if (!dog) return;
name: p.name,
type: p.parent_type,
generation: currentGen + 1
}));
parents.forEach(p => { if (!visited.has(id)) visited.set(id, []);
ancestors.push(...getAncestors(p.parent_id, currentGen + 1, maxGen)); visited.get(id).push({ id: dog.id, name: dog.name, generation: gen });
});
return ancestors; // 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));
}
}
recurse(dogId, 0);
return visited;
} }
const sireAncestors = getAncestors(sireId); const sireMap = getAncestorMap(sireId);
const damAncestors = getAncestors(damId); const damMap = getAncestorMap(damId);
// Find common ancestors // Find all IDs that appear in BOTH ancestor maps (common ancestors)
const commonAncestors = []; // Exclude the sire and dam themselves from being listed as common ancestors
sireAncestors.forEach(sireAnc => { // (they can't be common ancestors of the hypothetical offspring with each other)
damAncestors.forEach(damAnc => { const commonIds = [...sireMap.keys()].filter(
if (sireAnc.id === damAnc.id) { id => damMap.has(id) && id !== parseInt(sireId) && id !== parseInt(damId)
commonAncestors.push({ );
id: sireAnc.id,
name: sireAnc.name,
sireGen: sireAnc.generation,
damGen: damAnc.generation
});
}
});
});
// Calculate COI using path coefficient method
let coi = 0; let coi = 0;
const processed = new Set(); const commonAncestorList = [];
const processedPaths = new Set();
commonAncestors.forEach(anc => { commonIds.forEach(ancId => {
const key = `${anc.id}-${anc.sireGen}-${anc.damGen}`; const sireOccurrences = sireMap.get(ancId);
if (!processed.has(key)) { const damOccurrences = damMap.get(ancId);
processed.add(key);
const pathLength = anc.sireGen + anc.damGen; // Wright: sum over every combination of paths through this ancestor
coi += Math.pow(0.5, pathLength); 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);
}
});
});
// 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);
commonAncestorList.push({
id: ancId,
name: sireOccurrences[0].name,
sireGen: closestSire.generation,
damGen: closestDam.generation
});
}); });
return { return {
coefficient: Math.round(coi * 10000) / 100, // Percentage with 2 decimals coefficient: Math.round(coi * 10000) / 100,
commonAncestors: [...new Map(commonAncestors.map(a => [a.id, a])).values()] commonAncestors: commonAncestorList
}; };
} }
@@ -89,22 +122,18 @@ router.get('/:id', (req, res) => {
`).all(dogId); `).all(dogId);
const sire = parents.find(p => p.parent_type === 'sire'); const sire = parents.find(p => p.parent_type === 'sire');
const dam = parents.find(p => p.parent_type === 'dam'); const dam = parents.find(p => p.parent_type === 'dam');
return { return {
...dog, ...dog,
generation: currentGen, generation: currentGen,
sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null, sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null,
dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null
}; };
} }
const tree = buildTree(req.params.id); const tree = buildTree(req.params.id);
if (!tree) return res.status(404).json({ error: 'Dog not found' });
if (!tree) {
return res.status(404).json({ error: 'Dog not found' });
}
res.json(tree); res.json(tree);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -140,11 +169,7 @@ router.get('/:id/descendants', (req, res) => {
} }
const tree = buildDescendantTree(req.params.id); const tree = buildDescendantTree(req.params.id);
if (!tree) return res.status(404).json({ error: 'Dog not found' });
if (!tree) {
return res.status(404).json({ error: 'Dog not found' });
}
res.json(tree); res.json(tree);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -161,7 +186,6 @@ router.post('/trial-pairing', (req, res) => {
} }
const db = getDatabase(); const db = getDatabase();
// FIX: use single quotes for string literals — SQLite treats double quotes as identifiers
const sire = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'male'").get(sire_id); const sire = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'male'").get(sire_id);
const dam = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'female'").get(dam_id); const dam = db.prepare("SELECT * FROM dogs WHERE id = ? AND sex = 'female'").get(dam_id);
@@ -172,11 +196,13 @@ router.post('/trial-pairing', (req, res) => {
const result = calculateCOI(sire_id, dam_id); const result = calculateCOI(sire_id, dam_id);
res.json({ res.json({
sire: { id: sire.id, name: sire.name }, sire: { id: sire.id, name: sire.name },
dam: { id: dam.id, name: dam.name }, dam: { id: dam.id, name: dam.name },
coi: result.coefficient, coi: result.coefficient,
commonAncestors: result.commonAncestors, commonAncestors: result.commonAncestors,
recommendation: result.coefficient < 5 ? 'Low risk' : result.coefficient < 10 ? 'Moderate risk' : 'High risk' recommendation: result.coefficient < 5 ? 'Low risk'
: result.coefficient < 10 ? 'Moderate risk'
: 'High risk'
}); });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });