"""Typed program synthesis — init, crossover, mutation — full grammar. Termination guarantee: every type closes at the depth floor. - Matrix → MatrixTerminal - Vector → Reduce(MatrixTerminal, agg) - Scalar → Associate(Reduce(MatrixTerminal, "mean"), target, "spearman") - Model → (not a leaf — only via FitApply which itself is Vector) The richer operators (Effect, Split, FitApply, Search) are introduced at configurable rates so the population genuinely contains programs that use them. ``Search`` is gated OFF by default (rate 0) — it's the recursive operator and the prompt explicitly says "flag it and gate behind a toggle" until we're satisfied with the cost. """ from __future__ import annotations import random from typing import Sequence from engine_v2.nodes import ( Associate, Combine, Effect, FeatureSet, FitApply, MatrixTerminal, Node, Reduce, Search, Select, Split, ) from engine_v2.types import ( AGGS, ASSOC_KINDS, ASSOC_TARGETS, DEFAULT_MAX_DEPTH, DEFAULT_MAX_GENES_PER_SET, MIN_GENES_PER_SET, OPS, PREDICATE_KINDS, SEARCH_MAX_K, TType, ) # Init / mutation rates for the richer operators. Keep them modest: # enough that the population genuinely contains them, low enough that # the search space stays tractable. DEFAULT_RATES = { "split": 0.10, # Vector slot picks Split instead of Reduce/Combine. "fitapply": 0.10, # Vector slot picks FitApply. "effect": 0.40, # Scalar slot picks Effect (vs Associate). # Matrix slot picks Search instead of Select/M. ON at a modest # rate so Search appears in the population without dominating — # every Search runs an inner gene ranking, which is the # heaviest per-node operation. Caps stay (k ≤ 4 selected, # ≤ 200 candidate columns) so each Search's cost is bounded. "search": 0.05, } # --------------------------------------------------------------------------- # Feature-set sampling # --------------------------------------------------------------------------- def _sample_feature_set( rng: random.Random, pool: Sequence[str], *, max_genes_per_set: int, ) -> FeatureSet: if not pool: raise ValueError("synth: empty gene pool") upper = min(max_genes_per_set, len(pool)) size = rng.randint(MIN_GENES_PER_SET, max(MIN_GENES_PER_SET, upper)) return FeatureSet(rng.sample(list(pool), size)) # --------------------------------------------------------------------------- # Grow — typed recursive expansion # --------------------------------------------------------------------------- def _grow_matrix(rng: random.Random, pool, depth: int, *, mgps: int, full: bool, rates: dict, objective_target: str) -> Node: """Generate a Matrix-typed subtree with depth <= ``depth``. Every Matrix leaf is wrapped in a ``Select`` — a bare ``MatrixTerminal()`` would let a program score on the global expression vector with zero gene choice (a "whole-matrix mean" detector that conflates detection with gene discovery). The rule applies to ALL objectives: MSI/TMB/HPV/unsup. ``Search`` is gated off under unsupervised (the rate is already 0 by default but the gate makes it explicit). """ is_unsup = objective_target == "none" if depth < 1: depth = 1 if depth == 1: return Select( MatrixTerminal(), _sample_feature_set(rng, pool, max_genes_per_set=mgps), ) # Matrix options: Select(Matrix, FeatureSet) | Search(Matrix, k). # The bare-MatrixTerminal fall-through is gone — it would have # produced a global-mean detector that scores well via bulk # expression rather than gene choice. r = rng.random() if not is_unsup and rates["search"] > 0 and r < rates["search"]: inner = _grow_matrix( rng, pool, depth - 1, mgps=mgps, full=full, rates=rates, objective_target=objective_target, ) return Search(inner, k=rng.randint(2, SEARCH_MAX_K)) inner = _grow_matrix( rng, pool, depth - 1, mgps=mgps, full=full, rates=rates, objective_target=objective_target, ) return Select(inner, _sample_feature_set(rng, pool, max_genes_per_set=mgps)) def _grow_vector(rng: random.Random, pool, depth: int, *, mgps: int, full: bool, rates: dict, objective_target: str, in_split: bool = False) -> Node: """Generate a Vector-typed subtree with depth <= ``depth``. Operators that score against a label (FitApply) are constructed with ``target = objective_target`` — the engine never picks its own target. See ``_check_target_binding`` in ``fitness.py``. """ is_unsup = objective_target == "none" if depth < 2: depth = 2 if depth == 2: # Depth-floor: every objective wraps in Select so no Reduce # ever sits on a bare MatrixTerminal (would be a global-mean # shortcut that detects without choosing genes). leaf: Node = Select( MatrixTerminal(), _sample_feature_set(rng, pool, max_genes_per_set=mgps), ) return Reduce(leaf, rng.choice(AGGS)) r = rng.random() # Split has ONE-level rule — never inside another Split. if not in_split and r < rates["split"]: inner = _grow_vector( rng, pool, depth - 1, mgps=mgps, full=full, rates=rates, objective_target=objective_target, in_split=True, ) # Under unsup the structure must come from the gene-based score, # not the known clinical axis (stage_late) — force "score". predicate = ( "score" if is_unsup else rng.choice(PREDICATE_KINDS) ) return Split(inner, predicate=predicate) if r < rates["split"] + rates["fitapply"]: inner = _grow_vector( rng, pool, depth - 1, mgps=mgps, full=full, rates=rates, objective_target=objective_target, in_split=in_split, ) # FitApply's target is BOUND to the active objective, never picked. return FitApply(inner, target=objective_target) if full or rng.random() < 0.5: return Combine( _grow_vector(rng, pool, depth - 1, mgps=mgps, full=full, rates=rates, objective_target=objective_target, in_split=in_split), _grow_vector(rng, pool, depth - 1, mgps=mgps, full=full, rates=rates, objective_target=objective_target, in_split=in_split), rng.choice(OPS), ) return Reduce( _grow_matrix( rng, pool, depth - 1, mgps=mgps, full=full, rates=rates, objective_target=objective_target, ), rng.choice(AGGS), ) def _grow_scalar(rng: random.Random, pool, depth: int, *, mgps: int, full: bool, rates: dict, objective_target: str) -> Node: """Generate a Scalar-typed subtree (Associate or Effect). The target is BOUND to the active objective — the engine picks the kind (pearson / spearman) and whether to use Effect (adjusted) or Associate (raw), never the target. """ if depth < 3: depth = 3 inner_depth = max(2, depth - 1) inner = _grow_vector(rng, pool, inner_depth, mgps=mgps, full=full, rates=rates, objective_target=objective_target) kind = rng.choice(ASSOC_KINDS) if rng.random() < rates["effect"]: return Effect(inner, target=objective_target, kind=kind) return Associate(inner, target=objective_target, kind=kind) def random_program( rng: random.Random, pool: Sequence[str], *, objective_target: str, max_depth: int = DEFAULT_MAX_DEPTH, max_genes_per_set: int = DEFAULT_MAX_GENES_PER_SET, full: bool | None = None, rates: dict | None = None, return_type: TType = TType.VECTOR, ) -> Node: """A random tree of the requested return type. ``return_type=Vector`` is the engine's normal root — but the GP also explores Scalar-rooted programs (Associate / Effect) since A3 allows Scalar outputs. The objective's target is bound; the engine never chooses it. """ rates = rates or DEFAULT_RATES if full is None: full = rng.random() < 0.5 if return_type is TType.SCALAR: return _grow_scalar(rng, pool, max_depth, mgps=max_genes_per_set, full=full, rates=rates, objective_target=objective_target) return _grow_vector(rng, pool, max_depth, mgps=max_genes_per_set, full=full, rates=rates, objective_target=objective_target) def ramped_population( rng: random.Random, pool: Sequence[str], *, n: int, objective_target: str, max_depth: int = DEFAULT_MAX_DEPTH, max_genes_per_set: int = DEFAULT_MAX_GENES_PER_SET, rates: dict | None = None, scalar_share: float = 0.20, ) -> list[Node]: """Ramped half-and-half across depths, with ``scalar_share`` of the population rooted at a Scalar (Associate / Effect).""" out: list[Node] = [] depths = list(range(2, max_depth + 1)) or [2] for i in range(n): d = depths[i % len(depths)] full = (i // len(depths)) % 2 == 0 rt = TType.SCALAR if rng.random() < scalar_share else TType.VECTOR out.append(random_program( rng, pool, objective_target=objective_target, max_depth=d, max_genes_per_set=max_genes_per_set, full=full, rates=rates, return_type=rt, )) return out # --------------------------------------------------------------------------- # Crossover — swap subtrees of matching return type # --------------------------------------------------------------------------- def _enumerate(parent: Node, slot: TType) -> list[tuple[Node, "_Cursor"]]: targets: list[tuple[Node, _Cursor]] = [] if parent.ttype is slot: targets.append((parent, _Cursor.root(parent, slot))) _walk_for_replacement(parent, slot, targets) return targets def _walk_for_replacement(parent: Node, slot: TType, out: list[tuple[Node, "_Cursor"]]) -> None: # Single-child carriers one_child_attrs = { Select: "matrix", Reduce: "matrix", Split: "inner", Associate: "inner", Effect: "inner", FitApply: "inner", Search: "matrix", } for cls, attr in one_child_attrs.items(): if isinstance(parent, cls): child: Node = getattr(parent, attr) if child.ttype is slot: out.append((child, _Cursor.field(parent, attr, slot))) _walk_for_replacement(child, slot, out) return if isinstance(parent, Combine): for attr in ("left", "right"): ch = getattr(parent, attr) if ch.ttype is slot: out.append((ch, _Cursor.field(parent, attr, slot))) _walk_for_replacement(ch, slot, out) return # MatrixTerminal has no children. class _Cursor: def __init__(self, applier, slot): self._apply = applier self.slot = slot def apply(self, new_node: Node) -> Node: return self._apply(new_node) @staticmethod def root(root: Node, slot: TType) -> "_Cursor": def apply(new_node: Node) -> Node: return new_node return _Cursor(apply, slot) @staticmethod def field(parent: Node, name: str, slot: TType) -> "_Cursor": def apply(new_node: Node) -> Node: setattr(parent, name, new_node) return new_node return _Cursor(apply, slot) def crossover( rng: random.Random, p1: Node, p2: Node, *, max_depth: int = DEFAULT_MAX_DEPTH, max_nodes: int = 64, ) -> Node: from copy import deepcopy child = deepcopy(p1) candidate_slots = ( _enumerate(child, TType.VECTOR) + _enumerate(child, TType.MATRIX) + _enumerate(child, TType.SCALAR) ) options: list[tuple[TType, _Cursor, Node]] = [] seen: set[int] = set() for sub, cur in candidate_slots: if id(sub) in seen: continue seen.add(id(sub)) options.append((sub.ttype, cur, sub)) rng.shuffle(options) for slot_t, cur, _sub in options: donor_slots = _enumerate(p2, slot_t) if not donor_slots: continue donor_sub, _ = rng.choice(donor_slots) donor_copy = deepcopy(donor_sub) replaced = cur.apply(donor_copy) if cur.slot is child.ttype and id(replaced) is id(donor_copy): new_root = donor_copy else: new_root = child if new_root.depth() <= max_depth and new_root.node_count() <= max_nodes: return new_root child = deepcopy(p1) return deepcopy(p1) # --------------------------------------------------------------------------- # Mutation # --------------------------------------------------------------------------- def mutate( rng: random.Random, program: Node, pool: Sequence[str], *, objective_target: str, p_mut: float = 0.7, max_depth: int = DEFAULT_MAX_DEPTH, max_genes_per_set: int = DEFAULT_MAX_GENES_PER_SET, max_nodes: int = 64, rates: dict | None = None, ) -> Node: """Subtree + point mutation. The objective's target is BOUND — it is never a point-mutation spot, and freshly-grown subtrees inherit the same binding.""" from copy import deepcopy if rng.random() > p_mut: return program rates = rates or DEFAULT_RATES program = deepcopy(program) # ----- Subtree mutation ----- if rng.random() < 0.5: candidates = ( _enumerate(program, TType.VECTOR) + _enumerate(program, TType.MATRIX) + _enumerate(program, TType.SCALAR) ) seen: set[int] = set() unique = [] for sub, cur in candidates: if id(sub) in seen: continue seen.add(id(sub)) unique.append((sub, cur)) if not unique: return program original = deepcopy(program) sub, cur = rng.choice(unique) sub_budget = 3 if cur.slot is TType.VECTOR else 2 if cur.slot is TType.SCALAR: new_sub = _grow_scalar( rng, pool, 3, mgps=max_genes_per_set, full=False, rates=rates, objective_target=objective_target, ) elif cur.slot is TType.MATRIX: new_sub = _grow_matrix( rng, pool, sub_budget, mgps=max_genes_per_set, full=False, rates=rates, objective_target=objective_target, ) else: new_sub = _grow_vector( rng, pool, sub_budget, mgps=max_genes_per_set, full=False, rates=rates, objective_target=objective_target, ) new_root = cur.apply(new_sub) if cur.slot is program.ttype and id(new_root) is id(new_sub): program = new_sub if program.depth() > max_depth or program.node_count() > max_nodes: return original return program # ----- Point mutation ----- # NOTE: the target field on Associate / Effect / FitApply is NOT a # point-mutation spot — the engine must not flip a program's target # mid-run. spots: list[tuple[str, object]] = [] for n in program.walk(): if isinstance(n, Reduce): spots.append(("agg", n)) elif isinstance(n, Combine): spots.append(("op", n)) elif isinstance(n, Select): spots.append(("gene", n.features)) elif isinstance(n, Associate) or isinstance(n, Effect): spots.append(("scalar_kind", n)) elif isinstance(n, Split): # Under unsupervised the only legal predicate is "score" — # don't emit a mutation spot that could flip it to a clinical # variable (stage_late). if objective_target != "none": spots.append(("predicate", n)) elif isinstance(n, Search): spots.append(("search_k", n)) if not spots: return program kind, target = rng.choice(spots) if kind == "agg": target.agg = rng.choice( # type: ignore[attr-defined] [a for a in AGGS if a != target.agg] or list(AGGS) ) elif kind == "op": target.op = rng.choice( # type: ignore[attr-defined] [o for o in OPS if o != target.op] or list(OPS) ) elif kind == "gene": fs = target # FeatureSet if not pool or not fs.ids: # type: ignore[attr-defined] return program idx = rng.randrange(len(fs.ids)) # type: ignore[attr-defined] replacements = [g for g in pool if g not in fs.ids] # type: ignore[attr-defined] if not replacements: return program fs.ids[idx] = rng.choice(replacements) # type: ignore[attr-defined] elif kind == "scalar_kind": # Flip only the correlation kind (pearson/spearman) — never the # target. The target is the objective's, by construction. target.kind = rng.choice( # type: ignore[attr-defined] [k for k in ASSOC_KINDS if k != target.kind] or list(ASSOC_KINDS) ) elif kind == "predicate": target.predicate = rng.choice( # type: ignore[attr-defined] [p for p in PREDICATE_KINDS if p != target.predicate] or list(PREDICATE_KINDS) ) elif kind == "search_k": target.k = max(2, min(SEARCH_MAX_K, target.k + rng.choice([-1, 1]))) # type: ignore[attr-defined] return program