@@ -152,9 +221,7 @@ export default function PairingSimulator() {
COI
{result.coi.toFixed(2)}%
@@ -183,7 +250,7 @@ export default function PairingSimulator() {
{result.commonAncestors.length === 0 ? (
- No common ancestors found within 5 generations. This pairing has excellent genetic diversity.
+ No common ancestors found within 6 generations. This pairing has excellent genetic diversity.
) : (
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'