hatchimera / src /buddy_fusion /fallback.py
arkai2025's picture
feat(bench): random parts on 🎲, well-supported hints, Lab scratch entry
d1fa9fc
Raw
History Blame Contribute Delete
12.4 kB
from __future__ import annotations
import random
from .genome import Part, Genome, PART_KINDS, ARCHETYPES, ANIMALS, DEFAULT_PALETTE, clamp_part
# Each archetype has a signature palette so a freshly hatched ancestor already
# reads as that animal (white chick, brown horse, blue hippo, ...). The model /
# fusion can override these.
_ARCHETYPE_PALETTE = {
# Warm baby-chick yellow (not Crossy-Road white) so it reads as our own chick.
"chick": ("#ffd24a", "#ff8a3d", "#e2913b"),
"sheep": ("#f3efe6", "#caa06a", "#3a2f2a"),
"horse": ("#a4663a", "#6e3f1f", "#2b1a0f"),
"dog": ("#d8923f", "#7a4a1e", "#2b1a0f"),
"hippo": ("#3b7ad4", "#2b5aa0", "#ff9bd2"),
"frog": ("#4caf50", "#2e7d32", "#fff7d6"),
"turtle": ("#5a8f3a", "#3f6e2a", "#86c46a"),
"penguin": ("#20203a", "#f7f7f7", "#ff9b18"),
}
_LIMBS = ("leg", "arm", "tail", "wing", "horn", "paw")
_MUTATIONS = (
"a third eye that never blinks", "a tail it keeps tripping over",
"ears that point the wrong way", "one oversized paw",
"a horn that hums when nervous", "wings far too small to fly",
)
_NAMES = ("Mittenmaw", "Croaklet", "Fluffgnaw", "Snorbit", "Wigglethorn", "Pebblesnout", "Gloomsprout")
def _rng(seed: int) -> random.Random:
return random.Random(seed * 2654435761 % 2**32)
def fuse_boxes_fallback(a_boxes, b_boxes, seed: int = 0) -> list:
"""Deterministic box merge used when no model is available (fake runtime) or the
freeform fuse call fails. Takes the lower half of A (body/legs) and the upper
half of B (head/top) by y, so the child visibly combines both. Never empty as
long as at least one parent has boxes."""
a = [b for b in (a_boxes or []) if isinstance(b, dict)]
b = [b for b in (b_boxes or []) if isinstance(b, dict)]
if not a:
return list(b)
if not b:
return list(a)
def mid(boxes):
ys = [int(x.get("y", 0)) for x in boxes]
return (min(ys) + max(ys)) / 2
lower = [dict(x) for x in a if int(x.get("y", 0)) <= mid(a)]
upper = [dict(x) for x in b if int(x.get("y", 0)) > mid(b)]
merged = lower + upper
return merged or (list(a) + list(b))
def fallback_genome(seed: int, archetype: str | None = None) -> Genome:
r = _rng(seed)
archetype = archetype if archetype in ARCHETYPES else r.choice(ARCHETYPES)
palette = list(_ARCHETYPE_PALETTE[archetype])
# Minimal parts: the archetype template draws the body/head/legs itself, so
# parts here only carry optional mutations layered on top.
parts: list[Part] = []
if r.random() < 0.45:
kind = r.choice(_LIMBS)
parts.append(clamp_part(Part(kind, (1, r.randint(2, 3), 1), r.choice(palette),
count=r.choice([1, 2]), tip=r.choice(("none", "paw", "claw")))))
name = r.choice(_NAMES)
return Genome(name=name, palette=palette, parts=parts, archetype=archetype,
traits=[r.choice(_MUTATIONS)], story="A freshly hatched ancestor.")
def _blend_hex(a: str, b: str) -> str:
av, bv = a.lstrip("#"), b.lstrip("#")
mix = [(int(av[i:i+2], 16) + int(bv[i:i+2], 16)) // 2 for i in (0, 2, 4)]
return "#" + "".join(f"{c:02x}" for c in mix)
def fuse_fallback(a: Genome, b: Genome, seed: int) -> Genome:
r = _rng(seed * 31 + 7)
# Chimera: body silhouette from one parent, the rest is recombined parts.
archetype = r.choice([a.archetype, b.archetype])
other = b if archetype == a.archetype else a
parts: list[Part] = []
kinds = []
for kind in PART_KINDS:
pa = next((p for p in a.parts if p.kind == kind), None)
pb = next((p for p in b.parts if p.kind == kind), None)
chosen = r.choice([pa, pb]) if pa and pb else (pa or pb)
if chosen:
parts.append(chosen)
kinds.append(kind)
# graft the OTHER parent's animal head-style on as a head part (visible chimera)
if other.archetype != archetype:
parts.append(clamp_part(Part("head", (3, 3, 3), (other.palette[0] if other.palette else "#cccccc"),
animal=_archetype_animal(other.archetype))))
kinds.append("head")
# blended palette
palette = [_blend_hex(x, y) for x, y in zip(a.palette, b.palette)] or list(a.palette) or list(DEFAULT_PALETTE)
# forced mutation: a brand-new part the inheritance did not pick
spare = [k for k in ("tail", "wing", "horn", "eye") if k not in kinds]
if spare:
parts.append(clamp_part(Part(r.choice(spare), (1, 2, 1), r.choice(palette), count=1)))
mutation = r.choice(_MUTATIONS)
traits = [mutation] + [t for t in (a.traits + b.traits) if t][:1]
name = (a.name[: len(a.name)//2] + b.name[len(b.name)//2:]).title()[:40] or "Splice"
story = f"{a.name} × {b.name}{archetype} body, got {mutation}."
return Genome(name=name, palette=palette[:5], parts=parts, archetype=archetype, traits=traits, story=story)
def _archetype_animal(archetype: str) -> str:
return {"chick": "bird", "penguin": "bird", "dog": "dog", "frog": "frog",
"horse": "dog", "sheep": "dog", "hippo": "frog", "turtle": "frog"}.get(archetype, "none")
# --- Deterministic voice command editor (offline-capable; also the LLM fallback) ---
_ARCH_WORDS = {
"chick": ("chick", "chicken", "雞", "鳥"), "sheep": ("sheep", "羊"),
"horse": ("horse", "pony", "馬"), "dog": ("dog", "puppy", "狗"),
"hippo": ("hippo", "河馬"), "frog": ("frog", "青蛙", "蛙"),
"turtle": ("turtle", "tortoise", "烏龜", "龜"), "penguin": ("penguin", "企鵝"),
}
_PART_WORDS = {
"horn": ("horn", "角"), "wing": ("wing", "翅膀", "翅"), "tail": ("tail", "尾巴", "尾"),
"leg": ("leg", "腿", "腳"), "arm": ("arm", "手", "手臂"), "eye": ("eye", "eyes", "眼睛", "眼"),
"head": ("head", "頭"),
}
_COLOR_WORDS = {
"red": ("red", "紅"), "orange": ("orange", "橙", "橘"), "yellow": ("yellow", "黃"),
"green": ("green", "綠"), "blue": ("blue", "藍"), "purple": ("purple", "紫"),
"pink": ("pink", "粉"), "white": ("white", "白"), "black": ("black", "黑"),
}
_COLOR_HEX = {
"red": "#ef4444", "orange": "#ff8a3d", "yellow": "#facc15", "green": "#4caf50",
"blue": "#3b7ad4", "purple": "#a855f7", "pink": "#ff9bd2", "white": "#f7f7f7", "black": "#20203a",
}
_NUM_WORDS = {"two": 2, "three": 3, "four": 4, "兩": 2, "二": 2, "三": 3, "四": 4, "2": 2, "3": 3, "4": 4}
def _hit(text: str, words: tuple) -> bool:
return any(w in text for w in words)
def _box_bounds(boxes: list[dict]):
"""Rough extents of a box list for placing appended parts. Returns
(top_y, front_z, back_z, side_x) as integers."""
top = 0
front = 0
back = 0
side = 1
for b in boxes:
x = int(b.get("x", 0)); z = int(b.get("z", 0)); y = int(b.get("y", 0))
w = int(b.get("w", 1)); h = int(b.get("h", 1)); d = int(b.get("d", 1))
top = max(top, y + h)
front = max(front, z + (d + 1) // 2)
back = min(back, z - (d + 1) // 2)
side = max(side, abs(x) + (w + 1) // 2)
return top, front, back, side
def edit_boxes_fallback(boxes: list[dict], instruction: str) -> list[dict]:
"""Deterministic, box-aware edit used by the fake runtime and as the freeform
LLM fallback (the box analogue of voice_edit_genome). Recognises recolour
("make it purple") and additive parts ("add two horns", "give it wings",
"a tail", "extra legs", "arms", "eyes"). Unknown input returns the boxes
unchanged. Never strips existing geometry — it only recolours or appends."""
src = [dict(b) for b in (boxes or []) if isinstance(b, dict)]
if not src:
return src
t = (instruction or "").lower()
changed = False
# Recolour the dominant (most-common) colour across the whole creature.
for col, words in _COLOR_WORDS.items():
if _hit(t, words):
counts: dict[str, int] = {}
for b in src:
counts[b.get("color", "")] = counts.get(b.get("color", ""), 0) + 1
dominant = max(counts, key=counts.get) if counts else None
new_hex = _COLOR_HEX[col]
for b in src:
if b.get("color") == dominant:
b["color"] = new_hex
changed = True
break
top, front, back, side = _box_bounds(src)
accent = src[0].get("color", "#caa06f")
count = next((n for w, n in _NUM_WORDS.items() if w in t), 1)
def add(x, y, z, w, h, d, color):
src.append({"x": x, "y": y, "z": z, "w": w, "h": h, "d": d, "color": color})
if _hit(t, _PART_WORDS["horn"]):
for sx in ([-1, 1][:max(1, min(2, count))] if count >= 2 else [0]):
add(sx, top, front - 1, 1, 2, 1, accent)
changed = True
elif _hit(t, _PART_WORDS["wing"]):
for sx in (-(side + 1), side + 1):
add(sx, max(1, top - 2), 0, 1, 2, 2, accent)
changed = True
elif _hit(t, _PART_WORDS["tail"]):
add(0, max(1, top - 3), back - 1, 1, 2, 2, accent)
changed = True
elif _hit(t, _PART_WORDS["leg"]):
for sx in (-(side - 1), side - 1):
add(sx, 0, 0, 1, 2, 1, accent)
changed = True
elif _hit(t, _PART_WORDS["arm"]):
for sx in (-(side + 1), side + 1):
add(sx, max(1, top - 3), 1, 1, 2, 1, accent)
changed = True
elif _hit(t, _PART_WORDS["eye"]):
for sx in (-1, 1):
add(sx, top - 2, front, 1, 1, 1, "#16161f")
changed = True
return src if changed else [dict(b) for b in src]
# Concise full-sentence tweak ideas for the Splice Bench hint bubbles. Used as the
# first-render pool AND as the deterministic fallback when the model is absent
# (fake runtime) or the suggest call fails. EVERY entry must resolve to a real
# catalog part via edit_parser.match_part — clicking a chip ADDS that part (the
# edit path can't reshape the body), so body-shape phrasings like "make it tall"
# are banned: they silently matched the wrong part (tophat / round ears). Keep this
# list in sync with the parts catalog; verify new entries with match_part.
SUGGEST_POOL = [
"Add two curved horns", "Give it a pair of wings", "Add a long swishy tail",
"Add big round eyes", "Give it tall pointy ears", "Add stubby little legs",
"Add spikes down its back", "Give it tiny arms", "Put a crown on its head",
"Give it a turtle shell", "Wrap a cozy scarf around it", "Give it sharp fangs",
"Add cat ears", "Give it a fox tail", "Add a jetpack", "Top it with a party hat",
]
def suggest_tweaks_fallback(boxes: list[dict]) -> list[str]:
"""Deterministic 4 tweak suggestions. Rotated by the creature's box count so
the set visibly changes as the form grows (gives the fake runtime a sense of
'the hints react to the creature' without a model)."""
n = len(boxes or [])
pool = SUGGEST_POOL
start = n % len(pool)
return [pool[(start + i) % len(pool)] for i in range(4)]
def voice_edit_genome(genome: Genome, transcript: str) -> Genome:
"""Apply a spoken instruction to a genome deterministically (no model).
Recognises: change archetype ("turn into a frog"), add a part
("add two horns"), recolour ("make it purple"). Also matches the CJK
synonyms held in the keyword tables above. Unknown input leaves the genome
unchanged. Used by the fake runtime and as the real runtime's fallback so
voice always does *something* offline.
"""
t = (transcript or "").lower()
archetype = genome.archetype
for arch, words in _ARCH_WORDS.items():
if _hit(t, words):
archetype = arch
break
palette = list(genome.palette)
for col, words in _COLOR_WORDS.items():
if _hit(t, words):
palette = [_COLOR_HEX[col]] + palette[1:]
break
parts = list(genome.parts)
count = next((n for w, n in _NUM_WORDS.items() if w in t), 1)
for kind, words in _PART_WORDS.items():
if _hit(t, words):
parts.append(clamp_part(Part(kind, (1, 2, 1), palette[0] if palette else "#cccccc", count=count)))
break
if archetype == genome.archetype and palette == list(genome.palette) and parts == list(genome.parts):
return genome # nothing recognised
return Genome(name=genome.name, palette=palette, parts=parts, archetype=archetype,
traits=genome.traits, story=genome.story)