Files
breedr/server/routes/pedigree.js
2026-03-11 23:48:35 -05:00

346 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const router = express.Router();
const { getDatabase } = require('../db/init');
const MAX_CACHE_SIZE = 1000;
const ancestorCache = new Map();
const coiCache = new Map();
function getFromCache(cache, key, computeFn) {
if (cache.has(key)) {
const val = cache.get(key);
cache.delete(key);
cache.set(key, val);
return val;
}
const val = computeFn();
if (cache.size >= MAX_CACHE_SIZE) {
cache.delete(cache.keys().next().value);
}
cache.set(key, val);
return val;
}
/**
* getAncestorMap(db, dogId, maxGen)
* Returns Map<id, [{ id, name, generation }, ...]>
* INCLUDES dogId itself at generation 0 so direct parent-offspring
* pairings are correctly detected by calculateCOI.
*/
function getAncestorMap(db, dogId, maxGen = 6) {
const cacheKey = `${dogId}-${maxGen}`;
return getFromCache(ancestorCache, cacheKey, () => {
const map = new Map();
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 });
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(parseInt(dogId), 0);
return map;
});
}
/**
* isDirectRelation(db, sireId, damId)
* Returns { related, relationship } if one dog is a direct ancestor
* of the other within 3 generations.
*/
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.
* Dogs included at gen 0 in their own maps so parent x offspring
* yields ~25% COI.
*
* Fix: do NOT exclude sid/did from commonIds globally.
* - Exclude `did` from sireMap keys (the dam itself can't be a
* common ancestor of the sire's side for THIS pairing's offspring)
* - Exclude `sid` from damMap keys (same logic for sire)
* This preserves the case where the sire IS a common ancestor in the
* dam's ancestry (parent x offspring) while still avoiding reflexive
* self-loops.
*/
function calculateCOI(db, sireId, damId) {
const cacheKey = `${sireId}-${damId}`;
return getFromCache(coiCache, cacheKey, () => {
const sid = parseInt(sireId);
const did = parseInt(damId);
const sireMap = getAncestorMap(db, sid);
const damMap = getAncestorMap(db, did);
// Common ancestors: in BOTH maps, but:
// - not the dam itself appearing in sireMap (would be a loop)
// - not the sire itself appearing in damMap already handled below
// We collect all IDs present in both, excluding only the direct
// subjects (did from sireMap side, sid excluded already since we
// iterate sireMap keys — but sid IS in sireMap at gen 0, and if
// damMap also has sid, that is the parent×offspring case we WANT).
const commonIds = [...sireMap.keys()].filter(
id => damMap.has(id) && id !== did
);
let coi = 0;
const processedPaths = new Set();
const commonAncestorList = [];
commonIds.forEach(ancId => {
const sireOccs = sireMap.get(ancId);
const damOccs = damMap.get(ancId);
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);
}
});
});
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: sireOccs[0].name,
sireGen: closestSire.generation,
damGen: closestDam.generation
});
});
return {
coefficient: coi,
commonAncestors: commonAncestorList
};
});
}
// =====================================================================
// IMPORTANT: Specific named routes MUST be registered BEFORE
// the /:id wildcard, or Express will match 'relations' and
// 'trial-pairing' as dog IDs and return 404/wrong data.
// =====================================================================
const handleTrialPairing = (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' });
}
const db = getDatabase();
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);
if (!sire || !dam) {
return res.status(404).json({ error: 'Invalid sire or dam \u2014 check sex values in database' });
}
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 < 0.05 ? 'Low risk'
: result.coefficient < 0.10 ? 'Moderate risk'
: 'High risk'
});
} catch (error) {
res.status(500).json({ error: error.message });
}
};
// POST /api/pedigree/trial-pairing
router.post('/trial-pairing', handleTrialPairing);
// POST /api/pedigree/coi
router.post('/coi', handleTrialPairing);
// GET /api/pedigree/:id/coi
router.get('/:id/coi', (req, res) => {
try {
const db = getDatabase();
const parents = db.prepare('SELECT parent_type, parent_id FROM parents WHERE dog_id = ?').all(req.params.id);
const sire = parents.find(p => p.parent_type === 'sire');
const dam = parents.find(p => p.parent_type === 'dam');
if (!sire || !dam) {
return res.json({ coi: 0, commonAncestors: [], message: 'Incomplete parent data' });
}
const result = calculateCOI(db, sire.parent_id, dam.parent_id);
res.json({
coi: result.coefficient,
commonAncestors: result.commonAncestors
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 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 });
}
});
// GET /api/pedigree/:id/cancer-lineage
router.get('/:id/cancer-lineage', (req, res) => {
try {
const db = getDatabase();
// Get ancestor map up to 5 generations
const ancestorMap = getAncestorMap(db, req.params.id, 5);
// Collect all unique ancestor IDs
const ancestorIds = Array.from(ancestorMap.keys());
if (ancestorIds.length === 0) {
return res.json({ lineage_cases: [], stats: { total_ancestors: 0, ancestors_with_cancer: 0 } });
}
// Query cancer history for all ancestors
const placeholders = ancestorIds.map(() => '?').join(',');
const cancerRecords = db.prepare(`
SELECT c.*, d.name, d.sex
FROM cancer_history c
JOIN dogs d ON c.dog_id = d.id
WHERE c.dog_id IN (${placeholders})
`).all(...ancestorIds);
// Structure the response
const cases = cancerRecords.map(record => {
// Find the closest generation this ancestor appears in
const occurrences = ancestorMap.get(record.dog_id);
const closestGen = occurrences.reduce((min, occ) => occ.generation < min ? occ.generation : min, 999);
return {
...record,
generation_distance: closestGen
};
});
// Sort by generation distance (closer relatives first)
cases.sort((a, b) => a.generation_distance - b.generation_distance);
// Count unique dogs with cancer (excluding generation 0 if we only want stats on ancestors)
const ancestorCases = cases.filter(c => c.generation_distance > 0);
const uniqueAncestorsWithCancer = new Set(ancestorCases.map(c => c.dog_id)).size;
// Number of ancestors is total unique IDs minus 1 for the dog itself
const numAncestors = ancestorIds.length > 0 && ancestorMap.get(parseInt(req.params.id)) ? ancestorIds.length - 1 : ancestorIds.length;
res.json({
lineage_cases: cases,
stats: {
total_ancestors: numAncestors,
ancestors_with_cancer: uniqueAncestorsWithCancer
}
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// =====================================================================
// Wildcard routes last
// =====================================================================
// GET /api/pedigree/:id
router.get('/:id', (req, res) => {
try {
const db = getDatabase();
const generations = parseInt(req.query.generations) || 5;
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 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,
sire: sire ? buildTree(sire.parent_id, currentGen + 1) : null,
dam: dam ? buildTree(dam.parent_id, currentGen + 1) : null
};
}
const tree = buildTree(req.params.id);
if (!tree) return res.status(404).json({ error: 'Dog not found' });
res.json(tree);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GET /api/pedigree/:id/descendants
router.get('/:id/descendants', (req, res) => {
try {
const db = getDatabase();
const generations = parseInt(req.query.generations) || 3;
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
WHERE p.parent_id = ? AND d.is_active = 1
`).all(dogId);
return {
...dog,
generation: currentGen,
offspring: offspring.map(c => buildDescendantTree(c.id, currentGen + 1))
};
}
const tree = buildDescendantTree(req.params.id);
if (!tree) return res.status(404).json({ error: 'Dog not found' });
res.json(tree);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;