diff --git a/gerbil-manager-web/src/genetics/genotype.ts b/gerbil-manager-web/src/genetics/genotype.ts new file mode 100644 index 0000000..0138cc0 --- /dev/null +++ b/gerbil-manager-web/src/genetics/genotype.ts @@ -0,0 +1,141 @@ +/** + * Genotype representation + canonical serialisation. + * + * ── SERIALISATION CONTRACT (must match DATA-1 DB storage) ────────────────── + * + * In-memory / JSON storage shape (source of truth, lossless): + * Genotype = { [locus]: [alleleA, alleleB] } + * e.g. { "A":["A","a"], "C":["C","ch"], "D":["D","D"], "E":["E","E"], + * "G":["G","G"], "P":["P","p"], "Sp":["Sp","sp"], "Re":["re","re"] } + * - Allele pair is stored most-dominant-first (canonical order). + * - Allele symbols are exactly: A a | C cchm ch | D d | E ef e | G g | P p | Sp sp | Re re + * + * Compact display string (German-breeder convention, human-facing): + * "Aa CC Dd EE GG Pp Spsp rere" (locus tokens in fixed order A C D E G P Sp Re) + * - Multi-char alleles concatenate as-is, parsed via maximal-munch over the + * known allele set, so "Cch" = [C, ch], "cchmcchm" = [cchm, cchm]. + * + * Partially-unknown genotypes: an allele may be the wildcard "?" meaning + * "unknown / any". Wildcards are accepted by the engine (it expands them over + * the locus' allele set, weighted uniformly) — see punnett.ts. + * ─────────────────────────────────────────────────────────────────────────── + */ +import { + ALLELE_SYMBOLS, + ALLELE_TO_LOCUS, + LOCI, + LOCUS_ORDER, + dominanceRank, + type LocusKey, +} from './loci' + +export const WILDCARD = '?' + +export type AllelePair = readonly [string, string] +export type Genotype = Readonly> + +/** Order an allele pair most-dominant-first; wildcards sort last. */ +export function canonicalPair(locus: LocusKey, a: string, b: string): AllelePair { + const rank = (x: string) => (x === WILDCARD ? Number.MAX_SAFE_INTEGER : dominanceRank(locus, x)) + return rank(a) <= rank(b) ? [a, b] : [b, a] +} + +function assertAllele(locus: LocusKey, allele: string): void { + if (allele === WILDCARD) return + if (!LOCI[locus].alleles.includes(allele)) { + throw new Error(`Invalid allele "${allele}" for locus ${locus}`) + } +} + +/** Build a validated, canonically-ordered Genotype from a partial map. */ +export function makeGenotype(input: Record): Genotype { + const out = {} as Record + for (const locus of LOCUS_ORDER) { + const pair = input[locus] + if (!pair) throw new Error(`Missing locus ${locus} in genotype`) + assertAllele(locus, pair[0]) + assertAllele(locus, pair[1]) + out[locus] = canonicalPair(locus, pair[0], pair[1]) + } + return out +} + +/** The wild-type genotype: AA CC DD EE GG PP spsp rere. */ +export function wildType(): Genotype { + const out = {} as Record + for (const locus of LOCUS_ORDER) { + // Wild-type is homozygous for the most dominant allele, EXCEPT the + // marker loci Sp/Re whose wild form is the recessive (unmarked) allele. + const alleles = LOCI[locus].alleles + const a = locus === 'Sp' || locus === 'Re' ? alleles[alleles.length - 1] : alleles[0] + out[locus] = [a, a] + } + return out +} + +/** Compact display string, e.g. "Aa CC Dd EE GG Pp spsp rere". */ +export function toDisplayString(g: Genotype): string { + return LOCUS_ORDER.map((locus) => g[locus][0] + g[locus][1]).join(' ') +} + +/** Stable JSON-storable object (already the in-memory shape; returned as a copy). */ +export function toJSON(g: Genotype): Record { + const out = {} as Record + for (const locus of LOCUS_ORDER) out[locus] = [g[locus][0], g[locus][1]] + return out +} + +export function fromJSON(obj: Record): Genotype { + return makeGenotype(obj as Record) +} + +/** + * Split a locus token like "Cch" or "cchmcchm" or "Aa" into its two alleles + * via maximal-munch over the known allele symbols (longest-first). + */ +function splitToken(token: string): [string, string] { + const alleles: string[] = [] + let rest = token + while (rest.length > 0) { + if (rest.startsWith(WILDCARD)) { + alleles.push(WILDCARD) + rest = rest.slice(1) + continue + } + const sym = ALLELE_SYMBOLS.find((s) => rest.startsWith(s)) + if (!sym) throw new Error(`Cannot parse allele token "${token}" at "${rest}"`) + alleles.push(sym) + rest = rest.slice(sym.length) + } + if (alleles.length !== 2) { + throw new Error(`Token "${token}" did not resolve to exactly two alleles`) + } + return [alleles[0], alleles[1]] +} + +/** + * Parse a display string ("Aa CC Dd EE GG Pp Spsp rere") back into a Genotype. + * Tokens may be given in any order; each token must belong to a distinct locus. + * Missing loci default to wild-type. + */ +export function fromDisplayString(input: string): Genotype { + const tokens = input.trim().split(/\s+/).filter(Boolean) + const acc = {} as Record + for (const token of tokens) { + const [a, b] = splitToken(token) + const refAllele = a === WILDCARD ? b : a + if (refAllele === WILDCARD) { + throw new Error(`Token "${token}" is fully unknown; cannot infer its locus`) + } + const locus = ALLELE_TO_LOCUS[refAllele] + if (!locus) throw new Error(`Unknown allele "${refAllele}" in token "${token}"`) + if (acc[locus]) throw new Error(`Locus ${locus} given twice`) + acc[locus] = canonicalPair(locus, a, b) + } + const base = wildType() + return makeGenotype({ ...base, ...acc }) +} + +export function hasUnknown(g: Genotype): boolean { + return LOCUS_ORDER.some((l) => g[l][0] === WILDCARD || g[l][1] === WILDCARD) +}