Merge pull request 'fix: COI direct-ancestor bug — correct Wright algorithm + frontend relation guard' (#45) from fix/pairing-coi-and-direct-relation-guard into master

Reviewed-on: #45
This commit was merged in pull request #45.
This commit is contained in:
2026-03-10 14:57:15 -05:00
2 changed files with 176 additions and 104 deletions

View File

@@ -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 (
<div className={`risk-badge risk-${isLow ? 'low' : isMed ? 'med' : 'high'}`}>
{isLow && <CheckCircle size={20} />}
{isMed && <AlertTriangle size={20} />}
{isLow && <CheckCircle size={20} />}
{isMed && <AlertTriangle size={20} />}
{isHigh && <XCircle size={20} />}
<span>{recommendation}</span>
</div>
@@ -84,7 +118,7 @@ export default function PairingSimulator() {
<label className="label">Sire (Male) </label>
<select
value={sireId}
onChange={e => setSireId(e.target.value)}
onChange={handleSireChange}
required
disabled={dogsLoading}
>
@@ -104,7 +138,7 @@ export default function PairingSimulator() {
<label className="label">Dam (Female) </label>
<select
value={damId}
onChange={e => setDamId(e.target.value)}
onChange={handleDamChange}
required
disabled={dogsLoading}
>
@@ -121,10 +155,30 @@ export default function PairingSimulator() {
</div>
</div>
{/* Direct-relation warning banner */}
{relationChecking && (
<p style={{ fontSize: '0.8125rem', color: 'var(--text-muted)', marginBottom: '1rem' }}>Checking relationship</p>
)}
{!relationChecking && relationWarning && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
background: 'rgba(234,179,8,0.12)', border: '1px solid rgba(234,179,8,0.4)',
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1rem'
}}>
<ShieldAlert size={18} style={{ color: '#eab308', flexShrink: 0, marginTop: '0.1rem' }} />
<div>
<p style={{ margin: 0, fontWeight: 600, color: '#eab308', fontSize: '0.875rem' }}>Direct Relation Detected</p>
<p style={{ margin: '0.2rem 0 0', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>
{relationWarning}. COI will reflect the high inbreeding coefficient for this pairing.
</p>
</div>
</div>
)}
<button
type="submit"
className="btn btn-primary"
disabled={!sireId || !damId || loading}
disabled={!sireId || !damId || loading || relationChecking}
style={{ minWidth: '160px' }}
>
{loading ? 'Calculating…' : <><FlaskConical size={16} /> Simulate Pairing</>}
@@ -138,6 +192,21 @@ export default function PairingSimulator() {
{/* Results */}
{result && (
<div style={{ maxWidth: '720px' }}>
{/* Direct-relation alert in results */}
{result.directRelation && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: '0.6rem',
background: 'rgba(239,68,68,0.1)', border: '1px solid rgba(239,68,68,0.35)',
borderRadius: 'var(--radius-sm)', padding: '0.75rem 1rem', marginBottom: '1.25rem'
}}>
<ShieldAlert size={18} style={{ color: 'var(--danger)', flexShrink: 0, marginTop: '0.1rem' }} />
<div>
<p style={{ margin: 0, fontWeight: 600, color: 'var(--danger)', fontSize: '0.875rem' }}>Direct Relation High Inbreeding Risk</p>
<p style={{ margin: '0.2rem 0 0', fontSize: '0.8125rem', color: 'var(--text-secondary)' }}>{result.directRelation}</p>
</div>
</div>
)}
{/* COI Summary */}
<div className="card" style={{ marginBottom: '1.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
@@ -152,9 +221,7 @@ export default function PairingSimulator() {
<div style={{ textAlign: 'right' }}>
<p style={{ color: 'var(--text-muted)', fontSize: '0.8125rem', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500, marginBottom: '0.25rem' }}>COI</p>
<p style={{
fontSize: '2rem',
fontWeight: 700,
lineHeight: 1,
fontSize: '2rem', fontWeight: 700, lineHeight: 1,
color: result.coi < 5 ? 'var(--success)' : result.coi < 10 ? 'var(--warning)' : 'var(--danger)'
}}>
{result.coi.toFixed(2)}%
@@ -183,7 +250,7 @@ export default function PairingSimulator() {
{result.commonAncestors.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '1.5rem 0', margin: 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.
</p>
) : (
<div style={{ overflowX: 'auto' }}>

View File

@@ -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<id, [{ id, name, generation }, ...]>
* 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'