From c949fe250236155fe34a40051c30ed05462e6619 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 10 Mar 2026 14:54:59 -0500 Subject: [PATCH 1/2] fix(backend): rewrite COI with self-at-gen-0 Wright method + direct-relation detection endpoint --- server/routes/pedigree.js | 185 +++++++++++++++++++------------------- 1 file changed, 95 insertions(+), 90 deletions(-) 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' From 72c54f847fad3b5cbf974d8deb52a7a35d6bfa5c Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 10 Mar 2026 14:56:09 -0500 Subject: [PATCH 2/2] fix(frontend): block/warn direct parent-offspring selections in PairingSimulator --- client/src/pages/PairingSimulator.jsx | 95 +++++++++++++++++++++++---- 1 file changed, 81 insertions(+), 14 deletions(-) diff --git a/client/src/pages/PairingSimulator.jsx b/client/src/pages/PairingSimulator.jsx index 9633f3e..047f708 100644 --- a/client/src/pages/PairingSimulator.jsx +++ b/client/src/pages/PairingSimulator.jsx @@ -1,5 +1,5 @@ -import { useState, useEffect } from 'react' -import { FlaskConical, AlertTriangle, CheckCircle, XCircle, GitMerge } from 'lucide-react' +import { useState, useEffect, useCallback } from 'react' +import { FlaskConical, AlertTriangle, CheckCircle, XCircle, GitMerge, ShieldAlert } from 'lucide-react' export default function PairingSimulator() { const [dogs, setDogs] = useState([]) @@ -9,6 +9,8 @@ export default function PairingSimulator() { const [loading, setLoading] = useState(false) const [error, setError] = useState(null) const [dogsLoading, setDogsLoading] = useState(true) + const [relationWarning, setRelationWarning] = useState(null) + const [relationChecking, setRelationChecking] = useState(false) useEffect(() => { fetch('/api/dogs') @@ -20,7 +22,39 @@ export default function PairingSimulator() { .catch(() => setDogsLoading(false)) }, []) - const males = dogs.filter(d => d.sex === 'male') + // Check for direct relation whenever both sire and dam are selected + const checkRelation = useCallback(async (sid, did) => { + if (!sid || !did) { + setRelationWarning(null) + return + } + setRelationChecking(true) + try { + const res = await fetch(`/api/pedigree/relations/${sid}/${did}`) + const data = await res.json() + setRelationWarning(data.related ? data.relationship : null) + } catch { + setRelationWarning(null) + } finally { + setRelationChecking(false) + } + }, []) + + function handleSireChange(e) { + const val = e.target.value + setSireId(val) + setResult(null) + checkRelation(val, damId) + } + + function handleDamChange(e) { + const val = e.target.value + setDamId(val) + setResult(null) + checkRelation(sireId, val) + } + + const males = dogs.filter(d => d.sex === 'male') const females = dogs.filter(d => d.sex === 'female') async function handleSimulate(e) { @@ -48,13 +82,13 @@ export default function PairingSimulator() { } function RiskBadge({ coi, recommendation }) { - const isLow = coi < 5 - const isMed = coi >= 5 && coi < 10 + const isLow = coi < 5 + const isMed = coi >= 5 && coi < 10 const isHigh = coi >= 10 return (
- {isLow && } - {isMed && } + {isLow && } + {isMed && } {isHigh && } {recommendation}
@@ -84,7 +118,7 @@ export default function PairingSimulator() { setDamId(e.target.value)} + onChange={handleDamChange} required disabled={dogsLoading} > @@ -121,10 +155,30 @@ export default function PairingSimulator() { + {/* Direct-relation warning banner */} + {relationChecking && ( +

Checking relationship…

+ )} + {!relationChecking && relationWarning && ( +
+ +
+

Direct Relation Detected

+

+ {relationWarning}. COI will reflect the high inbreeding coefficient for this pairing. +

+
+
+ )} +