GEN-1: add Genotype type + canonical serialization (JSON + display string)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
141
gerbil-manager-web/src/genetics/genotype.ts
Normal file
141
gerbil-manager-web/src/genetics/genotype.ts
Normal file
@@ -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<Record<LocusKey, AllelePair>>
|
||||
|
||||
/** 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<LocusKey, AllelePair>): Genotype {
|
||||
const out = {} as Record<LocusKey, AllelePair>
|
||||
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<LocusKey, AllelePair>
|
||||
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<LocusKey, [string, string]> {
|
||||
const out = {} as Record<LocusKey, [string, string]>
|
||||
for (const locus of LOCUS_ORDER) out[locus] = [g[locus][0], g[locus][1]]
|
||||
return out
|
||||
}
|
||||
|
||||
export function fromJSON(obj: Record<string, [string, string]>): Genotype {
|
||||
return makeGenotype(obj as Record<LocusKey, AllelePair>)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<LocusKey, AllelePair>
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user