fix: COI direct-ancestor bug — correct Wright algorithm + frontend relation guard #45

Merged
jason merged 2 commits from fix/pairing-coi-and-direct-relation-guard into master 2026-03-10 14:57:16 -05:00
Showing only changes of commit 72c54f847f - Show all commits

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' }}>