From 67aca380a5e1823b8ec0fcb7ed47696236178b16 Mon Sep 17 00:00:00 2001 From: Spbd1 <148923621+Spbd1@users.noreply.github.com> Date: Thu, 7 May 2026 02:42:36 +0000 Subject: [PATCH] Implement structured phenotype expression --- pishiegen/phenotype/__init__.py | 5 +- pishiegen/phenotype/dominance.py | 72 +++++++++++ pishiegen/phenotype/expression.py | 190 +++++++++++++++++++++++++---- pishiegen/phenotype/traits.py | 143 ++++++++++++++++++++++ tests/test_phenotype_expression.py | 107 ++++++++++++++++ 5 files changed, 494 insertions(+), 23 deletions(-) create mode 100644 pishiegen/phenotype/dominance.py create mode 100644 pishiegen/phenotype/traits.py create mode 100644 tests/test_phenotype_expression.py diff --git a/pishiegen/phenotype/__init__.py b/pishiegen/phenotype/__init__.py index 2962ba0..588d19c 100644 --- a/pishiegen/phenotype/__init__.py +++ b/pishiegen/phenotype/__init__.py @@ -1,5 +1,6 @@ """Phenotype expression from compact genomes.""" -from pishiegen.phenotype.expression import Phenotype, express_genome +from pishiegen.phenotype.expression import express_genome +from pishiegen.phenotype.traits import PHENOTYPE_FIELDS, Phenotype, clamp -__all__ = ["Phenotype", "express_genome"] +__all__ = ["PHENOTYPE_FIELDS", "Phenotype", "clamp", "express_genome"] diff --git a/pishiegen/phenotype/dominance.py b/pishiegen/phenotype/dominance.py new file mode 100644 index 0000000..2057675 --- /dev/null +++ b/pishiegen/phenotype/dominance.py @@ -0,0 +1,72 @@ +"""Simple dominance helpers for coat-color allele expression.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class CoatAllele: + """Decoded coat allele plus modifier information.""" + + name: str + dilute: bool = False + + +_COAT_ALLELES = ( + "black", + "chocolate", + "cinnamon", + "orange", + "cream", + "silver", + "white", + "red", +) + +_DOMINANCE_RANK = { + "white": 8, + "black": 7, + "red": 6, + "orange": 5, + "chocolate": 4, + "cinnamon": 3, + "silver": 2, + "cream": 1, +} + +_DILUTIONS = { + "black": "blue", + "chocolate": "lilac", + "cinnamon": "fawn", + "orange": "cream", + "red": "cream", + "silver": "pale silver", + "cream": "cream", + "white": "white", +} + + +def decode_coat_allele(raw_value: int) -> CoatAllele: + """Decode an 8-bit coat field into a base allele and dilution modifier.""" + + return CoatAllele( + name=_COAT_ALLELES[raw_value % len(_COAT_ALLELES)], + dilute=bool(raw_value & 0b1000), + ) + + +def dominant_allele(first: CoatAllele, second: CoatAllele) -> CoatAllele: + """Return the allele that wins the simple dominance hierarchy.""" + + first_rank = _DOMINANCE_RANK[first.name] + second_rank = _DOMINANCE_RANK[second.name] + return first if first_rank >= second_rank else second + + +def expressed_coat_color(allele: CoatAllele) -> str: + """Return the visible color, applying dilution only as a modifier.""" + + if not allele.dilute: + return allele.name + return _DILUTIONS[allele.name] diff --git a/pishiegen/phenotype/expression.py b/pishiegen/phenotype/expression.py index bba8e6a..b5e02fd 100644 --- a/pishiegen/phenotype/expression.py +++ b/pishiegen/phenotype/expression.py @@ -1,38 +1,186 @@ -"""Genotype-to-phenotype expression rules.""" +"""Genotype-to-phenotype expression rules for PishieGen.""" from __future__ import annotations -from dataclasses import dataclass from math import tanh -from pishiegen.genome.core import Genome +from pishiegen.genome.core import Genome as AbstractGenome +from pishiegen.genome.decoder import decode_genome +from pishiegen.genome.encoding import Genome as CompactGenome +from pishiegen.phenotype.dominance import ( + decode_coat_allele, + dominant_allele, + expressed_coat_color, +) +from pishiegen.phenotype.traits import Phenotype, clamp -@dataclass(frozen=True, slots=True) -class Phenotype: - """Expressed trait values used by ecological and fitness modules.""" +_PATTERN_BY_CODE = { + 0: "mackerel tabby", + 1: "classic tabby", + 2: "spotted tabby", + 3: "ticked tabby", + 4: "rosetted tabby", + 5: "broken tabby", + 6: "marbled tabby", + 7: "striped tabby", +} +_FUR_TYPES = ("straight", "wavy", "curly", "rex") +_EAR_TYPES = ("normal", "folded", "curled", "lynx-tufted") +_TAIL_TYPES = ("normal", "long", "bobtail", "tailless") +_POLYDACTYLY = ("none", "carrier", "polydactyl", "strong polydactyl") +_COLORPOINT = ("none", "carrier", "colorpoint", "albino") +_CIRCADIAN = ("nocturnal", "diurnal", "crepuscular") - traits: dict[str, float] - def value(self, trait: str, default: float = 0.0) -> float: - """Return a trait value if present.""" +def express_genome(genome: CompactGenome | AbstractGenome | int) -> Phenotype: + """Convert a compact 128-bit genotype into a structured phenotype. - return self.traits.get(trait, default) + The primary expression path accepts ``pishiegen.genome.encoding.Genome`` or a + raw 128-bit integer. The older abstract gene ``Genome`` remains supported for + existing simulations by expressing its arbitrary gene effects as + ``extra_traits`` on an otherwise neutral phenotype. + """ + if isinstance(genome, AbstractGenome): + return _express_abstract_genome(genome) + fields = decode_genome(genome) + return _express_compact_fields(fields) -def express_genome(genome: Genome) -> Phenotype: - """Map a genome to bounded continuous phenotypic traits. - Gene effects are weighted by dominance and passed through a smooth bounded - transform. The bounded transform is a modeling convenience for stable early - simulations. +def _express_compact_fields(fields: dict[str, int]) -> Phenotype: + base_allele = decode_coat_allele(fields["base_coat_color"]) + hidden_allele = decode_coat_allele(fields["hidden_coat_color"]) + visible_allele = dominant_allele(base_allele, hidden_allele) - TODO: Compare this expression model with established artificial-life systems - and quantitative genetics references before using it for scientific claims. - """ + agouti_field = fields["agouti_tabby_pattern"] + agouti = bool(agouti_field & 0b1000) + pattern = _PATTERN_BY_CODE[agouti_field & 0b0111] if agouti else "solid" + pattern_intensity = fields["pattern_intensity"] / 15.0 if agouti else 0.0 + + fur_field = fields["fur_length_type"] + fur_length = (fur_field & 0b0011) / 3.0 + fur_type = _FUR_TYPES[(fur_field >> 2) & 0b0011] + + ear_field = fields["ear_morphology"] + ear_type = _EAR_TYPES[ear_field & 0b0011] + ear_size = ((ear_field >> 2) & 0b0011) / 3.0 + + tail_type = _TAIL_TYPES[fields["tail_morphology"] & 0b0011] + polydactyly_status = _POLYDACTYLY[fields["polydactyly"]] + colorpoint_status = _COLORPOINT[fields["colorpoint_albino_locus"]] + + thermal = fields["thermal_tolerance"] + cold_tolerance = (thermal & 0x0F) / 15.0 + heat_tolerance = ((thermal >> 4) & 0x0F) / 15.0 + + # Fur expression modifies thermal performance without inventing new colors. + if fur_length >= 0.75: + cold_tolerance += 0.15 + heat_tolerance -= 0.15 + elif fur_length <= 0.10: + cold_tolerance -= 0.20 + heat_tolerance += 0.20 + elif fur_length <= 0.35: + cold_tolerance -= 0.08 + heat_tolerance += 0.08 + + if ear_size >= 0.75: + heat_tolerance += 0.10 + elif ear_size <= 0.10: + heat_tolerance -= 0.05 - expressed: dict[str, float] = {} + camouflage = fields["camouflage_profile"] + forest_camouflage = (camouflage & 0b11) / 3.0 + desert_camouflage = ((camouflage >> 2) & 0b11) / 3.0 + snow_camouflage = ((camouflage >> 4) & 0b11) / 3.0 + wetland_mobility = ((camouflage >> 6) & 0b11) / 3.0 + + poly_bonus = { + "none": 0.0, + "carrier": 0.02, + "polydactyl": 0.07, + "strong polydactyl": 0.10, + }[polydactyly_status] + wetland_mobility += poly_bonus + + agility = fields["agility_muscle"] / 255.0 + if tail_type == "long": + agility += 0.08 + elif tail_type == "normal": + agility += 0.04 + elif tail_type == "bobtail": + agility -= 0.08 + else: + agility -= 0.15 + agility += poly_bonus / 2.0 + + sensory_acuity = fields["sensory_acuity"] / 255.0 + if ear_size >= 0.75: + sensory_acuity += 0.10 + if ear_type == "lynx-tufted": + sensory_acuity += 0.04 + + health_risk_score = fields["health_risk_loci"] / 255.0 * 0.60 + if ear_type == "folded": + health_risk_score += 0.20 + if colorpoint_status == "albino": + health_risk_score += 0.10 + + return Phenotype( + coat_color=expressed_coat_color(visible_allele), + hidden_coat_color=expressed_coat_color(hidden_allele), + pattern=pattern, + pattern_intensity=pattern_intensity, + fur_length=fur_length, + fur_type=fur_type, + ear_type=ear_type, + ear_size=ear_size, + tail_type=tail_type, + polydactyly_status=polydactyly_status, + colorpoint_status=colorpoint_status, + health_risk_score=health_risk_score, + cold_tolerance=cold_tolerance, + heat_tolerance=heat_tolerance, + forest_camouflage=forest_camouflage, + desert_camouflage=desert_camouflage, + snow_camouflage=snow_camouflage, + wetland_mobility=wetland_mobility, + agility=agility, + sensory_acuity=sensory_acuity, + intelligence=fields["intelligence_cognition"] / 255.0, + circadian_type=_CIRCADIAN[fields["circadian_tendency"] % len(_CIRCADIAN)], + ) + + +def _express_abstract_genome(genome: AbstractGenome) -> Phenotype: + extra_traits: dict[str, float] = {} for trait, genes in genome.by_trait().items(): additive_effect = sum(gene.effect * gene.dominance for gene in genes) - expressed[trait] = tanh(additive_effect) - return Phenotype(expressed) + extra_traits[trait] = clamp((tanh(additive_effect) + 1.0) / 2.0) + + return Phenotype( + coat_color="black", + hidden_coat_color="black", + pattern="solid", + pattern_intensity=0.0, + fur_length=0.5, + fur_type="straight", + ear_type="normal", + ear_size=0.5, + tail_type="normal", + polydactyly_status="none", + colorpoint_status="none", + health_risk_score=0.0, + cold_tolerance=0.5, + heat_tolerance=0.5, + forest_camouflage=0.5, + desert_camouflage=0.5, + snow_camouflage=0.5, + wetland_mobility=0.5, + agility=0.5, + sensory_acuity=0.5, + intelligence=0.5, + circadian_type="crepuscular", + extra_traits=extra_traits, + ) diff --git a/pishiegen/phenotype/traits.py b/pishiegen/phenotype/traits.py new file mode 100644 index 0000000..9fb94b6 --- /dev/null +++ b/pishiegen/phenotype/traits.py @@ -0,0 +1,143 @@ +"""Structured phenotype traits and bounded numeric helpers.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +def clamp(value: float, minimum: float = 0.0, maximum: float = 1.0) -> float: + """Return ``value`` constrained to the inclusive ``minimum``/``maximum`` range.""" + + return max(minimum, min(maximum, value)) + + +@dataclass(frozen=True, slots=True) +class Phenotype: + """Expressed Pishie traits derived from a compact computational genotype. + + Numeric ecological and fitness-facing values are normalized to the inclusive + 0-1 range. Categorical morphology fields remain human-readable strings. + ``extra_traits`` preserves compatibility with older abstract gene genomes. + """ + + coat_color: str + hidden_coat_color: str + pattern: str + pattern_intensity: float + fur_length: float + fur_type: str + ear_type: str + ear_size: float + tail_type: str + polydactyly_status: str + colorpoint_status: str + health_risk_score: float + cold_tolerance: float + heat_tolerance: float + forest_camouflage: float + desert_camouflage: float + snow_camouflage: float + wetland_mobility: float + agility: float + sensory_acuity: float + intelligence: float + circadian_type: str + extra_traits: dict[str, float] = field(default_factory=dict) + + def __post_init__(self) -> None: + """Clamp all normalized numeric values after dataclass construction.""" + + for name in _NORMALIZED_FIELDS: + object.__setattr__(self, name, clamp(float(getattr(self, name)))) + object.__setattr__( + self, + "extra_traits", + {name: clamp(float(value)) for name, value in self.extra_traits.items()}, + ) + + @property + def traits(self) -> dict[str, Any]: + """Return a serializable dictionary of all expressed phenotype fields.""" + + values: dict[str, Any] = { + "coat_color": self.coat_color, + "hidden_coat_color": self.hidden_coat_color, + "pattern": self.pattern, + "pattern_intensity": self.pattern_intensity, + "fur_length": self.fur_length, + "fur_type": self.fur_type, + "ear_type": self.ear_type, + "ear_size": self.ear_size, + "tail_type": self.tail_type, + "polydactyly_status": self.polydactyly_status, + "colorpoint_status": self.colorpoint_status, + "health_risk_score": self.health_risk_score, + "cold_tolerance": self.cold_tolerance, + "heat_tolerance": self.heat_tolerance, + "forest_camouflage": self.forest_camouflage, + "desert_camouflage": self.desert_camouflage, + "snow_camouflage": self.snow_camouflage, + "wetland_mobility": self.wetland_mobility, + "agility": self.agility, + "sensory_acuity": self.sensory_acuity, + "intelligence": self.intelligence, + "circadian_type": self.circadian_type, + } + values.update(self.extra_traits) + return values + + def value(self, trait: str, default: float = 0.0) -> float: + """Return a numeric trait value for fitness scoring. + + Categorical traits intentionally return ``default`` because distance-based + fitness scoring expects normalized continuous values. + """ + + if trait in self.extra_traits: + return self.extra_traits[trait] + value = getattr(self, trait, default) + return value if isinstance(value, int | float) else default + + +_NORMALIZED_FIELDS = ( + "pattern_intensity", + "fur_length", + "ear_size", + "health_risk_score", + "cold_tolerance", + "heat_tolerance", + "forest_camouflage", + "desert_camouflage", + "snow_camouflage", + "wetland_mobility", + "agility", + "sensory_acuity", + "intelligence", +) + + +PHENOTYPE_FIELDS = ( + "coat_color", + "hidden_coat_color", + "pattern", + "pattern_intensity", + "fur_length", + "fur_type", + "ear_type", + "ear_size", + "tail_type", + "polydactyly_status", + "colorpoint_status", + "health_risk_score", + "cold_tolerance", + "heat_tolerance", + "forest_camouflage", + "desert_camouflage", + "snow_camouflage", + "wetland_mobility", + "agility", + "sensory_acuity", + "intelligence", + "circadian_type", +) diff --git a/tests/test_phenotype_expression.py b/tests/test_phenotype_expression.py new file mode 100644 index 0000000..74654c3 --- /dev/null +++ b/tests/test_phenotype_expression.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import pytest + +from pishiegen.genome.encoding import Genome +from pishiegen.phenotype import PHENOTYPE_FIELDS, express_genome + + +NUMERIC_PHENOTYPE_FIELDS = ( + "pattern_intensity", + "fur_length", + "ear_size", + "health_risk_score", + "cold_tolerance", + "heat_tolerance", + "forest_camouflage", + "desert_camouflage", + "snow_camouflage", + "wetland_mobility", + "agility", + "sensory_acuity", + "intelligence", +) + + +def compact_genome(**fields: int) -> Genome: + genome = Genome(0) + for name, value in fields.items(): + genome = genome.set_field(name, value) + return genome + + +def test_known_compact_genome_expresses_expected_structured_phenotype() -> None: + genome = compact_genome( + base_coat_color=8, + hidden_coat_color=2, + agouti_tabby_pattern=0b1010, + pattern_intensity=12, + fur_length_type=0b0011, + ear_morphology=0b1101, + tail_morphology=1, + polydactyly=2, + colorpoint_albino_locus=2, + health_risk_loci=0, + thermal_tolerance=0x69, + camouflage_profile=0b0010_0111, + agility_muscle=128, + sensory_acuity=128, + intelligence_cognition=204, + circadian_tendency=2, + ) + + phenotype = express_genome(genome) + + assert phenotype.coat_color == "blue" + assert phenotype.hidden_coat_color == "cinnamon" + assert phenotype.pattern == "spotted tabby" + assert phenotype.pattern_intensity == pytest.approx(0.8) + assert phenotype.fur_length == 1.0 + assert phenotype.fur_type == "straight" + assert phenotype.ear_type == "folded" + assert phenotype.ear_size == 1.0 + assert phenotype.tail_type == "long" + assert phenotype.polydactyly_status == "polydactyl" + assert phenotype.colorpoint_status == "colorpoint" + assert phenotype.health_risk_score == pytest.approx(0.2) + assert phenotype.cold_tolerance == pytest.approx(0.75) + assert phenotype.heat_tolerance == pytest.approx(0.35) + assert phenotype.forest_camouflage == 1.0 + assert phenotype.desert_camouflage == pytest.approx(1 / 3) + assert phenotype.snow_camouflage == pytest.approx(2 / 3) + assert phenotype.wetland_mobility == pytest.approx(0.07) + assert phenotype.agility == pytest.approx(128 / 255 + 0.08 + 0.035) + assert phenotype.sensory_acuity == pytest.approx(128 / 255 + 0.10) + assert phenotype.intelligence == pytest.approx(0.8) + assert phenotype.circadian_type == "crepuscular" + + +def test_non_agouti_suppresses_visible_tabby_pattern() -> None: + genome = compact_genome(agouti_tabby_pattern=0b0010, pattern_intensity=15) + + phenotype = express_genome(genome) + + assert phenotype.pattern == "solid" + assert phenotype.pattern_intensity == 0.0 + + +def test_polydactyly_improves_wetland_mobility_without_default_health_penalty() -> None: + normal = express_genome(compact_genome(polydactyly=0)) + polydactyl = express_genome(compact_genome(polydactyly=2)) + + assert polydactyl.wetland_mobility > normal.wetland_mobility + assert polydactyl.health_risk_score == normal.health_risk_score + + +def test_phenotype_contains_required_fields() -> None: + phenotype = express_genome(Genome(0)) + + assert tuple(phenotype.traits) == PHENOTYPE_FIELDS + + +def test_all_normalized_phenotype_scores_are_clamped_to_zero_one() -> None: + for raw in (0, 2**128 - 1): + phenotype = express_genome(Genome(raw)) + for field in NUMERIC_PHENOTYPE_FIELDS: + value = getattr(phenotype, field) + assert 0.0 <= value <= 1.0, field