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:
2026-06-05 23:38:49 +02:00
parent 9d5608f46e
commit 3fc430f1a0

View 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)
}