perforatedai.configure_perforatedai

   1import sys
   2import shutil
   3import json
   4import os
   5import select
   6import re
   7
   8# POSIX-only. The module must still import on native Windows (perforate_model
   9# imports it unconditionally); the interactive TUI just cannot run there.
  10try:
  11    import termios
  12    import tty
  13except ImportError:  # pragma: no cover - exercised only on Windows
  14    termios = None
  15    tty = None
  16
  17from perforatedai import globals_perforatedai as GPA
  18
  19try:
  20    import torch
  21except Exception:  # pragma: no cover - torch is a hard dependency at runtime
  22    torch = None
  23
  24
  25# ---------------------------------------------------------------------------
  26# Selection list plumbing
  27# ---------------------------------------------------------------------------
  28def dedupe_list(values):
  29    """Return a list with duplicates removed while preserving order."""
  30    seen = set()
  31    unique = []
  32    for value in values:
  33        if value in seen:
  34            continue
  35        seen.add(value)
  36        unique.append(value)
  37    return unique
  38
  39
  40def normalize_selection_conflicts():
  41    """Normalize id and name selection lists and remove direct conflicts."""
  42    ids_perforate = dedupe_list(GPA.pc.get_module_ids_to_perforate())
  43    ids_track = dedupe_list(GPA.pc.get_module_ids_to_track())
  44    names_perforate = dedupe_list(GPA.pc.get_module_names_to_perforate())
  45    names_track = dedupe_list(GPA.pc.get_module_names_to_track())
  46
  47    overlap_ids = set(ids_perforate) & set(ids_track)
  48    overlap_names = set(names_perforate) & set(names_track)
  49
  50    if overlap_ids:
  51        ids_track = [value for value in ids_track if value not in overlap_ids]
  52    if overlap_names:
  53        names_track = [value for value in names_track if value not in overlap_names]
  54
  55    GPA.pc.set_module_ids_to_perforate(ids_perforate)
  56    GPA.pc.set_module_ids_to_track(ids_track)
  57    GPA.pc.set_module_names_to_perforate(names_perforate)
  58    GPA.pc.set_module_names_to_track(names_track)
  59
  60
  61def get_module_entries(model):
  62    """Collect modules for interactive navigation.
  63
  64    Each entry also carries ``is_replaced_root`` (this module's class is in
  65    ``modules_to_replace`` and will be restructured before training) and
  66    ``in_replaced`` / ``replaced_root`` (this module lives under such a module,
  67    so its id will not exist at training time).
  68    """
  69    entries = []
  70    for name, module in model.named_modules(remove_duplicate=False):
  71        if name == "":
  72            continue
  73        module_id = "." + name
  74        depth = name.count(".")
  75        entries.append(
  76            {
  77                "id": module_id,
  78                "name": name,
  79                "type_name": type(module).__name__,
  80                "module": module,
  81                "depth": depth,
  82                "direct_param_count": sum(
  83                    param.numel()
  84                    for _param_name, param in module.named_parameters(recurse=False)
  85                ),
  86                "tree_param_count": sum(
  87                    param.numel()
  88                    for _param_name, param in module.named_parameters(recurse=True)
  89                ),
  90            }
  91        )
  92
  93    replace_classes = tuple(GPA.pc.get_modules_to_replace())
  94    replaced_root_ids = []
  95    for entry in entries:
  96        entry["is_replaced_root"] = (
  97            len(replace_classes) > 0
  98            and isinstance(entry["module"], replace_classes)
  99        )
 100        if entry["is_replaced_root"]:
 101            replaced_root_ids.append(entry["id"])
 102
 103    for entry in entries:
 104        entry["replaced_root"] = None
 105        entry["in_replaced"] = False
 106        for root_id in replaced_root_ids:
 107            if entry["id"].startswith(root_id + "."):
 108                entry["replaced_root"] = root_id
 109                entry["in_replaced"] = True
 110                break
 111
 112    return entries
 113
 114
 115def get_id_mode(module_id):
 116    """Get explicit id-based mode for a module id ("perforated"/"tracked"/None)."""
 117    if module_id in GPA.pc.get_module_ids_to_perforate():
 118        return "perforated"
 119    if module_id in GPA.pc.get_module_ids_to_track():
 120        return "tracked"
 121    return None
 122
 123
 124def get_name_mode(module_type_name):
 125    """Get type-name-based mode for a module type ("perforated"/"tracked"/None)."""
 126    if module_type_name in GPA.pc.get_module_names_to_perforate():
 127        return "perforated"
 128    if module_type_name in GPA.pc.get_module_names_to_track():
 129        return "tracked"
 130    return None
 131
 132
 133def get_parent_module_id(module_id):
 134    """Return the direct parent module id for a dot-id module path."""
 135    parts = module_id.split(".")
 136    if len(parts) <= 2:
 137        return None
 138    return "." + ".".join(parts[1:-1])
 139
 140
 141def resolve_entry_modes(entries):
 142    """Resolve every entry's mode, source, and any overridden-by-ancestor state.
 143
 144    Returns a map of module id to a dict with:
 145      - ``eff``:    "perforated" / "tracked" / None
 146      - ``source``: "id" / "type" / "inherited" / None
 147      - ``origin_id``: the id the mode was set on (for inherited rows)
 148      - ``overridden``: {"mode", "source"} when a direct setting here is ignored
 149    """
 150    resolved = {}
 151    by_id = {entry["id"]: entry for entry in entries}
 152    for entry in entries:
 153        module_id = entry["id"]
 154        parent_id = get_parent_module_id(module_id)
 155        parent = resolved.get(parent_id) if parent_id in by_id else None
 156
 157        own_id_mode = get_id_mode(module_id)
 158        own_type_mode = get_name_mode(entry["type_name"])
 159        own_mode = own_id_mode or own_type_mode
 160        own_source = "id" if own_id_mode else ("type" if own_type_mode else None)
 161
 162        record = {
 163            "eff": None,
 164            "source": None,
 165            "origin_id": module_id,
 166            "overridden": None,
 167        }
 168        if parent is not None and parent["eff"] is not None:
 169            record["eff"] = parent["eff"]
 170            record["source"] = "inherited"
 171            record["origin_id"] = parent["origin_id"]
 172            if own_mode is not None:
 173                record["overridden"] = {"mode": own_mode, "source": own_source}
 174        elif own_mode is not None:
 175            record["eff"] = own_mode
 176            record["source"] = own_source
 177            record["origin_id"] = module_id
 178        resolved[module_id] = record
 179    return resolved
 180
 181
 182# ---------------------------------------------------------------------------
 183# Formatting helpers
 184# ---------------------------------------------------------------------------
 185COLOR_PERFORATE = "00A5A5"
 186COLOR_TRACK = "8A95A0"
 187COLOR_ATTENTION = "FD4D00"
 188COLOR_ACCENT = "D9A441"
 189
 190
 191def get_color_hex_for_mode(mode):
 192    """Map a resolved mode to its color hex value."""
 193    if mode == "perforated":
 194        return COLOR_PERFORATE
 195    if mode == "tracked":
 196        return COLOR_TRACK
 197    return "000000"
 198
 199
 200def format_human_count(value):
 201    """Format integer counts into compact human-readable units (K/M/B)."""
 202    if value < 1000:
 203        return str(value)
 204    if value < 1000000:
 205        scaled = value / 1000.0
 206        text = f"{scaled:.1f}"
 207        return (text[:-2] if text.endswith(".0") else text) + "K"
 208    if value < 1000000000:
 209        scaled = value / 1000000.0
 210        text = f"{scaled:.1f}"
 211        return (text[:-2] if text.endswith(".0") else text) + "M"
 212    scaled = value / 1000000000.0
 213    text = f"{scaled:.1f}"
 214    return (text[:-2] if text.endswith(".0") else text) + "B"
 215
 216
 217def _ansi(text, code):
 218    return f"\x1b[{code}m{text}\x1b[0m"
 219
 220
 221def color_text(text, hex_color):
 222    """Render text in an ANSI truecolor foreground."""
 223    r = int(hex_color[0:2], 16)
 224    g = int(hex_color[2:4], 16)
 225    b = int(hex_color[4:6], 16)
 226    return f"\x1b[38;2;{r};{g};{b}m{text}\x1b[0m"
 227
 228
 229def make_color_square(hex_color):
 230    """Create one colored block character using ANSI truecolor."""
 231    return color_text("█", hex_color)
 232
 233
 234def dim(text):
 235    """Render text dimmed."""
 236    return _ansi(text, "90")
 237
 238
 239def make_inverted_text(text):
 240    """Render text with inverted foreground/background colors."""
 241    return f"\x1b[7m{text}\x1b[0m"
 242
 243
 244ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m")
 245
 246
 247def strip_ansi(text):
 248    """Remove ANSI color/style escape codes for width calculation."""
 249    return ANSI_ESCAPE_RE.sub("", text)
 250
 251
 252def get_visual_line_count(lines, terminal_columns):
 253    """Count rendered terminal rows, including wrapped long lines."""
 254    if terminal_columns < 1:
 255        return len(lines)
 256    total = 0
 257    for line in lines:
 258        plain = strip_ansi(line)
 259        if len(plain) == 0:
 260            total += 1
 261            continue
 262        total += (len(plain) + terminal_columns - 1) // terminal_columns
 263    return total
 264
 265
 266def wrap_ansi_line_on_words(line, terminal_columns):
 267    """Wrap one ANSI-colored line on word boundaries for terminal display."""
 268    if terminal_columns < 1:
 269        return [line]
 270
 271    plain = strip_ansi(line)
 272    if len(plain) <= terminal_columns:
 273        return [line]
 274
 275    visible_to_raw = []
 276    raw_index = 0
 277    while raw_index < len(line):
 278        if line[raw_index] == "\x1b":
 279            match = ANSI_ESCAPE_RE.match(line, raw_index)
 280            if match is not None:
 281                raw_index = match.end()
 282                continue
 283        visible_to_raw.append(raw_index)
 284        raw_index += 1
 285
 286    if len(visible_to_raw) == 0:
 287        return [line]
 288
 289    wrapped = []
 290    start = 0
 291    visible_len = len(plain)
 292    while start < visible_len:
 293        while start < visible_len and plain[start] == " ":
 294            start += 1
 295        if start >= visible_len:
 296            break
 297
 298        max_end = min(visible_len, start + terminal_columns)
 299        if max_end == visible_len:
 300            end = visible_len
 301        else:
 302            split_at = plain.rfind(" ", start, max_end)
 303            end = split_at if split_at > start else max_end
 304
 305        raw_start = visible_to_raw[start]
 306        raw_end = len(line) if end >= visible_len else visible_to_raw[end]
 307        wrapped.append(line[raw_start:raw_end].rstrip())
 308
 309        if end < visible_len and plain[end] == " ":
 310            start = end + 1
 311        else:
 312            start = end
 313
 314    return wrapped if wrapped else [line]
 315
 316
 317def wrap_screen_text_for_terminal(screen_text):
 318    """Wrap full screen text to terminal width without mid-word breaks."""
 319    terminal_columns = shutil.get_terminal_size(fallback=(120, 40)).columns
 320    wrapped_lines = []
 321    for raw_line in screen_text.split("\n"):
 322        wrapped_lines.extend(wrap_ansi_line_on_words(raw_line, terminal_columns))
 323    return "\n".join(wrapped_lines)
 324
 325
 326def render_text_with_cursor(text, cursor_index):
 327    """Render text with a CLI-style inverted-character cursor."""
 328    if cursor_index < 0:
 329        cursor_index = 0
 330    if cursor_index > len(text):
 331        cursor_index = len(text)
 332    if cursor_index == len(text):
 333        return text + make_inverted_text(" ")
 334    current_char = text[cursor_index]
 335    return (
 336        text[:cursor_index]
 337        + make_inverted_text(current_char)
 338        + text[cursor_index + 1 :]
 339    )
 340
 341
 342# ---------------------------------------------------------------------------
 343# Key token predicates
 344# ---------------------------------------------------------------------------
 345def is_up_key(key):
 346    if key in ("k",):
 347        return True
 348    if not key.startswith("\x1b"):
 349        return False
 350    return key in ("\x1b[A", "\x1bOA") or (key.startswith("\x1b[") and key.endswith("A"))
 351
 352
 353def is_down_key(key):
 354    if key in ("j",):
 355        return True
 356    if not key.startswith("\x1b"):
 357        return False
 358    return key in ("\x1b[B", "\x1bOB") or (key.startswith("\x1b[") and key.endswith("B"))
 359
 360
 361def is_left_key(key):
 362    if not key.startswith("\x1b"):
 363        return False
 364    return key in ("\x1b[D", "\x1bOD") or (key.startswith("\x1b[") and key.endswith("D"))
 365
 366
 367def is_right_key(key):
 368    if not key.startswith("\x1b"):
 369        return False
 370    return key in ("\x1b[C", "\x1bOC") or (key.startswith("\x1b[") and key.endswith("C"))
 371
 372
 373def is_page_up_key(key):
 374    return key == "\x1b[5~" or (key.startswith("\x1b[") and "[5" in key and key.endswith("~"))
 375
 376
 377def is_page_down_key(key):
 378    return key == "\x1b[6~" or (key.startswith("\x1b[") and "[6" in key and key.endswith("~"))
 379
 380
 381def is_delete_key(key):
 382    return key == "\x1b[3~" or (key.startswith("\x1b[") and "[3" in key and key.endswith("~"))
 383
 384
 385def is_enter_key(key):
 386    return key in ("\r", "\n")
 387
 388
 389def is_tab_key(key):
 390    return key == "\t"
 391
 392
 393def is_select_key(key):
 394    """Space/Enter — used only inside the inline value editors."""
 395    return key in (" ", "\r", "\n")
 396
 397
 398# ---------------------------------------------------------------------------
 399# Mode setters
 400# ---------------------------------------------------------------------------
 401def set_module_id_mode(module_id, mode):
 402    """Set (or toggle off) an id-based mode, enforcing id-list exclusivity."""
 403    ids_perforate = dedupe_list(GPA.pc.get_module_ids_to_perforate())
 404    ids_track = dedupe_list(GPA.pc.get_module_ids_to_track())
 405
 406    if mode == "perforated":
 407        if module_id in ids_perforate:
 408            ids_perforate = [value for value in ids_perforate if value != module_id]
 409        else:
 410            ids_perforate.append(module_id)
 411            ids_track = [value for value in ids_track if value != module_id]
 412    elif mode == "tracked":
 413        if module_id in ids_track:
 414            ids_track = [value for value in ids_track if value != module_id]
 415        else:
 416            ids_track.append(module_id)
 417            ids_perforate = [value for value in ids_perforate if value != module_id]
 418
 419    GPA.pc.set_module_ids_to_perforate(ids_perforate)
 420    GPA.pc.set_module_ids_to_track(ids_track)
 421
 422
 423def set_module_name_mode(module_type_name, mode):
 424    """Set (or toggle off) a type-name-based mode, enforcing name-list exclusivity."""
 425    names_perforate = dedupe_list(GPA.pc.get_module_names_to_perforate())
 426    names_track = dedupe_list(GPA.pc.get_module_names_to_track())
 427
 428    if mode == "perforated":
 429        if module_type_name in names_perforate:
 430            names_perforate = [v for v in names_perforate if v != module_type_name]
 431        else:
 432            names_perforate.append(module_type_name)
 433            names_track = [v for v in names_track if v != module_type_name]
 434    elif mode == "tracked":
 435        if module_type_name in names_track:
 436            names_track = [v for v in names_track if v != module_type_name]
 437        else:
 438            names_track.append(module_type_name)
 439            names_perforate = [v for v in names_perforate if v != module_type_name]
 440
 441    GPA.pc.set_module_names_to_perforate(names_perforate)
 442    GPA.pc.set_module_names_to_track(names_track)
 443
 444
 445def clear_module_mode(entry):
 446    """Clear a direct id rule on this module; if none, clear its type rule.
 447
 448    Returns a short human message describing what was cleared.
 449    """
 450    module_id = entry["id"]
 451    type_name = entry["type_name"]
 452
 453    ids_perforate = [v for v in GPA.pc.get_module_ids_to_perforate() if v != module_id]
 454    ids_track = [v for v in GPA.pc.get_module_ids_to_track() if v != module_id]
 455    if (
 456        module_id in GPA.pc.get_module_ids_to_perforate()
 457        or module_id in GPA.pc.get_module_ids_to_track()
 458    ):
 459        GPA.pc.set_module_ids_to_perforate(ids_perforate)
 460        GPA.pc.set_module_ids_to_track(ids_track)
 461        return f"cleared {module_id}"
 462
 463    if (
 464        type_name in GPA.pc.get_module_names_to_perforate()
 465        or type_name in GPA.pc.get_module_names_to_track()
 466    ):
 467        GPA.pc.set_module_names_to_perforate(
 468            [v for v in GPA.pc.get_module_names_to_perforate() if v != type_name]
 469        )
 470        GPA.pc.set_module_names_to_track(
 471            [v for v in GPA.pc.get_module_names_to_track() if v != type_name]
 472        )
 473        return f"cleared the {type_name} type rule"
 474
 475    return "nothing to clear on this module"
 476
 477
 478# ---------------------------------------------------------------------------
 479# Type rules / budget / attention
 480# ---------------------------------------------------------------------------
 481def type_rule_entries(entries):
 482    """Active type-name rules as (type_name, mode, live count) tuples."""
 483    counts = {}
 484    for entry in entries:
 485        if entry["in_replaced"]:
 486            continue
 487        counts[entry["type_name"]] = counts.get(entry["type_name"], 0) + 1
 488
 489    rules = []
 490    for type_name in sorted(GPA.pc.get_module_names_to_perforate()):
 491        rules.append((type_name, "perforated", counts.get(type_name, 0)))
 492    for type_name in sorted(GPA.pc.get_module_names_to_track()):
 493        rules.append((type_name, "tracked", counts.get(type_name, 0)))
 494    return rules
 495
 496
 497def unset_module_entries(entries, resolved):
 498    """Entries that own parameters but have no resolved mode (need attention)."""
 499    unset = []
 500    for entry in entries:
 501        if entry["in_replaced"] or entry["is_replaced_root"]:
 502            continue
 503        if entry["direct_param_count"] <= 0:
 504            continue
 505        if resolved[entry["id"]]["eff"] is None:
 506            unset.append(entry)
 507    return unset
 508
 509
 510def build_budget_line(entries, resolved):
 511    """The perforation-budget line: parameter cost of the current selection."""
 512    added = 0
 513    total_model_params = 0
 514    seen_added = set()
 515    seen_total = set()
 516
 517    for entry in entries:
 518        for _name, parameter in entry["module"].named_parameters(recurse=False):
 519            pid = id(parameter)
 520            if pid not in seen_total:
 521                seen_total.add(pid)
 522                total_model_params += parameter.numel()
 523
 524        if resolved[entry["id"]]["eff"] == "perforated":
 525            for _name, parameter in entry["module"].named_parameters(recurse=False):
 526                pid = id(parameter)
 527                if pid not in seen_added:
 528                    seen_added.add(pid)
 529                    added += parameter.numel()
 530
 531    pct = (100.0 * added / total_model_params) if total_model_params else 0.0
 532    return (
 533        f"Perforation budget:  +{format_human_count(added)} params per dendrite cycle"
 534        f"  ·  model {format_human_count(total_model_params)}"
 535        f"  (+{pct:.1f}% / cycle)"
 536    )
 537
 538
 539# ---------------------------------------------------------------------------
 540# Run settings metadata
 541# ---------------------------------------------------------------------------
 542RUNTIME_ONLY_CONFIG_KEYS = {"config_file"}
 543
 544DESCRIPTION_FILE_NAME = "configuration_descriptions.json"
 545DESCRIPTION_CACHE = None
 546
 547LABEL_IMPACTFUL = "impactful"
 548LABEL_SUPPORTING = "supporting"
 549
 550
 551def _normalize_label(label_text):
 552    """Normalize a label to one of the two tiers: impactful / supporting."""
 553    if not isinstance(label_text, str):
 554        return LABEL_SUPPORTING
 555    compact = " ".join(label_text.strip().split()).lower()
 556    if compact in ("impactful", "impactful hyperparameters"):
 557        return LABEL_IMPACTFUL
 558    return LABEL_SUPPORTING
 559
 560
 561def load_description_data():
 562    """Load bucket and setting metadata from local JSON."""
 563    global DESCRIPTION_CACHE
 564    if DESCRIPTION_CACHE is not None:
 565        return DESCRIPTION_CACHE
 566
 567    description_file = os.path.join(os.path.dirname(__file__), DESCRIPTION_FILE_NAME)
 568    payload = {}
 569    try:
 570        with open(description_file, "r") as file_handle:
 571            payload = json.load(file_handle)
 572    except Exception:
 573        payload = {}
 574
 575    bucket_entries = payload.get("buckets", {})
 576    bucket_descriptions = {}
 577    setting_descriptions = {}
 578    setting_labels = {}
 579    setting_to_bucket = {}
 580    settings_by_bucket = {}
 581    bucket_order = []
 582
 583    if isinstance(bucket_entries, dict):
 584        for bucket_name, bucket_payload in bucket_entries.items():
 585            bucket_order.append(bucket_name)
 586            if isinstance(bucket_payload, dict):
 587                bucket_descriptions[bucket_name] = bucket_payload.get("description", "")
 588                bucket_settings = bucket_payload.get("settings", {})
 589                ordered_setting_names = []
 590                if isinstance(bucket_settings, dict):
 591                    for setting_name, setting_payload in bucket_settings.items():
 592                        ordered_setting_names.append(setting_name)
 593                        setting_to_bucket[setting_name] = bucket_name
 594                        if isinstance(setting_payload, dict):
 595                            setting_descriptions[setting_name] = setting_payload.get(
 596                                "description", ""
 597                            )
 598                            setting_labels[setting_name] = _normalize_label(
 599                                setting_payload.get("label")
 600                            )
 601                        elif isinstance(setting_payload, str):
 602                            setting_descriptions[setting_name] = setting_payload
 603                            setting_labels[setting_name] = LABEL_SUPPORTING
 604                settings_by_bucket[bucket_name] = ordered_setting_names
 605            elif isinstance(bucket_payload, str):
 606                bucket_descriptions[bucket_name] = bucket_payload
 607
 608    DESCRIPTION_CACHE = {
 609        "buckets": bucket_descriptions,
 610        "bucket_order": bucket_order,
 611        "settings": setting_descriptions,
 612        "setting_labels": setting_labels,
 613        "setting_to_bucket": setting_to_bucket,
 614        "settings_by_bucket": settings_by_bucket,
 615    }
 616    return DESCRIPTION_CACHE
 617
 618
 619def get_setting_label(setting_name):
 620    """Get the tier (impactful / supporting) for one setting."""
 621    setting_labels = load_description_data().get("setting_labels", {})
 622    if setting_name in setting_labels:
 623        return _normalize_label(setting_labels[setting_name])
 624    return LABEL_SUPPORTING
 625
 626
 627def format_setting_value(value):
 628    """Format setting values for compact configuration screen display."""
 629    if value is None:
 630        return "None"
 631    if callable(value):
 632        text = GPA.callable_config_repr(value)
 633    else:
 634        text = str(value)
 635    if len(text) > 140:
 636        return text[:137] + "..."
 637    return text
 638
 639
 640SWITCH_MODE_NAMES = [
 641    "DOING_SWITCH_EVERY_TIME",
 642    "DOING_HISTORY",
 643    "DOING_FIXED_SWITCH",
 644    "DOING_NO_SWITCH",
 645]
 646FORWARD_FUNCTION_NAMES = ["relu", "tanh", "sigmoid"]
 647
 648
 649def _switch_mode_values():
 650    return [
 651        GPA.pc.DOING_SWITCH_EVERY_TIME,
 652        GPA.pc.DOING_HISTORY,
 653        GPA.pc.DOING_FIXED_SWITCH,
 654        GPA.pc.DOING_NO_SWITCH,
 655    ]
 656
 657
 658def get_enum_options(setting_name):
 659    """Return the ordered set of values for an enum-like setting, else None."""
 660    if setting_name == "switch_mode":
 661        return list(_switch_mode_values())
 662    if setting_name == "pai_forward_function":
 663        return list(FORWARD_FUNCTION_NAMES)
 664    return None
 665
 666
 667def format_run_setting_value(setting_name, value):
 668    """Display string for a run setting, naming enum values."""
 669    if setting_name == "switch_mode":
 670        try:
 671            return SWITCH_MODE_NAMES[list(_switch_mode_values()).index(value)]
 672        except (ValueError, IndexError):
 673            return str(value)
 674    if setting_name == "pai_forward_function":
 675        name = getattr(value, "__name__", None)
 676        if name in FORWARD_FUNCTION_NAMES:
 677            return name
 678        return format_setting_value(value)
 679    return format_setting_value(value)
 680
 681
 682def cycle_enum_setting(setting_name, current_value):
 683    """Compute and apply the next value for an enum setting."""
 684    options = get_enum_options(setting_name)
 685    if not options:
 686        return False, f"{setting_name} is not an enum setting."
 687
 688    if setting_name == "pai_forward_function":
 689        current_name = getattr(current_value, "__name__", None)
 690        index = options.index(current_name) if current_name in options else -1
 691        next_name = options[(index + 1) % len(options)]
 692        next_value = getattr(torch, next_name) if torch is not None else next_name
 693        return apply_setting_value(setting_name, next_value)
 694
 695    index = options.index(current_value) if current_value in options else -1
 696    next_value = options[(index + 1) % len(options)]
 697    return apply_setting_value(setting_name, next_value)
 698
 699
 700def get_global_setting_value(setting_name):
 701    """Read one setting value from the global config object."""
 702    getter = getattr(GPA.pc, f"get_{setting_name}", None)
 703    if getter is not None:
 704        try:
 705            return getter()
 706        except Exception:
 707            return "<unavailable>"
 708    if hasattr(GPA.pc, setting_name) and not callable(getattr(GPA.pc, setting_name)):
 709        return getattr(GPA.pc, setting_name)
 710    return "<unavailable>"
 711
 712
 713def apply_setting_value(setting_name, value):
 714    """Apply one setting value through a setter when available."""
 715    setter = getattr(GPA.pc, f"set_{setting_name}", None)
 716    if setter is not None:
 717        setter(value)
 718        return True, ""
 719    if hasattr(GPA.pc, setting_name) and not callable(getattr(GPA.pc, setting_name)):
 720        setattr(GPA.pc, setting_name, value)
 721        return True, ""
 722    return False, f"Setting {setting_name} is not editable."
 723
 724
 725def parse_value_from_text(text, sample_value):
 726    """Parse user text into the same type as a sample value where possible."""
 727    if isinstance(sample_value, bool):
 728        lowered = text.strip().lower()
 729        if lowered in ("1", "true", "t", "yes", "y", "on"):
 730            return True
 731        if lowered in ("0", "false", "f", "no", "n", "off"):
 732            return False
 733        raise ValueError("Expected boolean value (true/false).")
 734    if isinstance(sample_value, int) and not isinstance(sample_value, bool):
 735        return int(text.strip())
 736    if isinstance(sample_value, float):
 737        return float(text.strip())
 738    return text
 739
 740
 741def get_all_global_parameters():
 742    """Collect JSON-declared run settings as name -> current value."""
 743    description_data = load_description_data()
 744    visible_names = []
 745    for bucket_name in description_data.get("bucket_order", []):
 746        visible_names.extend(
 747            description_data.get("settings_by_bucket", {}).get(bucket_name, [])
 748        )
 749    values = {}
 750    for setting_name in visible_names:
 751        if setting_name in RUNTIME_ONLY_CONFIG_KEYS:
 752            continue
 753        values[setting_name] = get_global_setting_value(setting_name)
 754    return values
 755
 756
 757def build_run_items(expanded_buckets):
 758    """Build the Run settings list: bucket rows, tier-sorted settings, dividers."""
 759    values = get_all_global_parameters()
 760    description_data = load_description_data()
 761    items = []
 762
 763    for bucket_name in description_data.get("bucket_order", []):
 764        names = [
 765            name
 766            for name in description_data.get("settings_by_bucket", {}).get(bucket_name, [])
 767            if name not in RUNTIME_ONLY_CONFIG_KEYS
 768        ]
 769        if not names:
 770            continue
 771
 772        is_expanded = expanded_buckets.get(bucket_name, False)
 773        items.append(
 774            {
 775                "type": "bucket",
 776                "bucket": bucket_name,
 777                "is_expanded": is_expanded,
 778                "count": len(names),
 779            }
 780        )
 781        if not is_expanded:
 782            continue
 783
 784        impactful = [n for n in names if get_setting_label(n) == LABEL_IMPACTFUL]
 785        supporting = [n for n in names if get_setting_label(n) != LABEL_IMPACTFUL]
 786
 787        def _setting_item(setting_name):
 788            return {
 789                "type": "setting",
 790                "bucket": bucket_name,
 791                "name": setting_name,
 792                "value": values.get(setting_name, "<unavailable>"),
 793            }
 794
 795        for setting_name in impactful:
 796            items.append(_setting_item(setting_name))
 797        if impactful and supporting:
 798            items.append({"type": "divider", "bucket": bucket_name})
 799        for setting_name in supporting:
 800            items.append(_setting_item(setting_name))
 801
 802    return items
 803
 804
 805# ---------------------------------------------------------------------------
 806# Overrides (per-target settings)
 807# ---------------------------------------------------------------------------
 808def load_existing_module_settings():
 809    """Load existing module_settings from local config sources."""
 810    merged = {}
 811    source_paths = []
 812    config_file = GPA.pc.get_config_file()
 813    run_config = GPA.pc.get_run_config_path()
 814    if config_file:
 815        source_paths.append(config_file)
 816    if run_config:
 817        source_paths.append(run_config)
 818
 819    for source_path in source_paths:
 820        if not source_path or not os.path.exists(source_path):
 821            continue
 822        try:
 823            with open(source_path, "r") as file_handle:
 824                payload = json.load(file_handle)
 825            module_settings = payload.get("module_settings", {})
 826            for scope_key, values in module_settings.items():
 827                if isinstance(values, dict):
 828                    merged[scope_key] = dict(values)
 829        except Exception:
 830            continue
 831    return merged
 832
 833
 834def get_scope_values(scope_key, module_settings_overrides, existing_module_settings):
 835    """Merged override values for one scope: saved data plus in-session edits."""
 836    values = {}
 837    if scope_key in existing_module_settings:
 838        values.update(existing_module_settings[scope_key])
 839    if scope_key in module_settings_overrides:
 840        values.update(module_settings_overrides[scope_key])
 841    return values
 842
 843
 844def set_module_scoped_value(module_settings_overrides, scope_key, setting_name, value):
 845    """Write one override value into in-session state."""
 846    module_settings_overrides.setdefault(scope_key, {})[setting_name] = value
 847
 848
 849def get_override_scope_for_entry(entry, resolved):
 850    """Determine the override scope for a target, or a reason it is blocked."""
 851    record = resolved[entry["id"]]
 852    if entry["is_replaced_root"] or entry["in_replaced"]:
 853        return None, (
 854            "Overrides are not available inside a module that will be restructured."
 855        )
 856    if record["eff"] is None:
 857        return None, (
 858            "Overrides are only for perforated modules. Perforate this one first "
 859            "(p), or track it if you just want it monitored."
 860        )
 861    if record["source"] == "inherited":
 862        origin = record["origin_id"]
 863        return None, (
 864            f"This module inherits {origin}'s mode. Open overrides on {origin} to "
 865            "change settings for the whole subtree."
 866        )
 867    if record["eff"] != "perforated":
 868        return None, "Overrides are only for perforated modules — this one is tracked."
 869
 870    if record["source"] == "type":
 871        return (
 872            {"kind": "type", "scope_key": entry["type_name"]},
 873            None,
 874        )
 875    return ({"kind": "id", "scope_key": entry["id"]}, None)
 876
 877
 878def persist_module_settings_updates(module_settings_overrides, overwrite_config_file=False):
 879    """Persist in-session module_settings updates to local config JSON files."""
 880    if not module_settings_overrides:
 881        return
 882
 883    destinations = []
 884    run_config = GPA.pc.get_run_config_path()
 885    if run_config:
 886        destinations.append(run_config)
 887    if overwrite_config_file:
 888        config_file = GPA.pc.get_config_file()
 889        if config_file and config_file not in destinations:
 890            destinations.append(config_file)
 891
 892    for destination in destinations:
 893        payload = {}
 894        if os.path.exists(destination):
 895            try:
 896                with open(destination, "r") as file_handle:
 897                    payload = json.load(file_handle)
 898            except Exception:
 899                payload = {}
 900
 901        module_settings = payload.get("module_settings", {})
 902        if not isinstance(module_settings, dict):
 903            module_settings = {}
 904        for scope_key, values in module_settings_overrides.items():
 905            existing = module_settings.get(scope_key, {})
 906            if not isinstance(existing, dict):
 907                existing = {}
 908            existing.update(values)
 909            module_settings[scope_key] = existing
 910        payload["module_settings"] = module_settings
 911
 912        directory = os.path.dirname(destination)
 913        if directory:
 914            os.makedirs(directory, exist_ok=True)
 915        with open(destination, "w") as file_handle:
 916            json.dump(payload, file_handle, indent=2)
 917
 918
 919CUSTOMIZABLE_SETTING_NAMES = list(GPA.PAIConfig._CUSTOMIZABLE.keys())
 920
 921
 922def get_setting_description(setting_name):
 923    """Help text for one setting."""
 924    descriptions = load_description_data().get("settings", {})
 925    return descriptions.get(setting_name, f"No description available for {setting_name}.")
 926
 927
 928def get_bucket_description(bucket_name):
 929    descriptions = load_description_data().get("buckets", {})
 930    return descriptions.get(bucket_name, "No description available.")
 931
 932
 933# ---------------------------------------------------------------------------
 934# Rendering
 935# ---------------------------------------------------------------------------
 936HR = "  " + "─" * 70
 937
 938
 939def terminal_size():
 940    return shutil.get_terminal_size(fallback=(120, 40))
 941
 942
 943def render_tab_bar(active_screen):
 944    """The `[ Targets | Run settings ]` tab bar with the active screen lit."""
 945    targets = "Targets"
 946    run = "Run settings"
 947    if active_screen == "targets":
 948        targets = color_text(targets, COLOR_ACCENT)
 949    else:
 950        run = color_text(run, COLOR_ACCENT)
 951    return f"[ {targets} │ {run} ]" + " " * 12 + dim("[Tab] switch panel    ? help")
 952
 953
 954MODE_VERB = {"perforated": "perforate", "tracked": "track"}
 955
 956
 957def mode_verb(mode):
 958    """Present-tense verb form of a resolved mode for user-facing display."""
 959    return MODE_VERB.get(mode, str(mode))
 960
 961
 962def make_target_marker(entry, record):
 963    """(display, plain) marker for one target row, per D8."""
 964    if entry["in_replaced"]:
 965        return "     ", "     "
 966
 967    mode = record["eff"]
 968    if mode is None:
 969        if entry["direct_param_count"] > 0:
 970            glyph_display = color_text("!", COLOR_ATTENTION)
 971            block_display = color_text("██", COLOR_ATTENTION)
 972            return f"{glyph_display} {block_display} ", "! ██ "
 973        return "     ", "     "
 974
 975    block_display = make_block(mode)
 976    if record["source"] == "inherited":
 977        return f"↳ {block_display} ", "↳ ██ "
 978    if record["source"] == "id":
 979        return f"* {block_display} ", "* ██ "
 980    return f"  {block_display} ", "  ██ "
 981
 982
 983def make_block(mode):
 984    return color_text("██", get_color_hex_for_mode(mode))
 985
 986
 987def render_targets_lines(entries, visible_entries, selected_index, resolved, expanded_replaced):
 988    """Header + tree + footer for the Targets screen, as a list of lines."""
 989    need = len(unset_module_entries(entries, resolved))
 990    rules = type_rule_entries(entries)
 991
 992    lines = [render_tab_bar("targets"), ""]
 993    lines.append(
 994        dim(
 995            "  perforate = add dendrites   ·   track = not perforated"
 996            "   "
 997        )
 998    )
 999    lines.append("")
1000
1001    hint = color_text(f"[y] type rules ({len(rules)})", COLOR_ACCENT)
1002    if need > 0:
1003        noun = "module" if need == 1 else "modules"
1004        verb = "needs" if need == 1 else "need"
1005        status = color_text(f"  ⚠ {need} {noun} still {verb} a mode", COLOR_ATTENTION)
1006    else:
1007        status = color_text("  ✓ every parameter is perforated or tracked", COLOR_PERFORATE)
1008    pad = max(2, 58 - len(strip_ansi(status)))
1009    lines.append(status + " " * pad + hint)
1010    lines.append(HR)
1011
1012    def entry_lines(entry, selected):
1013        cursor = color_text("> ", "E8EEEC") if selected else "  "
1014        indent = "  " * entry["depth"]
1015        record = resolved[entry["id"]]
1016
1017        if entry["is_replaced_root"]:
1018            child_count = sum(1 for e in entries if e["replaced_root"] == entry["id"])
1019            glyph = color_text("↯", COLOR_ACCENT)
1020            is_open = expanded_replaced.get(entry["id"], False)
1021            toggle = "[← collapse]" if is_open else "[→ expand]"
1022            return [
1023                f"{cursor}{glyph}   {indent}{entry['id']}  "
1024                f"{dim('[' + entry['type_name'] + ']')}   "
1025                f"{dim('will be restructured for PAI before training')}",
1026                "      "
1027                + dim(
1028                    f"└ {child_count} modules below — ids won't exist at "
1029                    f"train time · target by type (P/T), not id  "
1030                )
1031                + color_text(toggle, COLOR_ACCENT),
1032            ]
1033
1034        marker_display, marker_plain = make_target_marker(entry, record)
1035
1036        if entry["in_replaced"]:
1037            id_part = dim(f"{entry['id']}  [{entry['type_name']}] (id stale)")
1038            return [f"{cursor}{marker_display} {indent}{id_part}"]
1039
1040        id_part = f"{entry['id']}  {dim('[' + entry['type_name'] + ']')}"
1041
1042        tail = ""
1043        if record["eff"] is not None:
1044            mode_color = get_color_hex_for_mode(record["eff"])
1045            tail = color_text(f"mode={mode_verb(record['eff'])}", mode_color)
1046            if record["source"] == "inherited":
1047                tail += dim(f"  (via {record['origin_id']})")
1048                if record["overridden"] is not None:
1049                    tail += dim(
1050                        f"  — your {mode_verb(record['overridden']['mode'])} here is ignored"
1051                    )
1052        elif entry["direct_param_count"] > 0:
1053            tail = color_text("needs a mode", COLOR_ATTENTION)
1054
1055        params = (
1056            dim(f"   params={format_human_count(entry['direct_param_count'])}")
1057            if entry["direct_param_count"] > 0
1058            else ""
1059        )
1060
1061        lead = (
1062            2 + len(marker_plain) + 1 + len(indent) + len(entry["id"])
1063            + 2 + len(entry["type_name"]) + 2
1064        )
1065        gap = max(2, 46 - lead)
1066        return [
1067            f"{cursor}{marker_display} {indent}{id_part}" + " " * gap + tail + params
1068        ]
1069
1070    body_lines = []
1071    focus = (0, 1)
1072    for i, entry in enumerate(visible_entries):
1073        rendered = entry_lines(entry, i == selected_index)
1074        if i == selected_index:
1075            focus = (len(body_lines), len(body_lines) + len(rendered))
1076        body_lines.extend(rendered)
1077
1078    footer = [
1079        HR,
1080        dim(build_budget_line(entries, resolved)),
1081        "",
1082        dim(
1083            "p/t perforate·track this module   P/T whole type   x clear   "
1084            "h legend   ↑↓/jk move   s start"
1085        ),
1086    ]
1087    return lines, body_lines, footer, focus
1088
1089
1090def render_run_settings_lines(items, selected_index, describe_name):
1091    """Header + item body + footer for the Run settings screen."""
1092    lines = [
1093        render_tab_bar("run"),
1094        "",
1095        dim("  Run settings — run-wide configuration"),
1096        HR,
1097    ]
1098
1099    body_lines = []
1100    for i, item in enumerate(items):
1101        selected = i == selected_index
1102        cursor = color_text("> ", "E8EEEC") if selected else "  "
1103        if item["type"] == "bucket":
1104            glyph = color_text("▾" if item["is_expanded"] else "▸", COLOR_ACCENT)
1105            body_lines.append(
1106                f"{cursor}{glyph} {item['bucket']} {dim('(' + str(item['count']) + ')')}"
1107            )
1108        elif item["type"] == "divider":
1109            body_lines.append("     " + dim("─ rarely changed ─"))
1110        else:
1111            value_text = format_run_setting_value(item["name"], item["value"])
1112            hint = ""
1113            if get_enum_options(item["name"]) is not None:
1114                hint = dim("  (Enter cycles)")
1115            elif isinstance(item["value"], bool):
1116                hint = dim("  (Enter toggles)")
1117            body_lines.append(f"{cursor}  {item['name']} = {value_text}{hint}")
1118
1119    footer_line = dim(
1120        "→ expand · ← collapse   Enter edit   h describe   "
1121        "↑↓/jk move   Tab switch panel   s start"
1122    )
1123    if describe_name:
1124        footer_line = dim(f"{describe_name} — {get_setting_description(describe_name)}")
1125    footer = [HR, footer_line]
1126    focus = (selected_index, selected_index + 1)
1127    return lines, body_lines, footer, focus
1128
1129
1130def compose_scrolling_screen(header_lines, body_lines, footer_lines, window_start, focus=None):
1131    """Join a header + windowed body + footer into one screen string.
1132
1133    Body rows may wrap to more than one terminal row, so the window is measured
1134    in *rendered* rows (after wrapping), not logical lines — otherwise a narrow
1135    terminal pushes the footer and budget line off-screen.
1136    """
1137    size = terminal_size()
1138    columns = size.columns
1139    reserved = get_visual_line_count(header_lines, columns) + get_visual_line_count(
1140        footer_lines, columns
1141    )
1142    # Two rows held back for the ^^^^ / vvvv overflow indicators.
1143    budget = max(1, size.lines - reserved - 3 - 2)
1144
1145    total = len(body_lines)
1146
1147    def rows_for(line):
1148        return max(1, get_visual_line_count([line], columns))
1149
1150    def window_end_from(start):
1151        used = 0
1152        end = start
1153        while end < total:
1154            height = rows_for(body_lines[end])
1155            if end > start and used + height > budget:
1156                break
1157            used += height
1158            end += 1
1159        return end
1160
1161    def window_start_for_end(end):
1162        used = 0
1163        start = end
1164        while start > 0:
1165            height = rows_for(body_lines[start - 1])
1166            if used + height > budget:
1167                break
1168            used += height
1169            start -= 1
1170        return start
1171
1172    if focus is not None:
1173        focus_start, focus_end = focus
1174        focus_start = max(0, min(focus_start, total))
1175        focus_end = max(focus_start + 1, min(focus_end, total))
1176        if focus_start < window_start:
1177            window_start = focus_start
1178        if window_end_from(window_start) < focus_end:
1179            window_start = window_start_for_end(focus_end)
1180
1181    window_start = max(0, min(window_start, total))
1182    window_end = window_end_from(window_start)
1183    # Grow upward if there is spare room at the bottom of the list.
1184    if window_end >= total:
1185        window_start = min(window_start, window_start_for_end(total))
1186        window_end = total
1187
1188    out = list(header_lines)
1189    if window_start > 0:
1190        out.append(dim("  ^^^^ more above ^^^^"))
1191    out.extend(body_lines[window_start:window_end])
1192    if window_end < total:
1193        out.append(dim("  vvvv more below vvvv"))
1194    out.extend(footer_lines)
1195    return "\n".join(out), window_start
1196
1197
1198def render_static_overlay(lines):
1199    return "\n".join(lines)
1200
1201
1202def render_type_rules_overlay(entries):
1203    rules = type_rule_entries(entries)
1204    rows = []
1205    if rules:
1206        for type_name, mode, count in rules:
1207            plain = f"{type_name} → {mode_verb(mode)}  ({count})"
1208            html = (
1209                type_name
1210                + " "
1211                + color_text(f"→ {mode_verb(mode)}", get_color_hex_for_mode(mode))
1212                + dim(f"  ({count})")
1213            )
1214            rows.append((plain, html))
1215    else:
1216        rows.append(("none yet — P / T on a row sets one",
1217                     dim("none yet — P / T on a row sets one")))
1218
1219    width = max(38, max(len(plain) for plain, _ in rows) + 2)
1220    box = ["  " + dim("┌─ Type rules " + "─" * max(1, width - 12) + "┐")]
1221    for plain, html in rows:
1222        box.append("  " + dim("│ ") + html + " " * (width - len(plain) - 1) + dim("│"))
1223    box.append("  " + dim("└" + "─" * width + "┘"))
1224    box.append("  " + color_text("y or Esc to close", COLOR_ACCENT))
1225    return box
1226
1227
1228def render_overrides_overlay(scope, ov_selected_index, module_settings_overrides,
1229                             existing_module_settings, describe_name):
1230    if scope["kind"] == "id":
1231        title = f"Overrides — {scope['scope_key']}"
1232    else:
1233        title = f"Overrides — every {scope['scope_key']}"
1234
1235    scoped = get_scope_values(
1236        scope["scope_key"], module_settings_overrides, existing_module_settings
1237    )
1238
1239    lines = [render_tab_bar("targets"), "", "  " + color_text(title, COLOR_ACCENT), HR, ""]
1240    if not CUSTOMIZABLE_SETTING_NAMES:
1241        lines.append("  " + dim("No per-target settings are customizable in this build."))
1242    for i, name in enumerate(CUSTOMIZABLE_SETTING_NAMES):
1243        overridden = name in scoped
1244        value = scoped[name] if overridden else get_global_setting_value(name)
1245        cursor = color_text("> ", "E8EEEC") if i == ov_selected_index else "  "
1246        tag = dim("(overridden here)") if overridden else dim("(from run settings)")
1247        lines.append(
1248            f"{cursor}{name} = {format_run_setting_value(name, value)}  {tag}"
1249        )
1250
1251    lines.append("")
1252    footer_line = dim(
1253        "Enter edit · h describe · ↑↓/jk move · Esc / x close"
1254    )
1255    if describe_name:
1256        footer_line = dim(f"{describe_name} — {get_setting_description(describe_name)}")
1257    lines.append(footer_line)
1258    return lines
1259
1260
1261def render_save_overlay(entries, resolved, save_selected_index, config_target_path):
1262    unset = unset_module_entries(entries, resolved)
1263    lines = ["", "  " + color_text("Save configuration", "E8EEEC"), ""]
1264
1265    if unset:
1266        noun = "module has" if len(unset) == 1 else "modules have"
1267        lines.append(
1268            color_text(
1269                f"  ⚠  {len(unset)} {noun} parameters but no mode",
1270                COLOR_ATTENTION,
1271            )
1272        )
1273        lines.append("")
1274        for entry in unset[:5]:
1275            lines.append(f"      {dim(entry['id'] + '   [' + entry['type_name'] + ']')}")
1276        if len(unset) > 5:
1277            lines.append(f"      {dim('(+' + str(len(unset) - 5) + ' more)')}")
1278        lines.append("")
1279        lines.append(
1280            dim(
1281                "  These parameters get no dendrites and are not tracked. "
1282                "Usually a mistake."
1283            )
1284        )
1285        lines.append("")
1286        options = [
1287            "Keep editing",
1288            f"Start anyway — I know these {len(unset)} have no mode",
1289        ]
1290    else:
1291        lines.append(dim("  Start training with this configuration:"))
1292        lines.append("")
1293        options = [
1294            ("just this run", "don't touch my saved config"),
1295            (
1296                "and save it as default",
1297                f"write it to {config_target_path}, skip this screen next time",
1298            ),
1299            ("Keep editing", ""),
1300        ]
1301
1302    for i, option in enumerate(options):
1303        marked = i == save_selected_index
1304        pointer = color_text("  ▸ ", "E8EEEC") if marked else "    "
1305        if isinstance(option, tuple):
1306            label, detail = option
1307            text = color_text(label, "E8EEEC") if marked else label
1308            if detail:
1309                text += dim(f"  — {detail}")
1310            lines.append(pointer + text)
1311        else:
1312            text = color_text(option, "E8EEEC") if marked else dim(option)
1313            lines.append(pointer + text)
1314
1315    lines.append("")
1316    lines.append("")
1317    lines.append(
1318        dim("  ↑↓/jk move · Enter choose · Esc / x back to editing")
1319    )
1320    return lines
1321
1322
1323def render_help_overlay():
1324    p = lambda t: color_text(t, COLOR_PERFORATE)
1325    tr = lambda t: color_text(t, COLOR_TRACK)
1326    return [
1327        "",
1328        "  " + color_text("Keys", "E8EEEC"),
1329        "",
1330        dim("  Targets screen"),
1331        "   p / t     perforate / track this module",
1332        "   P / T     perforate / track every module of this type",
1333        "   x         clear this module (falls back to its type rule)",
1334        "   → / ←     expand / collapse a module that will be restructured (↯)",
1335        "   y         show / hide the type-rules list",
1336        "   h         show the marker & colour legend",
1337        "   Enter     open Overrides for a perforated module",
1338        "",
1339        dim("  Run settings screen"),
1340        "   → / ←     expand / collapse a bucket",
1341        "   Enter     edit the highlighted setting",
1342        "   h         describe the highlighted setting",
1343        "",
1344        dim("  Everywhere"),
1345        "   ↑↓ / jk   move        Tab  switch panel",
1346        "   s         save & start training",
1347        "   q         quit without configuring",
1348        "",
1349        dim("  Modes"),
1350        "   " + p("perforate") + " = dendrites are added here during training",
1351        "   " + tr("track") + "     = no dendrites, the parameters are just counted",
1352        "   " + dim("↳ inherited") + " = a mode set on an ancestor applies to the whole subtree",
1353        "   " + dim("(see the h legend on the Targets panel for how modes resolve)"),
1354        "",
1355        color_text("  ? or Esc to close", COLOR_ACCENT),
1356    ]
1357
1358
1359def render_legend_overlay():
1360    """Describe the Targets-screen markers and colours."""
1361    perf = color_text("██", COLOR_PERFORATE)
1362    trk = color_text("██", COLOR_TRACK)
1363    att = color_text("██", COLOR_ATTENTION)
1364    return [
1365        "",
1366        "  " + color_text("Targets legend", "E8EEEC"),
1367        "",
1368        dim("  Mode blocks"),
1369        f"   {perf}        " + color_text("perforate", COLOR_PERFORATE)
1370        + " — dendrites are added here during training",
1371        f"   {trk}        " + color_text("track", COLOR_TRACK)
1372        + " — no dendrites, the parameters are just counted",
1373        "",
1374        dim("  Marker prefixes"),
1375        f"   * {perf}     mode set directly on this module (by id)",
1376        f"     {perf}     set by type name — every module of this class",
1377        f"   ↳ {perf}     inherited from an ancestor; applies to the whole subtree",
1378        f"   ! {att}     has parameters but no mode — needs attention",
1379        "   ↯         will be restructured for PAI before training;",
1380        "             target the modules inside it by type (P/T), not by id",
1381        "   (none)    structural container with no parameters of its own",
1382        "",
1383        dim("  Which mode wins  (highest priority first)"),
1384        "   1. inherited — a mode on an ancestor applies to its whole",
1385        "      subtree; a mode set directly on a descendant is ignored",
1386        "      while the ancestor's mode is in effect",
1387        "   2. by id — a mode set on this exact module",
1388        "   3. by type — a mode set on the module's class name",
1389        "   So: inherited  >  by id  >  by type.",
1390        "",
1391        color_text("  h or Esc to close", COLOR_ACCENT),
1392    ]
1393
1394
1395def render_quit_overlay():
1396    return [
1397        "",
1398        "",
1399        "",
1400        "  " + color_text("Quit without configuring?", "E8EEEC"),
1401        "",
1402        dim("  Training will not start. Re-run to configure again, or set"),
1403        dim("  configuration_confirmed=True to skip this screen."),
1404        "",
1405        "  " + color_text("[y] quit", COLOR_ATTENTION) + "     " + dim("[n] keep configuring"),
1406    ]
1407
1408
1409# ---------------------------------------------------------------------------
1410# Key input
1411# ---------------------------------------------------------------------------
1412def read_single_key():
1413    """Read one keypress, including arrow keys, from stdin.
1414
1415    Ctrl-C raises KeyboardInterrupt so the caller can exit cleanly.
1416    """
1417    file_descriptor = sys.stdin.fileno()
1418    old_settings = termios.tcgetattr(file_descriptor)
1419    try:
1420        tty.setraw(file_descriptor)
1421        first = os.read(file_descriptor, 1)
1422        if first == b"\x03":
1423            raise KeyboardInterrupt
1424        if first != b"\x1b":
1425            return first.decode("latin1")
1426
1427        sequence = bytearray(first)
1428        ready, _, _ = select.select([file_descriptor], [], [], 0.05)
1429        if not ready:
1430            return "\x1b"
1431        sequence.extend(os.read(file_descriptor, 1))
1432
1433        if sequence[1] in (ord("["), ord("O")):
1434            ready, _, _ = select.select([file_descriptor], [], [], 0.15)
1435            if ready:
1436                sequence.extend(os.read(file_descriptor, 1))
1437
1438        while True:
1439            last_byte = sequence[-1]
1440            if chr(last_byte).isalpha() or last_byte == ord("~"):
1441                break
1442            ready, _, _ = select.select([file_descriptor], [], [], 0.01)
1443            if not ready:
1444                break
1445            sequence.extend(os.read(file_descriptor, 1))
1446
1447        return bytes(sequence).decode("latin1")
1448    finally:
1449        termios.tcsetattr(file_descriptor, termios.TCSADRAIN, old_settings)
1450
1451
1452def enter_alternate_screen():
1453    """Switch terminal to alternate screen buffer for the interactive UI."""
1454    print("\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H", end="", flush=True)
1455
1456
1457def exit_alternate_screen():
1458    """Return terminal to the normal screen buffer."""
1459    print("\x1b[?25h\x1b[?1049l", end="", flush=True)
1460
1461
1462# ---------------------------------------------------------------------------
1463# Main loop
1464# ---------------------------------------------------------------------------
1465def _config_target_path():
1466    """Human path where 'save as default' would write."""
1467    config_file = GPA.pc.get_config_file()
1468    if config_file:
1469        return config_file
1470    run_config_path = GPA.pc.get_run_config_path()
1471    if run_config_path:
1472        try:
1473            return os.path.relpath(run_config_path, os.getcwd())
1474        except Exception:
1475            return run_config_path
1476    save_name = GPA.pc.get_save_name() or "PAI"
1477    return f"{save_name}/{save_name}_config.json"
1478
1479
1480def _move(index, delta, length):
1481    if length <= 0:
1482        return 0
1483    return max(0, min(length - 1, index + delta))
1484
1485
1486def _distributed_rank():
1487    """Best-effort process rank for common launchers (0 when not distributed)."""
1488    for var in ("RANK", "LOCAL_RANK", "SLURM_PROCID", "OMPI_COMM_WORLD_RANK"):
1489        value = os.environ.get(var)
1490        if value is not None:
1491            try:
1492                return int(value)
1493            except ValueError:
1494                return 0
1495    return 0
1496
1497
1498def require_interactive_session():
1499    """Raise a clear error if the TUI cannot be driven in this process.
1500
1501    The configuration TUI reads raw keystrokes from the terminal. Under CI,
1502    ``nohup``, notebooks, piped stdin, or non-rank-0 distributed workers there is
1503    no usable TTY, so we fail fast with actionable guidance instead of crashing
1504    inside ``termios``.
1505    """
1506    if termios is None:
1507        raise RuntimeError(
1508            "The interactive perforation-configuration TUI is not available on this "
1509            "platform (no termios). Set configuration_confirmed=True in your config "
1510            "file (or call GPA.pc.set_configuration_confirmed(True)) and configure "
1511            "targets via the config file or Perforated Studio instead."
1512        )
1513
1514    try:
1515        is_tty = bool(sys.stdin.isatty() and sys.stdout.isatty())
1516    except (ValueError, AttributeError, OSError):
1517        is_tty = False
1518    rank = _distributed_rank()
1519
1520    if is_tty and rank == 0:
1521        return
1522
1523    reason = "no interactive terminal is attached" if not is_tty else f"process rank is {rank}, not 0"
1524    raise RuntimeError(
1525        "Perforation configuration has not been confirmed, and it cannot be done "
1526        f"interactively here ({reason}).\n"
1527        "Run perforate_model once from an interactive terminal to choose "
1528        "perforation targets, or set configuration_confirmed=True in your config "
1529        "file (or call GPA.pc.set_configuration_confirmed(True) before "
1530        "perforate_model) to skip the interactive step."
1531    )
1532
1533
1534def set_perforation_targets(model):
1535    """Interactive configuration TUI for perforation targets and run settings.
1536
1537    Two screens (Tab to switch): Targets (the module tree) and Run settings.
1538    See the `?` overlay for the full key reference.
1539    """
1540    require_interactive_session()
1541    previous_auto_persist = GPA.pc.__dict__.get("_auto_persist_config", True)
1542    GPA.pc.__dict__["_auto_persist_config"] = False
1543    entered_alt_screen = False
1544    quit_requested = False
1545
1546    try:
1547        enter_alternate_screen()
1548        entered_alt_screen = True
1549        normalize_selection_conflicts()
1550
1551        entries = get_module_entries(model)
1552        if len(entries) == 0:
1553            print(model)
1554            input("Press Enter to confirm perforation targets...")
1555            GPA.pc.set_configuration_confirmed(True)
1556            return
1557
1558        active_screen = "targets"
1559        overlay = None
1560        status_message = ""
1561
1562        target_selected_index = 0
1563        target_window_start = 0
1564        expanded_replaced = {}
1565
1566        run_selected_index = 0
1567        run_window_start = 0
1568        expanded_buckets = {}
1569        describe_name = ""
1570
1571        ov_scope = None
1572        ov_selected_index = 0
1573        module_settings_overrides = {}
1574        existing_module_settings = load_existing_module_settings()
1575
1576        save_selected_index = 0
1577
1578        scalar_editor_state = None
1579        list_editor_state = None
1580
1581        def finalize_save(choice_index):
1582            if choice_index == 0:
1583                GPA.pc.persist_config_outputs(overwrite_config_file=False)
1584                persist_module_settings_updates(
1585                    module_settings_overrides, overwrite_config_file=False
1586                )
1587                return
1588
1589            config_file = GPA.pc.get_config_file()
1590            if not config_file:
1591                current_save_name = GPA.pc.get_save_name() or "PAI"
1592                relative_config_name = _config_target_path()
1593                exit_alternate_screen()
1594                print("\x1b[2J\x1b[H", end="")
1595                print("No config_file is set.")
1596                print(
1597                    "Enter a local JSON filename/path to create a reusable "
1598                    "configuration file."
1599                )
1600                print(
1601                    f"You currently have save_name='{current_save_name}'. Choose "
1602                    f"'{relative_config_name}' to skip this screen next time without "
1603                    "changing your perforate_model call."
1604                )
1605                while True:
1606                    filename = input("Config filename/path: ").strip()
1607                    if filename:
1608                        GPA.pc.__dict__["_config_file"] = filename
1609                        break
1610                    print("A filename is required to save a default configuration.")
1611                enter_alternate_screen()
1612
1613            GPA.pc.persist_config_outputs(overwrite_config_file=True)
1614            persist_module_settings_updates(
1615                module_settings_overrides, overwrite_config_file=True
1616            )
1617
1618        def visible_targets():
1619            out = []
1620            for entry in entries:
1621                if entry["in_replaced"] and not expanded_replaced.get(
1622                    entry["replaced_root"], False
1623                ):
1624                    continue
1625                out.append(entry)
1626            return out
1627
1628        while True:
1629            normalize_selection_conflicts()
1630            resolved = resolve_entry_modes(entries)
1631
1632            # ---- editors capture input first -------------------------------
1633            if scalar_editor_state is not None:
1634                _render_editor_frame(scalar_editor_state)
1635                key = read_single_key()
1636                done, status_message = _handle_scalar_editor(
1637                    scalar_editor_state, key, module_settings_overrides
1638                )
1639                if done:
1640                    scalar_editor_state = None
1641                continue
1642
1643            if list_editor_state is not None:
1644                _render_editor_frame(list_editor_state)
1645                key = read_single_key()
1646                done, status_message = _handle_list_editor(
1647                    list_editor_state, key, module_settings_overrides
1648                )
1649                if done:
1650                    list_editor_state = None
1651                continue
1652
1653            # ---- draw the current screen ---------------------------------
1654            print("\x1b[2J\x1b[H", end="")
1655
1656            if overlay == "help":
1657                screen_text = render_static_overlay(render_help_overlay())
1658            elif overlay == "legend":
1659                screen_text = render_static_overlay(render_legend_overlay())
1660            elif overlay == "quit":
1661                screen_text = render_static_overlay(render_quit_overlay())
1662            elif overlay == "save":
1663                screen_text = render_static_overlay(
1664                    render_save_overlay(
1665                        entries, resolved, save_selected_index, _config_target_path()
1666                    )
1667                )
1668            elif overlay == "overrides":
1669                screen_text = render_static_overlay(
1670                    render_overrides_overlay(
1671                        ov_scope,
1672                        ov_selected_index,
1673                        module_settings_overrides,
1674                        existing_module_settings,
1675                        describe_name,
1676                    )
1677                )
1678            elif active_screen == "targets":
1679                vis = visible_targets()
1680                if target_selected_index >= len(vis):
1681                    target_selected_index = max(0, len(vis) - 1)
1682                header, body, footer, focus = render_targets_lines(
1683                    entries, vis, target_selected_index, resolved, expanded_replaced
1684                )
1685                if overlay == "typerules":
1686                    header = header + [""] + render_type_rules_overlay(entries) + [""]
1687                if status_message:
1688                    footer = footer + [color_text("  " + status_message, COLOR_ACCENT)]
1689                screen_text, target_window_start = compose_scrolling_screen(
1690                    header, body, footer, target_window_start, focus
1691                )
1692            else:
1693                items = build_run_items(expanded_buckets)
1694                if run_selected_index >= len(items):
1695                    run_selected_index = max(0, len(items) - 1)
1696                header, body, footer, focus = render_run_settings_lines(
1697                    items, run_selected_index, describe_name
1698                )
1699                if status_message:
1700                    footer = footer + [color_text("  " + status_message, COLOR_ACCENT)]
1701                screen_text, run_window_start = compose_scrolling_screen(
1702                    header, body, footer, run_window_start, focus
1703                )
1704
1705            print(wrap_screen_text_for_terminal(screen_text))
1706            key = read_single_key()
1707
1708            # ---- overlay key handling -----------------------------------
1709            if overlay == "help":
1710                if key == "?" or key == "\x1b":
1711                    overlay = None
1712                continue
1713
1714            if overlay == "legend":
1715                if key == "h" or key == "\x1b":
1716                    overlay = None
1717                continue
1718
1719            if overlay == "quit":
1720                if key in ("y", "Y"):
1721                    quit_requested = True
1722                    break
1723                if key in ("n", "N") or key == "\x1b":
1724                    overlay = None
1725                continue
1726
1727            if overlay == "typerules":
1728                if key in ("y", "Y") or key == "\x1b":
1729                    overlay = None
1730                elif key.lower() == "s":
1731                    overlay = "save"
1732                    save_selected_index = 0
1733                continue
1734
1735            if overlay == "save":
1736                unset = unset_module_entries(entries, resolved)
1737                option_count = 2 if unset else 3
1738                if is_up_key(key):
1739                    save_selected_index = _move(save_selected_index, -1, option_count)
1740                elif is_down_key(key):
1741                    save_selected_index = _move(save_selected_index, 1, option_count)
1742                elif key == "\x1b" or key == "x":
1743                    overlay = None
1744                elif is_enter_key(key):
1745                    if unset:
1746                        if save_selected_index == 0:
1747                            overlay = None
1748                        else:
1749                            finalize_save(0)
1750                            GPA.pc.set_configuration_confirmed(True)
1751                            break
1752                    else:
1753                        if save_selected_index == 2:
1754                            overlay = None
1755                        elif save_selected_index == 1:
1756                            GPA.pc.set_configuration_confirmed(True)
1757                            finalize_save(1)
1758                            break
1759                        else:
1760                            finalize_save(0)
1761                            GPA.pc.set_configuration_confirmed(True)
1762                            break
1763                continue
1764
1765            if overlay == "overrides":
1766                names = CUSTOMIZABLE_SETTING_NAMES
1767                if key == "\x1b" or key == "x":
1768                    overlay = None
1769                    describe_name = ""
1770                elif is_up_key(key):
1771                    ov_selected_index = _move(ov_selected_index, -1, len(names))
1772                    describe_name = ""
1773                elif is_down_key(key):
1774                    ov_selected_index = _move(ov_selected_index, 1, len(names))
1775                    describe_name = ""
1776                elif key == "h" and names:
1777                    current = names[ov_selected_index]
1778                    describe_name = "" if describe_name == current else current
1779                elif is_enter_key(key) and names:
1780                    name = names[ov_selected_index]
1781                    scoped = get_scope_values(
1782                        ov_scope["scope_key"],
1783                        module_settings_overrides,
1784                        existing_module_settings,
1785                    )
1786                    value = scoped[name] if name in scoped else get_global_setting_value(name)
1787                    if isinstance(value, bool):
1788                        set_module_scoped_value(
1789                            module_settings_overrides, ov_scope["scope_key"], name, not value
1790                        )
1791                    elif get_enum_options(name) is not None:
1792                        options = get_enum_options(name)
1793                        try:
1794                            index = options.index(value)
1795                        except ValueError:
1796                            index = -1
1797                        set_module_scoped_value(
1798                            module_settings_overrides,
1799                            ov_scope["scope_key"],
1800                            name,
1801                            options[(index + 1) % len(options)],
1802                        )
1803                    elif isinstance(value, list):
1804                        list_editor_state = _new_list_editor(
1805                            name, value, scope_key=ov_scope["scope_key"]
1806                        )
1807                    elif isinstance(value, (int, float)):
1808                        scalar_editor_state = _new_scalar_editor(
1809                            name, value, scope_key=ov_scope["scope_key"]
1810                        )
1811                    else:
1812                        status_message = f"{name} cannot be edited here."
1813                elif key.lower() == "s":
1814                    overlay = "save"
1815                    save_selected_index = 0
1816                continue
1817
1818            # ---- global keys (no overlay) -------------------------------
1819            if key == "?":
1820                overlay = "help"
1821                continue
1822            if key == "q":
1823                overlay = "quit"
1824                continue
1825            if key.lower() == "s":
1826                overlay = "save"
1827                save_selected_index = 0
1828                continue
1829            if is_tab_key(key):
1830                active_screen = "run" if active_screen == "targets" else "targets"
1831                status_message = ""
1832                describe_name = ""
1833                continue
1834
1835            status_message = ""
1836
1837            if active_screen == "targets":
1838                vis = visible_targets()
1839                if not vis:
1840                    continue
1841                if target_selected_index >= len(vis):
1842                    target_selected_index = len(vis) - 1
1843                entry = vis[target_selected_index]
1844                record = resolved[entry["id"]]
1845
1846                if is_up_key(key):
1847                    target_selected_index = _move(target_selected_index, -1, len(vis))
1848                elif is_down_key(key):
1849                    target_selected_index = _move(target_selected_index, 1, len(vis))
1850                elif is_page_up_key(key):
1851                    target_selected_index = _move(target_selected_index, -10, len(vis))
1852                elif is_page_down_key(key):
1853                    target_selected_index = _move(target_selected_index, 10, len(vis))
1854                elif key == "y":
1855                    overlay = "typerules"
1856                elif key == "h":
1857                    overlay = "legend"
1858                elif is_right_key(key) or is_left_key(key):
1859                    if entry["is_replaced_root"]:
1860                        expanded_replaced[entry["id"]] = is_right_key(key)
1861                elif key in ("p", "t"):
1862                    if entry["is_replaced_root"] or entry["in_replaced"]:
1863                        status_message = (
1864                            f"{entry['id']} is inside a module that will be restructured "
1865                            "— its id won't exist at training time. Use P/T to target "
1866                            "by type instead."
1867                        )
1868                    else:
1869                        set_module_id_mode(
1870                            entry["id"], "perforated" if key == "p" else "tracked"
1871                        )
1872                elif key in ("P", "T"):
1873                    if entry["is_replaced_root"]:
1874                        status_message = (
1875                            "Set a mode on this module's children by type, not on the "
1876                            "container that will be restructured."
1877                        )
1878                    else:
1879                        set_module_name_mode(
1880                            entry["type_name"], "perforated" if key == "P" else "tracked"
1881                        )
1882                elif key == "x":
1883                    status_message = clear_module_mode(entry)
1884                elif is_enter_key(key):
1885                    scope, reason = get_override_scope_for_entry(entry, resolved)
1886                    if scope is None:
1887                        status_message = reason
1888                    else:
1889                        ov_scope = scope
1890                        ov_selected_index = 0
1891                        describe_name = ""
1892                        overlay = "overrides"
1893                continue
1894
1895            # ---- run settings screen -----------------------------------
1896            items = build_run_items(expanded_buckets)
1897            if not items:
1898                continue
1899            if run_selected_index >= len(items):
1900                run_selected_index = len(items) - 1
1901            item = items[run_selected_index]
1902
1903            if is_up_key(key) or is_down_key(key):
1904                step = -1 if is_up_key(key) else 1
1905                run_selected_index = _move(run_selected_index, step, len(items))
1906                while (
1907                    0 < run_selected_index < len(items) - 1
1908                    and items[run_selected_index]["type"] == "divider"
1909                ):
1910                    run_selected_index = _move(run_selected_index, step, len(items))
1911                describe_name = ""
1912            elif is_page_up_key(key):
1913                run_selected_index = _move(run_selected_index, -10, len(items))
1914            elif is_page_down_key(key):
1915                run_selected_index = _move(run_selected_index, 10, len(items))
1916            elif is_right_key(key):
1917                if item["type"] == "bucket":
1918                    expanded_buckets[item["bucket"]] = True
1919            elif is_left_key(key):
1920                if item["type"] == "bucket":
1921                    expanded_buckets[item["bucket"]] = False
1922                else:
1923                    expanded_buckets[item["bucket"]] = False
1924                    for idx, other in enumerate(items):
1925                        if other["type"] == "bucket" and other["bucket"] == item["bucket"]:
1926                            run_selected_index = idx
1927                            break
1928            elif key == "h" and item["type"] == "setting":
1929                describe_name = "" if describe_name == item["name"] else item["name"]
1930            elif is_enter_key(key):
1931                describe_name = ""
1932                if item["type"] == "bucket":
1933                    expanded_buckets[item["bucket"]] = not item["is_expanded"]
1934                elif item["type"] == "setting":
1935                    name = item["name"]
1936                    value = item["value"]
1937                    if isinstance(value, bool):
1938                        ok, message = apply_setting_value(name, not value)
1939                        status_message = message
1940                    elif get_enum_options(name) is not None:
1941                        ok, message = cycle_enum_setting(name, value)
1942                        status_message = message
1943                    elif isinstance(value, list):
1944                        list_editor_state = _new_list_editor(name, value, scope_key=None)
1945                    elif isinstance(value, (int, float)):
1946                        scalar_editor_state = _new_scalar_editor(name, value, scope_key=None)
1947                    else:
1948                        status_message = f"{name} cannot be edited from this screen."
1949            continue
1950
1951    except KeyboardInterrupt:
1952        quit_requested = True
1953    finally:
1954        if entered_alt_screen:
1955            exit_alternate_screen()
1956        GPA.pc.__dict__["_auto_persist_config"] = previous_auto_persist
1957
1958    if quit_requested:
1959        print("Configuration cancelled — training did not start.")
1960        print(
1961            "Re-run to configure again, or set configuration_confirmed=True "
1962            "(GPA.pc.set_configuration_confirmed(True)) to skip this screen."
1963        )
1964        sys.exit(0)
1965
1966
1967# ---------------------------------------------------------------------------
1968# Inline value editors (scalar + list)
1969# ---------------------------------------------------------------------------
1970def _new_scalar_editor(setting_name, value, scope_key):
1971    text = str(value)
1972    return {
1973        "kind": "scalar",
1974        "setting_name": setting_name,
1975        "sample_value": value,
1976        "input_buffer": text,
1977        "cursor_index": len(text),
1978        "scope_key": scope_key,
1979    }
1980
1981
1982def _new_list_editor(setting_name, value, scope_key):
1983    return {
1984        "kind": "list",
1985        "setting_name": setting_name,
1986        "values": list(value),
1987        "selected_index": 0,
1988        "sample_type": value[0] if value else None,
1989        "typing_active": False,
1990        "edit_index": None,
1991        "input_buffer": "",
1992        "cursor_index": 0,
1993        "scope_key": scope_key,
1994    }
1995
1996
1997def _render_editor_frame(state):
1998    print("\x1b[2J\x1b[H", end="")
1999    name = state["setting_name"]
2000    if state["kind"] == "scalar":
2001        body = render_text_with_cursor(state["input_buffer"], state["cursor_index"])
2002        lines = [
2003            "  " + color_text(f"Edit {name}", "E8EEEC"),
2004            "",
2005            f"  {body}",
2006            "",
2007            dim("  Enter save · Esc cancel"),
2008        ]
2009    else:
2010        lines = [
2011            "  " + color_text(f"Edit {name}", "E8EEEC"),
2012            "",
2013            "  " + _format_list_editor_line(state),
2014            "",
2015            dim(
2016                "  ←/→ move · Enter edit/select · "
2017                "Backspace/Delete remove · Esc cancel"
2018            ),
2019        ]
2020    print(wrap_screen_text_for_terminal("\n".join(lines)))
2021
2022
2023def _format_list_editor_line(state):
2024    values = state["values"]
2025    tokens = []
2026    for i, value in enumerate(values):
2027        if state["typing_active"] and i == state["edit_index"]:
2028            tokens.append(render_text_with_cursor(state["input_buffer"], state["cursor_index"]))
2029        elif i == state["selected_index"] and not state["typing_active"]:
2030            tokens.append(make_inverted_text(str(value)))
2031        else:
2032            tokens.append(str(value))
2033
2034    add_index = len(values)
2035    if state["typing_active"] and state["edit_index"] == add_index:
2036        tokens.append(render_text_with_cursor(state["input_buffer"], state["cursor_index"]))
2037    elif state["selected_index"] == add_index:
2038        tokens.append(make_inverted_text("Add new entry"))
2039    else:
2040        tokens.append("Add new entry")
2041
2042    save_index = len(values) + 1
2043    save_token = "Save list and return"
2044    tokens.append(
2045        make_inverted_text(save_token)
2046        if state["selected_index"] == save_index and not state["typing_active"]
2047        else save_token
2048    )
2049    return " | ".join(tokens)
2050
2051
2052def _persist_edited_value(state, module_settings_overrides, value):
2053    if state["scope_key"] is not None:
2054        set_module_scoped_value(
2055            module_settings_overrides, state["scope_key"], state["setting_name"], value
2056        )
2057        return ""
2058    ok, message = apply_setting_value(state["setting_name"], value)
2059    return "" if ok else message
2060
2061
2062def _handle_scalar_editor(state, key, module_settings_overrides):
2063    buffer_text = state["input_buffer"]
2064    cursor = state["cursor_index"]
2065
2066    if key == "\x1b":
2067        return True, ""
2068    if is_left_key(key):
2069        state["cursor_index"] = max(0, cursor - 1)
2070        return False, ""
2071    if is_right_key(key):
2072        state["cursor_index"] = min(len(buffer_text), cursor + 1)
2073        return False, ""
2074    if key == "\x7f":
2075        if cursor > 0:
2076            state["input_buffer"] = buffer_text[: cursor - 1] + buffer_text[cursor:]
2077            state["cursor_index"] = cursor - 1
2078        return False, ""
2079    if is_delete_key(key):
2080        if cursor < len(buffer_text):
2081            state["input_buffer"] = buffer_text[:cursor] + buffer_text[cursor + 1 :]
2082        return False, ""
2083    if is_enter_key(key):
2084        try:
2085            parsed = parse_value_from_text(state["input_buffer"], state["sample_value"])
2086        except Exception as exc:
2087            return True, f"Invalid value for {state['setting_name']}: {exc}"
2088        return True, _persist_edited_value(state, module_settings_overrides, parsed)
2089    if len(key) == 1 and key >= " ":
2090        state["input_buffer"] = buffer_text[:cursor] + key + buffer_text[cursor:]
2091        state["cursor_index"] = cursor + 1
2092    return False, ""
2093
2094
2095def _handle_list_editor(state, key, module_settings_overrides):
2096    values = state["values"]
2097    add_index = len(values)
2098    save_index = len(values) + 1
2099
2100    if state["typing_active"]:
2101        buffer_text = state["input_buffer"]
2102        cursor = state["cursor_index"]
2103        if key == "\x1b":
2104            state["typing_active"] = False
2105            state["edit_index"] = None
2106            state["input_buffer"] = ""
2107            state["cursor_index"] = 0
2108            return False, ""
2109        if is_left_key(key):
2110            state["cursor_index"] = max(0, cursor - 1)
2111            return False, ""
2112        if is_right_key(key):
2113            state["cursor_index"] = min(len(buffer_text), cursor + 1)
2114            return False, ""
2115        if key == "\x7f":
2116            if cursor > 0:
2117                state["input_buffer"] = buffer_text[: cursor - 1] + buffer_text[cursor:]
2118                state["cursor_index"] = cursor - 1
2119            return False, ""
2120        if is_delete_key(key):
2121            if cursor < len(buffer_text):
2122                state["input_buffer"] = buffer_text[:cursor] + buffer_text[cursor + 1 :]
2123            return False, ""
2124        if is_enter_key(key):
2125            edit_index = state["edit_index"]
2126            sample = state["sample_type"]
2127            if edit_index is not None and edit_index < len(values):
2128                sample = values[edit_index]
2129            try:
2130                parsed = parse_value_from_text(state["input_buffer"], sample)
2131            except Exception as exc:
2132                state["typing_active"] = False
2133                state["edit_index"] = None
2134                state["input_buffer"] = ""
2135                return False, f"Invalid list entry: {exc}"
2136            if edit_index is not None and edit_index < len(values):
2137                values[edit_index] = parsed
2138                state["selected_index"] = edit_index
2139            else:
2140                values.append(parsed)
2141                state["selected_index"] = len(values) - 1
2142            state["typing_active"] = False
2143            state["edit_index"] = None
2144            state["input_buffer"] = ""
2145            state["cursor_index"] = 0
2146            return False, ""
2147        if len(key) == 1 and key >= " ":
2148            state["input_buffer"] = buffer_text[:cursor] + key + buffer_text[cursor:]
2149            state["cursor_index"] = cursor + 1
2150        return False, ""
2151
2152    selected = state["selected_index"]
2153    if key == "\x1b":
2154        return True, ""
2155    if is_left_key(key):
2156        state["selected_index"] = max(0, selected - 1)
2157        return False, ""
2158    if is_right_key(key):
2159        state["selected_index"] = min(save_index, selected + 1)
2160        return False, ""
2161    if key == "\x7f" or is_delete_key(key):
2162        if selected < len(values):
2163            del values[selected]
2164            if state["selected_index"] > len(values) + 1:
2165                state["selected_index"] = len(values) + 1
2166        return False, ""
2167    if is_select_key(key):
2168        if selected == save_index:
2169            return True, _persist_edited_value(
2170                state, module_settings_overrides, list(values)
2171            )
2172        initial_text = str(values[selected]) if selected < len(values) else ""
2173        state["typing_active"] = True
2174        state["edit_index"] = selected
2175        state["input_buffer"] = initial_text
2176        state["cursor_index"] = len(initial_text)
2177    return False, ""
def dedupe_list(values):
29def dedupe_list(values):
30    """Return a list with duplicates removed while preserving order."""
31    seen = set()
32    unique = []
33    for value in values:
34        if value in seen:
35            continue
36        seen.add(value)
37        unique.append(value)
38    return unique

Return a list with duplicates removed while preserving order.

def normalize_selection_conflicts():
41def normalize_selection_conflicts():
42    """Normalize id and name selection lists and remove direct conflicts."""
43    ids_perforate = dedupe_list(GPA.pc.get_module_ids_to_perforate())
44    ids_track = dedupe_list(GPA.pc.get_module_ids_to_track())
45    names_perforate = dedupe_list(GPA.pc.get_module_names_to_perforate())
46    names_track = dedupe_list(GPA.pc.get_module_names_to_track())
47
48    overlap_ids = set(ids_perforate) & set(ids_track)
49    overlap_names = set(names_perforate) & set(names_track)
50
51    if overlap_ids:
52        ids_track = [value for value in ids_track if value not in overlap_ids]
53    if overlap_names:
54        names_track = [value for value in names_track if value not in overlap_names]
55
56    GPA.pc.set_module_ids_to_perforate(ids_perforate)
57    GPA.pc.set_module_ids_to_track(ids_track)
58    GPA.pc.set_module_names_to_perforate(names_perforate)
59    GPA.pc.set_module_names_to_track(names_track)

Normalize id and name selection lists and remove direct conflicts.

def get_module_entries(model):
 62def get_module_entries(model):
 63    """Collect modules for interactive navigation.
 64
 65    Each entry also carries ``is_replaced_root`` (this module's class is in
 66    ``modules_to_replace`` and will be restructured before training) and
 67    ``in_replaced`` / ``replaced_root`` (this module lives under such a module,
 68    so its id will not exist at training time).
 69    """
 70    entries = []
 71    for name, module in model.named_modules(remove_duplicate=False):
 72        if name == "":
 73            continue
 74        module_id = "." + name
 75        depth = name.count(".")
 76        entries.append(
 77            {
 78                "id": module_id,
 79                "name": name,
 80                "type_name": type(module).__name__,
 81                "module": module,
 82                "depth": depth,
 83                "direct_param_count": sum(
 84                    param.numel()
 85                    for _param_name, param in module.named_parameters(recurse=False)
 86                ),
 87                "tree_param_count": sum(
 88                    param.numel()
 89                    for _param_name, param in module.named_parameters(recurse=True)
 90                ),
 91            }
 92        )
 93
 94    replace_classes = tuple(GPA.pc.get_modules_to_replace())
 95    replaced_root_ids = []
 96    for entry in entries:
 97        entry["is_replaced_root"] = (
 98            len(replace_classes) > 0
 99            and isinstance(entry["module"], replace_classes)
100        )
101        if entry["is_replaced_root"]:
102            replaced_root_ids.append(entry["id"])
103
104    for entry in entries:
105        entry["replaced_root"] = None
106        entry["in_replaced"] = False
107        for root_id in replaced_root_ids:
108            if entry["id"].startswith(root_id + "."):
109                entry["replaced_root"] = root_id
110                entry["in_replaced"] = True
111                break
112
113    return entries

Collect modules for interactive navigation.

Each entry also carries is_replaced_root (this module's class is in modules_to_replace and will be restructured before training) and in_replaced / replaced_root (this module lives under such a module, so its id will not exist at training time).

def get_id_mode(module_id):
116def get_id_mode(module_id):
117    """Get explicit id-based mode for a module id ("perforated"/"tracked"/None)."""
118    if module_id in GPA.pc.get_module_ids_to_perforate():
119        return "perforated"
120    if module_id in GPA.pc.get_module_ids_to_track():
121        return "tracked"
122    return None

Get explicit id-based mode for a module id ("perforated"/"tracked"/None).

def get_name_mode(module_type_name):
125def get_name_mode(module_type_name):
126    """Get type-name-based mode for a module type ("perforated"/"tracked"/None)."""
127    if module_type_name in GPA.pc.get_module_names_to_perforate():
128        return "perforated"
129    if module_type_name in GPA.pc.get_module_names_to_track():
130        return "tracked"
131    return None

Get type-name-based mode for a module type ("perforated"/"tracked"/None).

def get_parent_module_id(module_id):
134def get_parent_module_id(module_id):
135    """Return the direct parent module id for a dot-id module path."""
136    parts = module_id.split(".")
137    if len(parts) <= 2:
138        return None
139    return "." + ".".join(parts[1:-1])

Return the direct parent module id for a dot-id module path.

def resolve_entry_modes(entries):
142def resolve_entry_modes(entries):
143    """Resolve every entry's mode, source, and any overridden-by-ancestor state.
144
145    Returns a map of module id to a dict with:
146      - ``eff``:    "perforated" / "tracked" / None
147      - ``source``: "id" / "type" / "inherited" / None
148      - ``origin_id``: the id the mode was set on (for inherited rows)
149      - ``overridden``: {"mode", "source"} when a direct setting here is ignored
150    """
151    resolved = {}
152    by_id = {entry["id"]: entry for entry in entries}
153    for entry in entries:
154        module_id = entry["id"]
155        parent_id = get_parent_module_id(module_id)
156        parent = resolved.get(parent_id) if parent_id in by_id else None
157
158        own_id_mode = get_id_mode(module_id)
159        own_type_mode = get_name_mode(entry["type_name"])
160        own_mode = own_id_mode or own_type_mode
161        own_source = "id" if own_id_mode else ("type" if own_type_mode else None)
162
163        record = {
164            "eff": None,
165            "source": None,
166            "origin_id": module_id,
167            "overridden": None,
168        }
169        if parent is not None and parent["eff"] is not None:
170            record["eff"] = parent["eff"]
171            record["source"] = "inherited"
172            record["origin_id"] = parent["origin_id"]
173            if own_mode is not None:
174                record["overridden"] = {"mode": own_mode, "source": own_source}
175        elif own_mode is not None:
176            record["eff"] = own_mode
177            record["source"] = own_source
178            record["origin_id"] = module_id
179        resolved[module_id] = record
180    return resolved

Resolve every entry's mode, source, and any overridden-by-ancestor state.

Returns a map of module id to a dict with:

  • eff: "perforated" / "tracked" / None
  • source: "id" / "type" / "inherited" / None
  • origin_id: the id the mode was set on (for inherited rows)
  • overridden: {"mode", "source"} when a direct setting here is ignored
COLOR_PERFORATE = '00A5A5'
COLOR_TRACK = '8A95A0'
COLOR_ATTENTION = 'FD4D00'
COLOR_ACCENT = 'D9A441'
def get_color_hex_for_mode(mode):
192def get_color_hex_for_mode(mode):
193    """Map a resolved mode to its color hex value."""
194    if mode == "perforated":
195        return COLOR_PERFORATE
196    if mode == "tracked":
197        return COLOR_TRACK
198    return "000000"

Map a resolved mode to its color hex value.

def format_human_count(value):
201def format_human_count(value):
202    """Format integer counts into compact human-readable units (K/M/B)."""
203    if value < 1000:
204        return str(value)
205    if value < 1000000:
206        scaled = value / 1000.0
207        text = f"{scaled:.1f}"
208        return (text[:-2] if text.endswith(".0") else text) + "K"
209    if value < 1000000000:
210        scaled = value / 1000000.0
211        text = f"{scaled:.1f}"
212        return (text[:-2] if text.endswith(".0") else text) + "M"
213    scaled = value / 1000000000.0
214    text = f"{scaled:.1f}"
215    return (text[:-2] if text.endswith(".0") else text) + "B"

Format integer counts into compact human-readable units (K/M/B).

def color_text(text, hex_color):
222def color_text(text, hex_color):
223    """Render text in an ANSI truecolor foreground."""
224    r = int(hex_color[0:2], 16)
225    g = int(hex_color[2:4], 16)
226    b = int(hex_color[4:6], 16)
227    return f"\x1b[38;2;{r};{g};{b}m{text}\x1b[0m"

Render text in an ANSI truecolor foreground.

def make_color_square(hex_color):
230def make_color_square(hex_color):
231    """Create one colored block character using ANSI truecolor."""
232    return color_text("█", hex_color)

Create one colored block character using ANSI truecolor.

def dim(text):
235def dim(text):
236    """Render text dimmed."""
237    return _ansi(text, "90")

Render text dimmed.

def make_inverted_text(text):
240def make_inverted_text(text):
241    """Render text with inverted foreground/background colors."""
242    return f"\x1b[7m{text}\x1b[0m"

Render text with inverted foreground/background colors.

ANSI_ESCAPE_RE = re.compile('\\x1b\\[[0-9;]*m')
def strip_ansi(text):
248def strip_ansi(text):
249    """Remove ANSI color/style escape codes for width calculation."""
250    return ANSI_ESCAPE_RE.sub("", text)

Remove ANSI color/style escape codes for width calculation.

def get_visual_line_count(lines, terminal_columns):
253def get_visual_line_count(lines, terminal_columns):
254    """Count rendered terminal rows, including wrapped long lines."""
255    if terminal_columns < 1:
256        return len(lines)
257    total = 0
258    for line in lines:
259        plain = strip_ansi(line)
260        if len(plain) == 0:
261            total += 1
262            continue
263        total += (len(plain) + terminal_columns - 1) // terminal_columns
264    return total

Count rendered terminal rows, including wrapped long lines.

def wrap_ansi_line_on_words(line, terminal_columns):
267def wrap_ansi_line_on_words(line, terminal_columns):
268    """Wrap one ANSI-colored line on word boundaries for terminal display."""
269    if terminal_columns < 1:
270        return [line]
271
272    plain = strip_ansi(line)
273    if len(plain) <= terminal_columns:
274        return [line]
275
276    visible_to_raw = []
277    raw_index = 0
278    while raw_index < len(line):
279        if line[raw_index] == "\x1b":
280            match = ANSI_ESCAPE_RE.match(line, raw_index)
281            if match is not None:
282                raw_index = match.end()
283                continue
284        visible_to_raw.append(raw_index)
285        raw_index += 1
286
287    if len(visible_to_raw) == 0:
288        return [line]
289
290    wrapped = []
291    start = 0
292    visible_len = len(plain)
293    while start < visible_len:
294        while start < visible_len and plain[start] == " ":
295            start += 1
296        if start >= visible_len:
297            break
298
299        max_end = min(visible_len, start + terminal_columns)
300        if max_end == visible_len:
301            end = visible_len
302        else:
303            split_at = plain.rfind(" ", start, max_end)
304            end = split_at if split_at > start else max_end
305
306        raw_start = visible_to_raw[start]
307        raw_end = len(line) if end >= visible_len else visible_to_raw[end]
308        wrapped.append(line[raw_start:raw_end].rstrip())
309
310        if end < visible_len and plain[end] == " ":
311            start = end + 1
312        else:
313            start = end
314
315    return wrapped if wrapped else [line]

Wrap one ANSI-colored line on word boundaries for terminal display.

def wrap_screen_text_for_terminal(screen_text):
318def wrap_screen_text_for_terminal(screen_text):
319    """Wrap full screen text to terminal width without mid-word breaks."""
320    terminal_columns = shutil.get_terminal_size(fallback=(120, 40)).columns
321    wrapped_lines = []
322    for raw_line in screen_text.split("\n"):
323        wrapped_lines.extend(wrap_ansi_line_on_words(raw_line, terminal_columns))
324    return "\n".join(wrapped_lines)

Wrap full screen text to terminal width without mid-word breaks.

def render_text_with_cursor(text, cursor_index):
327def render_text_with_cursor(text, cursor_index):
328    """Render text with a CLI-style inverted-character cursor."""
329    if cursor_index < 0:
330        cursor_index = 0
331    if cursor_index > len(text):
332        cursor_index = len(text)
333    if cursor_index == len(text):
334        return text + make_inverted_text(" ")
335    current_char = text[cursor_index]
336    return (
337        text[:cursor_index]
338        + make_inverted_text(current_char)
339        + text[cursor_index + 1 :]
340    )

Render text with a CLI-style inverted-character cursor.

def is_up_key(key):
346def is_up_key(key):
347    if key in ("k",):
348        return True
349    if not key.startswith("\x1b"):
350        return False
351    return key in ("\x1b[A", "\x1bOA") or (key.startswith("\x1b[") and key.endswith("A"))
def is_down_key(key):
354def is_down_key(key):
355    if key in ("j",):
356        return True
357    if not key.startswith("\x1b"):
358        return False
359    return key in ("\x1b[B", "\x1bOB") or (key.startswith("\x1b[") and key.endswith("B"))
def is_left_key(key):
362def is_left_key(key):
363    if not key.startswith("\x1b"):
364        return False
365    return key in ("\x1b[D", "\x1bOD") or (key.startswith("\x1b[") and key.endswith("D"))
def is_right_key(key):
368def is_right_key(key):
369    if not key.startswith("\x1b"):
370        return False
371    return key in ("\x1b[C", "\x1bOC") or (key.startswith("\x1b[") and key.endswith("C"))
def is_page_up_key(key):
374def is_page_up_key(key):
375    return key == "\x1b[5~" or (key.startswith("\x1b[") and "[5" in key and key.endswith("~"))
def is_page_down_key(key):
378def is_page_down_key(key):
379    return key == "\x1b[6~" or (key.startswith("\x1b[") and "[6" in key and key.endswith("~"))
def is_delete_key(key):
382def is_delete_key(key):
383    return key == "\x1b[3~" or (key.startswith("\x1b[") and "[3" in key and key.endswith("~"))
def is_enter_key(key):
386def is_enter_key(key):
387    return key in ("\r", "\n")
def is_tab_key(key):
390def is_tab_key(key):
391    return key == "\t"
def is_select_key(key):
394def is_select_key(key):
395    """Space/Enter — used only inside the inline value editors."""
396    return key in (" ", "\r", "\n")

Space/Enter — used only inside the inline value editors.

def set_module_id_mode(module_id, mode):
402def set_module_id_mode(module_id, mode):
403    """Set (or toggle off) an id-based mode, enforcing id-list exclusivity."""
404    ids_perforate = dedupe_list(GPA.pc.get_module_ids_to_perforate())
405    ids_track = dedupe_list(GPA.pc.get_module_ids_to_track())
406
407    if mode == "perforated":
408        if module_id in ids_perforate:
409            ids_perforate = [value for value in ids_perforate if value != module_id]
410        else:
411            ids_perforate.append(module_id)
412            ids_track = [value for value in ids_track if value != module_id]
413    elif mode == "tracked":
414        if module_id in ids_track:
415            ids_track = [value for value in ids_track if value != module_id]
416        else:
417            ids_track.append(module_id)
418            ids_perforate = [value for value in ids_perforate if value != module_id]
419
420    GPA.pc.set_module_ids_to_perforate(ids_perforate)
421    GPA.pc.set_module_ids_to_track(ids_track)

Set (or toggle off) an id-based mode, enforcing id-list exclusivity.

def set_module_name_mode(module_type_name, mode):
424def set_module_name_mode(module_type_name, mode):
425    """Set (or toggle off) a type-name-based mode, enforcing name-list exclusivity."""
426    names_perforate = dedupe_list(GPA.pc.get_module_names_to_perforate())
427    names_track = dedupe_list(GPA.pc.get_module_names_to_track())
428
429    if mode == "perforated":
430        if module_type_name in names_perforate:
431            names_perforate = [v for v in names_perforate if v != module_type_name]
432        else:
433            names_perforate.append(module_type_name)
434            names_track = [v for v in names_track if v != module_type_name]
435    elif mode == "tracked":
436        if module_type_name in names_track:
437            names_track = [v for v in names_track if v != module_type_name]
438        else:
439            names_track.append(module_type_name)
440            names_perforate = [v for v in names_perforate if v != module_type_name]
441
442    GPA.pc.set_module_names_to_perforate(names_perforate)
443    GPA.pc.set_module_names_to_track(names_track)

Set (or toggle off) a type-name-based mode, enforcing name-list exclusivity.

def clear_module_mode(entry):
446def clear_module_mode(entry):
447    """Clear a direct id rule on this module; if none, clear its type rule.
448
449    Returns a short human message describing what was cleared.
450    """
451    module_id = entry["id"]
452    type_name = entry["type_name"]
453
454    ids_perforate = [v for v in GPA.pc.get_module_ids_to_perforate() if v != module_id]
455    ids_track = [v for v in GPA.pc.get_module_ids_to_track() if v != module_id]
456    if (
457        module_id in GPA.pc.get_module_ids_to_perforate()
458        or module_id in GPA.pc.get_module_ids_to_track()
459    ):
460        GPA.pc.set_module_ids_to_perforate(ids_perforate)
461        GPA.pc.set_module_ids_to_track(ids_track)
462        return f"cleared {module_id}"
463
464    if (
465        type_name in GPA.pc.get_module_names_to_perforate()
466        or type_name in GPA.pc.get_module_names_to_track()
467    ):
468        GPA.pc.set_module_names_to_perforate(
469            [v for v in GPA.pc.get_module_names_to_perforate() if v != type_name]
470        )
471        GPA.pc.set_module_names_to_track(
472            [v for v in GPA.pc.get_module_names_to_track() if v != type_name]
473        )
474        return f"cleared the {type_name} type rule"
475
476    return "nothing to clear on this module"

Clear a direct id rule on this module; if none, clear its type rule.

Returns a short human message describing what was cleared.

def type_rule_entries(entries):
482def type_rule_entries(entries):
483    """Active type-name rules as (type_name, mode, live count) tuples."""
484    counts = {}
485    for entry in entries:
486        if entry["in_replaced"]:
487            continue
488        counts[entry["type_name"]] = counts.get(entry["type_name"], 0) + 1
489
490    rules = []
491    for type_name in sorted(GPA.pc.get_module_names_to_perforate()):
492        rules.append((type_name, "perforated", counts.get(type_name, 0)))
493    for type_name in sorted(GPA.pc.get_module_names_to_track()):
494        rules.append((type_name, "tracked", counts.get(type_name, 0)))
495    return rules

Active type-name rules as (type_name, mode, live count) tuples.

def unset_module_entries(entries, resolved):
498def unset_module_entries(entries, resolved):
499    """Entries that own parameters but have no resolved mode (need attention)."""
500    unset = []
501    for entry in entries:
502        if entry["in_replaced"] or entry["is_replaced_root"]:
503            continue
504        if entry["direct_param_count"] <= 0:
505            continue
506        if resolved[entry["id"]]["eff"] is None:
507            unset.append(entry)
508    return unset

Entries that own parameters but have no resolved mode (need attention).

def build_budget_line(entries, resolved):
511def build_budget_line(entries, resolved):
512    """The perforation-budget line: parameter cost of the current selection."""
513    added = 0
514    total_model_params = 0
515    seen_added = set()
516    seen_total = set()
517
518    for entry in entries:
519        for _name, parameter in entry["module"].named_parameters(recurse=False):
520            pid = id(parameter)
521            if pid not in seen_total:
522                seen_total.add(pid)
523                total_model_params += parameter.numel()
524
525        if resolved[entry["id"]]["eff"] == "perforated":
526            for _name, parameter in entry["module"].named_parameters(recurse=False):
527                pid = id(parameter)
528                if pid not in seen_added:
529                    seen_added.add(pid)
530                    added += parameter.numel()
531
532    pct = (100.0 * added / total_model_params) if total_model_params else 0.0
533    return (
534        f"Perforation budget:  +{format_human_count(added)} params per dendrite cycle"
535        f"  ·  model {format_human_count(total_model_params)}"
536        f"  (+{pct:.1f}% / cycle)"
537    )

The perforation-budget line: parameter cost of the current selection.

RUNTIME_ONLY_CONFIG_KEYS = {'config_file'}
DESCRIPTION_FILE_NAME = 'configuration_descriptions.json'
DESCRIPTION_CACHE = None
LABEL_IMPACTFUL = 'impactful'
LABEL_SUPPORTING = 'supporting'
def load_description_data():
562def load_description_data():
563    """Load bucket and setting metadata from local JSON."""
564    global DESCRIPTION_CACHE
565    if DESCRIPTION_CACHE is not None:
566        return DESCRIPTION_CACHE
567
568    description_file = os.path.join(os.path.dirname(__file__), DESCRIPTION_FILE_NAME)
569    payload = {}
570    try:
571        with open(description_file, "r") as file_handle:
572            payload = json.load(file_handle)
573    except Exception:
574        payload = {}
575
576    bucket_entries = payload.get("buckets", {})
577    bucket_descriptions = {}
578    setting_descriptions = {}
579    setting_labels = {}
580    setting_to_bucket = {}
581    settings_by_bucket = {}
582    bucket_order = []
583
584    if isinstance(bucket_entries, dict):
585        for bucket_name, bucket_payload in bucket_entries.items():
586            bucket_order.append(bucket_name)
587            if isinstance(bucket_payload, dict):
588                bucket_descriptions[bucket_name] = bucket_payload.get("description", "")
589                bucket_settings = bucket_payload.get("settings", {})
590                ordered_setting_names = []
591                if isinstance(bucket_settings, dict):
592                    for setting_name, setting_payload in bucket_settings.items():
593                        ordered_setting_names.append(setting_name)
594                        setting_to_bucket[setting_name] = bucket_name
595                        if isinstance(setting_payload, dict):
596                            setting_descriptions[setting_name] = setting_payload.get(
597                                "description", ""
598                            )
599                            setting_labels[setting_name] = _normalize_label(
600                                setting_payload.get("label")
601                            )
602                        elif isinstance(setting_payload, str):
603                            setting_descriptions[setting_name] = setting_payload
604                            setting_labels[setting_name] = LABEL_SUPPORTING
605                settings_by_bucket[bucket_name] = ordered_setting_names
606            elif isinstance(bucket_payload, str):
607                bucket_descriptions[bucket_name] = bucket_payload
608
609    DESCRIPTION_CACHE = {
610        "buckets": bucket_descriptions,
611        "bucket_order": bucket_order,
612        "settings": setting_descriptions,
613        "setting_labels": setting_labels,
614        "setting_to_bucket": setting_to_bucket,
615        "settings_by_bucket": settings_by_bucket,
616    }
617    return DESCRIPTION_CACHE

Load bucket and setting metadata from local JSON.

def get_setting_label(setting_name):
620def get_setting_label(setting_name):
621    """Get the tier (impactful / supporting) for one setting."""
622    setting_labels = load_description_data().get("setting_labels", {})
623    if setting_name in setting_labels:
624        return _normalize_label(setting_labels[setting_name])
625    return LABEL_SUPPORTING

Get the tier (impactful / supporting) for one setting.

def format_setting_value(value):
628def format_setting_value(value):
629    """Format setting values for compact configuration screen display."""
630    if value is None:
631        return "None"
632    if callable(value):
633        text = GPA.callable_config_repr(value)
634    else:
635        text = str(value)
636    if len(text) > 140:
637        return text[:137] + "..."
638    return text

Format setting values for compact configuration screen display.

SWITCH_MODE_NAMES = ['DOING_SWITCH_EVERY_TIME', 'DOING_HISTORY', 'DOING_FIXED_SWITCH', 'DOING_NO_SWITCH']
FORWARD_FUNCTION_NAMES = ['relu', 'tanh', 'sigmoid']
def get_enum_options(setting_name):
659def get_enum_options(setting_name):
660    """Return the ordered set of values for an enum-like setting, else None."""
661    if setting_name == "switch_mode":
662        return list(_switch_mode_values())
663    if setting_name == "pai_forward_function":
664        return list(FORWARD_FUNCTION_NAMES)
665    return None

Return the ordered set of values for an enum-like setting, else None.

def format_run_setting_value(setting_name, value):
668def format_run_setting_value(setting_name, value):
669    """Display string for a run setting, naming enum values."""
670    if setting_name == "switch_mode":
671        try:
672            return SWITCH_MODE_NAMES[list(_switch_mode_values()).index(value)]
673        except (ValueError, IndexError):
674            return str(value)
675    if setting_name == "pai_forward_function":
676        name = getattr(value, "__name__", None)
677        if name in FORWARD_FUNCTION_NAMES:
678            return name
679        return format_setting_value(value)
680    return format_setting_value(value)

Display string for a run setting, naming enum values.

def cycle_enum_setting(setting_name, current_value):
683def cycle_enum_setting(setting_name, current_value):
684    """Compute and apply the next value for an enum setting."""
685    options = get_enum_options(setting_name)
686    if not options:
687        return False, f"{setting_name} is not an enum setting."
688
689    if setting_name == "pai_forward_function":
690        current_name = getattr(current_value, "__name__", None)
691        index = options.index(current_name) if current_name in options else -1
692        next_name = options[(index + 1) % len(options)]
693        next_value = getattr(torch, next_name) if torch is not None else next_name
694        return apply_setting_value(setting_name, next_value)
695
696    index = options.index(current_value) if current_value in options else -1
697    next_value = options[(index + 1) % len(options)]
698    return apply_setting_value(setting_name, next_value)

Compute and apply the next value for an enum setting.

def get_global_setting_value(setting_name):
701def get_global_setting_value(setting_name):
702    """Read one setting value from the global config object."""
703    getter = getattr(GPA.pc, f"get_{setting_name}", None)
704    if getter is not None:
705        try:
706            return getter()
707        except Exception:
708            return "<unavailable>"
709    if hasattr(GPA.pc, setting_name) and not callable(getattr(GPA.pc, setting_name)):
710        return getattr(GPA.pc, setting_name)
711    return "<unavailable>"

Read one setting value from the global config object.

def apply_setting_value(setting_name, value):
714def apply_setting_value(setting_name, value):
715    """Apply one setting value through a setter when available."""
716    setter = getattr(GPA.pc, f"set_{setting_name}", None)
717    if setter is not None:
718        setter(value)
719        return True, ""
720    if hasattr(GPA.pc, setting_name) and not callable(getattr(GPA.pc, setting_name)):
721        setattr(GPA.pc, setting_name, value)
722        return True, ""
723    return False, f"Setting {setting_name} is not editable."

Apply one setting value through a setter when available.

def parse_value_from_text(text, sample_value):
726def parse_value_from_text(text, sample_value):
727    """Parse user text into the same type as a sample value where possible."""
728    if isinstance(sample_value, bool):
729        lowered = text.strip().lower()
730        if lowered in ("1", "true", "t", "yes", "y", "on"):
731            return True
732        if lowered in ("0", "false", "f", "no", "n", "off"):
733            return False
734        raise ValueError("Expected boolean value (true/false).")
735    if isinstance(sample_value, int) and not isinstance(sample_value, bool):
736        return int(text.strip())
737    if isinstance(sample_value, float):
738        return float(text.strip())
739    return text

Parse user text into the same type as a sample value where possible.

def get_all_global_parameters():
742def get_all_global_parameters():
743    """Collect JSON-declared run settings as name -> current value."""
744    description_data = load_description_data()
745    visible_names = []
746    for bucket_name in description_data.get("bucket_order", []):
747        visible_names.extend(
748            description_data.get("settings_by_bucket", {}).get(bucket_name, [])
749        )
750    values = {}
751    for setting_name in visible_names:
752        if setting_name in RUNTIME_ONLY_CONFIG_KEYS:
753            continue
754        values[setting_name] = get_global_setting_value(setting_name)
755    return values

Collect JSON-declared run settings as name -> current value.

def build_run_items(expanded_buckets):
758def build_run_items(expanded_buckets):
759    """Build the Run settings list: bucket rows, tier-sorted settings, dividers."""
760    values = get_all_global_parameters()
761    description_data = load_description_data()
762    items = []
763
764    for bucket_name in description_data.get("bucket_order", []):
765        names = [
766            name
767            for name in description_data.get("settings_by_bucket", {}).get(bucket_name, [])
768            if name not in RUNTIME_ONLY_CONFIG_KEYS
769        ]
770        if not names:
771            continue
772
773        is_expanded = expanded_buckets.get(bucket_name, False)
774        items.append(
775            {
776                "type": "bucket",
777                "bucket": bucket_name,
778                "is_expanded": is_expanded,
779                "count": len(names),
780            }
781        )
782        if not is_expanded:
783            continue
784
785        impactful = [n for n in names if get_setting_label(n) == LABEL_IMPACTFUL]
786        supporting = [n for n in names if get_setting_label(n) != LABEL_IMPACTFUL]
787
788        def _setting_item(setting_name):
789            return {
790                "type": "setting",
791                "bucket": bucket_name,
792                "name": setting_name,
793                "value": values.get(setting_name, "<unavailable>"),
794            }
795
796        for setting_name in impactful:
797            items.append(_setting_item(setting_name))
798        if impactful and supporting:
799            items.append({"type": "divider", "bucket": bucket_name})
800        for setting_name in supporting:
801            items.append(_setting_item(setting_name))
802
803    return items

Build the Run settings list: bucket rows, tier-sorted settings, dividers.

def load_existing_module_settings():
809def load_existing_module_settings():
810    """Load existing module_settings from local config sources."""
811    merged = {}
812    source_paths = []
813    config_file = GPA.pc.get_config_file()
814    run_config = GPA.pc.get_run_config_path()
815    if config_file:
816        source_paths.append(config_file)
817    if run_config:
818        source_paths.append(run_config)
819
820    for source_path in source_paths:
821        if not source_path or not os.path.exists(source_path):
822            continue
823        try:
824            with open(source_path, "r") as file_handle:
825                payload = json.load(file_handle)
826            module_settings = payload.get("module_settings", {})
827            for scope_key, values in module_settings.items():
828                if isinstance(values, dict):
829                    merged[scope_key] = dict(values)
830        except Exception:
831            continue
832    return merged

Load existing module_settings from local config sources.

def get_scope_values(scope_key, module_settings_overrides, existing_module_settings):
835def get_scope_values(scope_key, module_settings_overrides, existing_module_settings):
836    """Merged override values for one scope: saved data plus in-session edits."""
837    values = {}
838    if scope_key in existing_module_settings:
839        values.update(existing_module_settings[scope_key])
840    if scope_key in module_settings_overrides:
841        values.update(module_settings_overrides[scope_key])
842    return values

Merged override values for one scope: saved data plus in-session edits.

def set_module_scoped_value(module_settings_overrides, scope_key, setting_name, value):
845def set_module_scoped_value(module_settings_overrides, scope_key, setting_name, value):
846    """Write one override value into in-session state."""
847    module_settings_overrides.setdefault(scope_key, {})[setting_name] = value

Write one override value into in-session state.

def get_override_scope_for_entry(entry, resolved):
850def get_override_scope_for_entry(entry, resolved):
851    """Determine the override scope for a target, or a reason it is blocked."""
852    record = resolved[entry["id"]]
853    if entry["is_replaced_root"] or entry["in_replaced"]:
854        return None, (
855            "Overrides are not available inside a module that will be restructured."
856        )
857    if record["eff"] is None:
858        return None, (
859            "Overrides are only for perforated modules. Perforate this one first "
860            "(p), or track it if you just want it monitored."
861        )
862    if record["source"] == "inherited":
863        origin = record["origin_id"]
864        return None, (
865            f"This module inherits {origin}'s mode. Open overrides on {origin} to "
866            "change settings for the whole subtree."
867        )
868    if record["eff"] != "perforated":
869        return None, "Overrides are only for perforated modules — this one is tracked."
870
871    if record["source"] == "type":
872        return (
873            {"kind": "type", "scope_key": entry["type_name"]},
874            None,
875        )
876    return ({"kind": "id", "scope_key": entry["id"]}, None)

Determine the override scope for a target, or a reason it is blocked.

def persist_module_settings_updates(module_settings_overrides, overwrite_config_file=False):
879def persist_module_settings_updates(module_settings_overrides, overwrite_config_file=False):
880    """Persist in-session module_settings updates to local config JSON files."""
881    if not module_settings_overrides:
882        return
883
884    destinations = []
885    run_config = GPA.pc.get_run_config_path()
886    if run_config:
887        destinations.append(run_config)
888    if overwrite_config_file:
889        config_file = GPA.pc.get_config_file()
890        if config_file and config_file not in destinations:
891            destinations.append(config_file)
892
893    for destination in destinations:
894        payload = {}
895        if os.path.exists(destination):
896            try:
897                with open(destination, "r") as file_handle:
898                    payload = json.load(file_handle)
899            except Exception:
900                payload = {}
901
902        module_settings = payload.get("module_settings", {})
903        if not isinstance(module_settings, dict):
904            module_settings = {}
905        for scope_key, values in module_settings_overrides.items():
906            existing = module_settings.get(scope_key, {})
907            if not isinstance(existing, dict):
908                existing = {}
909            existing.update(values)
910            module_settings[scope_key] = existing
911        payload["module_settings"] = module_settings
912
913        directory = os.path.dirname(destination)
914        if directory:
915            os.makedirs(directory, exist_ok=True)
916        with open(destination, "w") as file_handle:
917            json.dump(payload, file_handle, indent=2)

Persist in-session module_settings updates to local config JSON files.

CUSTOMIZABLE_SETTING_NAMES = ['output_dimensions']
def get_setting_description(setting_name):
923def get_setting_description(setting_name):
924    """Help text for one setting."""
925    descriptions = load_description_data().get("settings", {})
926    return descriptions.get(setting_name, f"No description available for {setting_name}.")

Help text for one setting.

def get_bucket_description(bucket_name):
929def get_bucket_description(bucket_name):
930    descriptions = load_description_data().get("buckets", {})
931    return descriptions.get(bucket_name, "No description available.")
HR = ' ──────────────────────────────────────────────────────────────────────'
def terminal_size():
940def terminal_size():
941    return shutil.get_terminal_size(fallback=(120, 40))
def render_tab_bar(active_screen):
944def render_tab_bar(active_screen):
945    """The `[ Targets | Run settings ]` tab bar with the active screen lit."""
946    targets = "Targets"
947    run = "Run settings"
948    if active_screen == "targets":
949        targets = color_text(targets, COLOR_ACCENT)
950    else:
951        run = color_text(run, COLOR_ACCENT)
952    return f"[ {targets} │ {run} ]" + " " * 12 + dim("[Tab] switch panel    ? help")

The [ Targets | Run settings ] tab bar with the active screen lit.

MODE_VERB = {'perforated': 'perforate', 'tracked': 'track'}
def mode_verb(mode):
958def mode_verb(mode):
959    """Present-tense verb form of a resolved mode for user-facing display."""
960    return MODE_VERB.get(mode, str(mode))

Present-tense verb form of a resolved mode for user-facing display.

def make_target_marker(entry, record):
963def make_target_marker(entry, record):
964    """(display, plain) marker for one target row, per D8."""
965    if entry["in_replaced"]:
966        return "     ", "     "
967
968    mode = record["eff"]
969    if mode is None:
970        if entry["direct_param_count"] > 0:
971            glyph_display = color_text("!", COLOR_ATTENTION)
972            block_display = color_text("██", COLOR_ATTENTION)
973            return f"{glyph_display} {block_display} ", "! ██ "
974        return "     ", "     "
975
976    block_display = make_block(mode)
977    if record["source"] == "inherited":
978        return f"↳ {block_display} ", "↳ ██ "
979    if record["source"] == "id":
980        return f"* {block_display} ", "* ██ "
981    return f"  {block_display} ", "  ██ "

(display, plain) marker for one target row, per D8.

def make_block(mode):
984def make_block(mode):
985    return color_text("██", get_color_hex_for_mode(mode))
def render_targets_lines( entries, visible_entries, selected_index, resolved, expanded_replaced):
 988def render_targets_lines(entries, visible_entries, selected_index, resolved, expanded_replaced):
 989    """Header + tree + footer for the Targets screen, as a list of lines."""
 990    need = len(unset_module_entries(entries, resolved))
 991    rules = type_rule_entries(entries)
 992
 993    lines = [render_tab_bar("targets"), ""]
 994    lines.append(
 995        dim(
 996            "  perforate = add dendrites   ·   track = not perforated"
 997            "   "
 998        )
 999    )
1000    lines.append("")
1001
1002    hint = color_text(f"[y] type rules ({len(rules)})", COLOR_ACCENT)
1003    if need > 0:
1004        noun = "module" if need == 1 else "modules"
1005        verb = "needs" if need == 1 else "need"
1006        status = color_text(f"  ⚠ {need} {noun} still {verb} a mode", COLOR_ATTENTION)
1007    else:
1008        status = color_text("  ✓ every parameter is perforated or tracked", COLOR_PERFORATE)
1009    pad = max(2, 58 - len(strip_ansi(status)))
1010    lines.append(status + " " * pad + hint)
1011    lines.append(HR)
1012
1013    def entry_lines(entry, selected):
1014        cursor = color_text("> ", "E8EEEC") if selected else "  "
1015        indent = "  " * entry["depth"]
1016        record = resolved[entry["id"]]
1017
1018        if entry["is_replaced_root"]:
1019            child_count = sum(1 for e in entries if e["replaced_root"] == entry["id"])
1020            glyph = color_text("↯", COLOR_ACCENT)
1021            is_open = expanded_replaced.get(entry["id"], False)
1022            toggle = "[← collapse]" if is_open else "[→ expand]"
1023            return [
1024                f"{cursor}{glyph}   {indent}{entry['id']}  "
1025                f"{dim('[' + entry['type_name'] + ']')}   "
1026                f"{dim('will be restructured for PAI before training')}",
1027                "      "
1028                + dim(
1029                    f"└ {child_count} modules below — ids won't exist at "
1030                    f"train time · target by type (P/T), not id  "
1031                )
1032                + color_text(toggle, COLOR_ACCENT),
1033            ]
1034
1035        marker_display, marker_plain = make_target_marker(entry, record)
1036
1037        if entry["in_replaced"]:
1038            id_part = dim(f"{entry['id']}  [{entry['type_name']}] (id stale)")
1039            return [f"{cursor}{marker_display} {indent}{id_part}"]
1040
1041        id_part = f"{entry['id']}  {dim('[' + entry['type_name'] + ']')}"
1042
1043        tail = ""
1044        if record["eff"] is not None:
1045            mode_color = get_color_hex_for_mode(record["eff"])
1046            tail = color_text(f"mode={mode_verb(record['eff'])}", mode_color)
1047            if record["source"] == "inherited":
1048                tail += dim(f"  (via {record['origin_id']})")
1049                if record["overridden"] is not None:
1050                    tail += dim(
1051                        f"  — your {mode_verb(record['overridden']['mode'])} here is ignored"
1052                    )
1053        elif entry["direct_param_count"] > 0:
1054            tail = color_text("needs a mode", COLOR_ATTENTION)
1055
1056        params = (
1057            dim(f"   params={format_human_count(entry['direct_param_count'])}")
1058            if entry["direct_param_count"] > 0
1059            else ""
1060        )
1061
1062        lead = (
1063            2 + len(marker_plain) + 1 + len(indent) + len(entry["id"])
1064            + 2 + len(entry["type_name"]) + 2
1065        )
1066        gap = max(2, 46 - lead)
1067        return [
1068            f"{cursor}{marker_display} {indent}{id_part}" + " " * gap + tail + params
1069        ]
1070
1071    body_lines = []
1072    focus = (0, 1)
1073    for i, entry in enumerate(visible_entries):
1074        rendered = entry_lines(entry, i == selected_index)
1075        if i == selected_index:
1076            focus = (len(body_lines), len(body_lines) + len(rendered))
1077        body_lines.extend(rendered)
1078
1079    footer = [
1080        HR,
1081        dim(build_budget_line(entries, resolved)),
1082        "",
1083        dim(
1084            "p/t perforate·track this module   P/T whole type   x clear   "
1085            "h legend   ↑↓/jk move   s start"
1086        ),
1087    ]
1088    return lines, body_lines, footer, focus

Header + tree + footer for the Targets screen, as a list of lines.

def render_run_settings_lines(items, selected_index, describe_name):
1091def render_run_settings_lines(items, selected_index, describe_name):
1092    """Header + item body + footer for the Run settings screen."""
1093    lines = [
1094        render_tab_bar("run"),
1095        "",
1096        dim("  Run settings — run-wide configuration"),
1097        HR,
1098    ]
1099
1100    body_lines = []
1101    for i, item in enumerate(items):
1102        selected = i == selected_index
1103        cursor = color_text("> ", "E8EEEC") if selected else "  "
1104        if item["type"] == "bucket":
1105            glyph = color_text("▾" if item["is_expanded"] else "▸", COLOR_ACCENT)
1106            body_lines.append(
1107                f"{cursor}{glyph} {item['bucket']} {dim('(' + str(item['count']) + ')')}"
1108            )
1109        elif item["type"] == "divider":
1110            body_lines.append("     " + dim("─ rarely changed ─"))
1111        else:
1112            value_text = format_run_setting_value(item["name"], item["value"])
1113            hint = ""
1114            if get_enum_options(item["name"]) is not None:
1115                hint = dim("  (Enter cycles)")
1116            elif isinstance(item["value"], bool):
1117                hint = dim("  (Enter toggles)")
1118            body_lines.append(f"{cursor}  {item['name']} = {value_text}{hint}")
1119
1120    footer_line = dim(
1121        "→ expand · ← collapse   Enter edit   h describe   "
1122        "↑↓/jk move   Tab switch panel   s start"
1123    )
1124    if describe_name:
1125        footer_line = dim(f"{describe_name} — {get_setting_description(describe_name)}")
1126    footer = [HR, footer_line]
1127    focus = (selected_index, selected_index + 1)
1128    return lines, body_lines, footer, focus

Header + item body + footer for the Run settings screen.

def compose_scrolling_screen(header_lines, body_lines, footer_lines, window_start, focus=None):
1131def compose_scrolling_screen(header_lines, body_lines, footer_lines, window_start, focus=None):
1132    """Join a header + windowed body + footer into one screen string.
1133
1134    Body rows may wrap to more than one terminal row, so the window is measured
1135    in *rendered* rows (after wrapping), not logical lines — otherwise a narrow
1136    terminal pushes the footer and budget line off-screen.
1137    """
1138    size = terminal_size()
1139    columns = size.columns
1140    reserved = get_visual_line_count(header_lines, columns) + get_visual_line_count(
1141        footer_lines, columns
1142    )
1143    # Two rows held back for the ^^^^ / vvvv overflow indicators.
1144    budget = max(1, size.lines - reserved - 3 - 2)
1145
1146    total = len(body_lines)
1147
1148    def rows_for(line):
1149        return max(1, get_visual_line_count([line], columns))
1150
1151    def window_end_from(start):
1152        used = 0
1153        end = start
1154        while end < total:
1155            height = rows_for(body_lines[end])
1156            if end > start and used + height > budget:
1157                break
1158            used += height
1159            end += 1
1160        return end
1161
1162    def window_start_for_end(end):
1163        used = 0
1164        start = end
1165        while start > 0:
1166            height = rows_for(body_lines[start - 1])
1167            if used + height > budget:
1168                break
1169            used += height
1170            start -= 1
1171        return start
1172
1173    if focus is not None:
1174        focus_start, focus_end = focus
1175        focus_start = max(0, min(focus_start, total))
1176        focus_end = max(focus_start + 1, min(focus_end, total))
1177        if focus_start < window_start:
1178            window_start = focus_start
1179        if window_end_from(window_start) < focus_end:
1180            window_start = window_start_for_end(focus_end)
1181
1182    window_start = max(0, min(window_start, total))
1183    window_end = window_end_from(window_start)
1184    # Grow upward if there is spare room at the bottom of the list.
1185    if window_end >= total:
1186        window_start = min(window_start, window_start_for_end(total))
1187        window_end = total
1188
1189    out = list(header_lines)
1190    if window_start > 0:
1191        out.append(dim("  ^^^^ more above ^^^^"))
1192    out.extend(body_lines[window_start:window_end])
1193    if window_end < total:
1194        out.append(dim("  vvvv more below vvvv"))
1195    out.extend(footer_lines)
1196    return "\n".join(out), window_start

Join a header + windowed body + footer into one screen string.

Body rows may wrap to more than one terminal row, so the window is measured in rendered rows (after wrapping), not logical lines — otherwise a narrow terminal pushes the footer and budget line off-screen.

def render_static_overlay(lines):
1199def render_static_overlay(lines):
1200    return "\n".join(lines)
def render_type_rules_overlay(entries):
1203def render_type_rules_overlay(entries):
1204    rules = type_rule_entries(entries)
1205    rows = []
1206    if rules:
1207        for type_name, mode, count in rules:
1208            plain = f"{type_name} → {mode_verb(mode)}  ({count})"
1209            html = (
1210                type_name
1211                + " "
1212                + color_text(f"→ {mode_verb(mode)}", get_color_hex_for_mode(mode))
1213                + dim(f"  ({count})")
1214            )
1215            rows.append((plain, html))
1216    else:
1217        rows.append(("none yet — P / T on a row sets one",
1218                     dim("none yet — P / T on a row sets one")))
1219
1220    width = max(38, max(len(plain) for plain, _ in rows) + 2)
1221    box = ["  " + dim("┌─ Type rules " + "─" * max(1, width - 12) + "┐")]
1222    for plain, html in rows:
1223        box.append("  " + dim("│ ") + html + " " * (width - len(plain) - 1) + dim("│"))
1224    box.append("  " + dim("└" + "─" * width + "┘"))
1225    box.append("  " + color_text("y or Esc to close", COLOR_ACCENT))
1226    return box
def render_overrides_overlay( scope, ov_selected_index, module_settings_overrides, existing_module_settings, describe_name):
1229def render_overrides_overlay(scope, ov_selected_index, module_settings_overrides,
1230                             existing_module_settings, describe_name):
1231    if scope["kind"] == "id":
1232        title = f"Overrides — {scope['scope_key']}"
1233    else:
1234        title = f"Overrides — every {scope['scope_key']}"
1235
1236    scoped = get_scope_values(
1237        scope["scope_key"], module_settings_overrides, existing_module_settings
1238    )
1239
1240    lines = [render_tab_bar("targets"), "", "  " + color_text(title, COLOR_ACCENT), HR, ""]
1241    if not CUSTOMIZABLE_SETTING_NAMES:
1242        lines.append("  " + dim("No per-target settings are customizable in this build."))
1243    for i, name in enumerate(CUSTOMIZABLE_SETTING_NAMES):
1244        overridden = name in scoped
1245        value = scoped[name] if overridden else get_global_setting_value(name)
1246        cursor = color_text("> ", "E8EEEC") if i == ov_selected_index else "  "
1247        tag = dim("(overridden here)") if overridden else dim("(from run settings)")
1248        lines.append(
1249            f"{cursor}{name} = {format_run_setting_value(name, value)}  {tag}"
1250        )
1251
1252    lines.append("")
1253    footer_line = dim(
1254        "Enter edit · h describe · ↑↓/jk move · Esc / x close"
1255    )
1256    if describe_name:
1257        footer_line = dim(f"{describe_name} — {get_setting_description(describe_name)}")
1258    lines.append(footer_line)
1259    return lines
def render_save_overlay(entries, resolved, save_selected_index, config_target_path):
1262def render_save_overlay(entries, resolved, save_selected_index, config_target_path):
1263    unset = unset_module_entries(entries, resolved)
1264    lines = ["", "  " + color_text("Save configuration", "E8EEEC"), ""]
1265
1266    if unset:
1267        noun = "module has" if len(unset) == 1 else "modules have"
1268        lines.append(
1269            color_text(
1270                f"  ⚠  {len(unset)} {noun} parameters but no mode",
1271                COLOR_ATTENTION,
1272            )
1273        )
1274        lines.append("")
1275        for entry in unset[:5]:
1276            lines.append(f"      {dim(entry['id'] + '   [' + entry['type_name'] + ']')}")
1277        if len(unset) > 5:
1278            lines.append(f"      {dim('(+' + str(len(unset) - 5) + ' more)')}")
1279        lines.append("")
1280        lines.append(
1281            dim(
1282                "  These parameters get no dendrites and are not tracked. "
1283                "Usually a mistake."
1284            )
1285        )
1286        lines.append("")
1287        options = [
1288            "Keep editing",
1289            f"Start anyway — I know these {len(unset)} have no mode",
1290        ]
1291    else:
1292        lines.append(dim("  Start training with this configuration:"))
1293        lines.append("")
1294        options = [
1295            ("just this run", "don't touch my saved config"),
1296            (
1297                "and save it as default",
1298                f"write it to {config_target_path}, skip this screen next time",
1299            ),
1300            ("Keep editing", ""),
1301        ]
1302
1303    for i, option in enumerate(options):
1304        marked = i == save_selected_index
1305        pointer = color_text("  ▸ ", "E8EEEC") if marked else "    "
1306        if isinstance(option, tuple):
1307            label, detail = option
1308            text = color_text(label, "E8EEEC") if marked else label
1309            if detail:
1310                text += dim(f"  — {detail}")
1311            lines.append(pointer + text)
1312        else:
1313            text = color_text(option, "E8EEEC") if marked else dim(option)
1314            lines.append(pointer + text)
1315
1316    lines.append("")
1317    lines.append("")
1318    lines.append(
1319        dim("  ↑↓/jk move · Enter choose · Esc / x back to editing")
1320    )
1321    return lines
def render_help_overlay():
1324def render_help_overlay():
1325    p = lambda t: color_text(t, COLOR_PERFORATE)
1326    tr = lambda t: color_text(t, COLOR_TRACK)
1327    return [
1328        "",
1329        "  " + color_text("Keys", "E8EEEC"),
1330        "",
1331        dim("  Targets screen"),
1332        "   p / t     perforate / track this module",
1333        "   P / T     perforate / track every module of this type",
1334        "   x         clear this module (falls back to its type rule)",
1335        "   → / ←     expand / collapse a module that will be restructured (↯)",
1336        "   y         show / hide the type-rules list",
1337        "   h         show the marker & colour legend",
1338        "   Enter     open Overrides for a perforated module",
1339        "",
1340        dim("  Run settings screen"),
1341        "   → / ←     expand / collapse a bucket",
1342        "   Enter     edit the highlighted setting",
1343        "   h         describe the highlighted setting",
1344        "",
1345        dim("  Everywhere"),
1346        "   ↑↓ / jk   move        Tab  switch panel",
1347        "   s         save & start training",
1348        "   q         quit without configuring",
1349        "",
1350        dim("  Modes"),
1351        "   " + p("perforate") + " = dendrites are added here during training",
1352        "   " + tr("track") + "     = no dendrites, the parameters are just counted",
1353        "   " + dim("↳ inherited") + " = a mode set on an ancestor applies to the whole subtree",
1354        "   " + dim("(see the h legend on the Targets panel for how modes resolve)"),
1355        "",
1356        color_text("  ? or Esc to close", COLOR_ACCENT),
1357    ]
def render_legend_overlay():
1360def render_legend_overlay():
1361    """Describe the Targets-screen markers and colours."""
1362    perf = color_text("██", COLOR_PERFORATE)
1363    trk = color_text("██", COLOR_TRACK)
1364    att = color_text("██", COLOR_ATTENTION)
1365    return [
1366        "",
1367        "  " + color_text("Targets legend", "E8EEEC"),
1368        "",
1369        dim("  Mode blocks"),
1370        f"   {perf}        " + color_text("perforate", COLOR_PERFORATE)
1371        + " — dendrites are added here during training",
1372        f"   {trk}        " + color_text("track", COLOR_TRACK)
1373        + " — no dendrites, the parameters are just counted",
1374        "",
1375        dim("  Marker prefixes"),
1376        f"   * {perf}     mode set directly on this module (by id)",
1377        f"     {perf}     set by type name — every module of this class",
1378        f"   ↳ {perf}     inherited from an ancestor; applies to the whole subtree",
1379        f"   ! {att}     has parameters but no mode — needs attention",
1380        "   ↯         will be restructured for PAI before training;",
1381        "             target the modules inside it by type (P/T), not by id",
1382        "   (none)    structural container with no parameters of its own",
1383        "",
1384        dim("  Which mode wins  (highest priority first)"),
1385        "   1. inherited — a mode on an ancestor applies to its whole",
1386        "      subtree; a mode set directly on a descendant is ignored",
1387        "      while the ancestor's mode is in effect",
1388        "   2. by id — a mode set on this exact module",
1389        "   3. by type — a mode set on the module's class name",
1390        "   So: inherited  >  by id  >  by type.",
1391        "",
1392        color_text("  h or Esc to close", COLOR_ACCENT),
1393    ]

Describe the Targets-screen markers and colours.

def render_quit_overlay():
1396def render_quit_overlay():
1397    return [
1398        "",
1399        "",
1400        "",
1401        "  " + color_text("Quit without configuring?", "E8EEEC"),
1402        "",
1403        dim("  Training will not start. Re-run to configure again, or set"),
1404        dim("  configuration_confirmed=True to skip this screen."),
1405        "",
1406        "  " + color_text("[y] quit", COLOR_ATTENTION) + "     " + dim("[n] keep configuring"),
1407    ]
def read_single_key():
1413def read_single_key():
1414    """Read one keypress, including arrow keys, from stdin.
1415
1416    Ctrl-C raises KeyboardInterrupt so the caller can exit cleanly.
1417    """
1418    file_descriptor = sys.stdin.fileno()
1419    old_settings = termios.tcgetattr(file_descriptor)
1420    try:
1421        tty.setraw(file_descriptor)
1422        first = os.read(file_descriptor, 1)
1423        if first == b"\x03":
1424            raise KeyboardInterrupt
1425        if first != b"\x1b":
1426            return first.decode("latin1")
1427
1428        sequence = bytearray(first)
1429        ready, _, _ = select.select([file_descriptor], [], [], 0.05)
1430        if not ready:
1431            return "\x1b"
1432        sequence.extend(os.read(file_descriptor, 1))
1433
1434        if sequence[1] in (ord("["), ord("O")):
1435            ready, _, _ = select.select([file_descriptor], [], [], 0.15)
1436            if ready:
1437                sequence.extend(os.read(file_descriptor, 1))
1438
1439        while True:
1440            last_byte = sequence[-1]
1441            if chr(last_byte).isalpha() or last_byte == ord("~"):
1442                break
1443            ready, _, _ = select.select([file_descriptor], [], [], 0.01)
1444            if not ready:
1445                break
1446            sequence.extend(os.read(file_descriptor, 1))
1447
1448        return bytes(sequence).decode("latin1")
1449    finally:
1450        termios.tcsetattr(file_descriptor, termios.TCSADRAIN, old_settings)

Read one keypress, including arrow keys, from stdin.

Ctrl-C raises KeyboardInterrupt so the caller can exit cleanly.

def enter_alternate_screen():
1453def enter_alternate_screen():
1454    """Switch terminal to alternate screen buffer for the interactive UI."""
1455    print("\x1b[?1049h\x1b[?25l\x1b[2J\x1b[H", end="", flush=True)

Switch terminal to alternate screen buffer for the interactive UI.

def exit_alternate_screen():
1458def exit_alternate_screen():
1459    """Return terminal to the normal screen buffer."""
1460    print("\x1b[?25h\x1b[?1049l", end="", flush=True)

Return terminal to the normal screen buffer.

def require_interactive_session():
1499def require_interactive_session():
1500    """Raise a clear error if the TUI cannot be driven in this process.
1501
1502    The configuration TUI reads raw keystrokes from the terminal. Under CI,
1503    ``nohup``, notebooks, piped stdin, or non-rank-0 distributed workers there is
1504    no usable TTY, so we fail fast with actionable guidance instead of crashing
1505    inside ``termios``.
1506    """
1507    if termios is None:
1508        raise RuntimeError(
1509            "The interactive perforation-configuration TUI is not available on this "
1510            "platform (no termios). Set configuration_confirmed=True in your config "
1511            "file (or call GPA.pc.set_configuration_confirmed(True)) and configure "
1512            "targets via the config file or Perforated Studio instead."
1513        )
1514
1515    try:
1516        is_tty = bool(sys.stdin.isatty() and sys.stdout.isatty())
1517    except (ValueError, AttributeError, OSError):
1518        is_tty = False
1519    rank = _distributed_rank()
1520
1521    if is_tty and rank == 0:
1522        return
1523
1524    reason = "no interactive terminal is attached" if not is_tty else f"process rank is {rank}, not 0"
1525    raise RuntimeError(
1526        "Perforation configuration has not been confirmed, and it cannot be done "
1527        f"interactively here ({reason}).\n"
1528        "Run perforate_model once from an interactive terminal to choose "
1529        "perforation targets, or set configuration_confirmed=True in your config "
1530        "file (or call GPA.pc.set_configuration_confirmed(True) before "
1531        "perforate_model) to skip the interactive step."
1532    )

Raise a clear error if the TUI cannot be driven in this process.

The configuration TUI reads raw keystrokes from the terminal. Under CI, nohup, notebooks, piped stdin, or non-rank-0 distributed workers there is no usable TTY, so we fail fast with actionable guidance instead of crashing inside termios.

def set_perforation_targets(model):
1535def set_perforation_targets(model):
1536    """Interactive configuration TUI for perforation targets and run settings.
1537
1538    Two screens (Tab to switch): Targets (the module tree) and Run settings.
1539    See the `?` overlay for the full key reference.
1540    """
1541    require_interactive_session()
1542    previous_auto_persist = GPA.pc.__dict__.get("_auto_persist_config", True)
1543    GPA.pc.__dict__["_auto_persist_config"] = False
1544    entered_alt_screen = False
1545    quit_requested = False
1546
1547    try:
1548        enter_alternate_screen()
1549        entered_alt_screen = True
1550        normalize_selection_conflicts()
1551
1552        entries = get_module_entries(model)
1553        if len(entries) == 0:
1554            print(model)
1555            input("Press Enter to confirm perforation targets...")
1556            GPA.pc.set_configuration_confirmed(True)
1557            return
1558
1559        active_screen = "targets"
1560        overlay = None
1561        status_message = ""
1562
1563        target_selected_index = 0
1564        target_window_start = 0
1565        expanded_replaced = {}
1566
1567        run_selected_index = 0
1568        run_window_start = 0
1569        expanded_buckets = {}
1570        describe_name = ""
1571
1572        ov_scope = None
1573        ov_selected_index = 0
1574        module_settings_overrides = {}
1575        existing_module_settings = load_existing_module_settings()
1576
1577        save_selected_index = 0
1578
1579        scalar_editor_state = None
1580        list_editor_state = None
1581
1582        def finalize_save(choice_index):
1583            if choice_index == 0:
1584                GPA.pc.persist_config_outputs(overwrite_config_file=False)
1585                persist_module_settings_updates(
1586                    module_settings_overrides, overwrite_config_file=False
1587                )
1588                return
1589
1590            config_file = GPA.pc.get_config_file()
1591            if not config_file:
1592                current_save_name = GPA.pc.get_save_name() or "PAI"
1593                relative_config_name = _config_target_path()
1594                exit_alternate_screen()
1595                print("\x1b[2J\x1b[H", end="")
1596                print("No config_file is set.")
1597                print(
1598                    "Enter a local JSON filename/path to create a reusable "
1599                    "configuration file."
1600                )
1601                print(
1602                    f"You currently have save_name='{current_save_name}'. Choose "
1603                    f"'{relative_config_name}' to skip this screen next time without "
1604                    "changing your perforate_model call."
1605                )
1606                while True:
1607                    filename = input("Config filename/path: ").strip()
1608                    if filename:
1609                        GPA.pc.__dict__["_config_file"] = filename
1610                        break
1611                    print("A filename is required to save a default configuration.")
1612                enter_alternate_screen()
1613
1614            GPA.pc.persist_config_outputs(overwrite_config_file=True)
1615            persist_module_settings_updates(
1616                module_settings_overrides, overwrite_config_file=True
1617            )
1618
1619        def visible_targets():
1620            out = []
1621            for entry in entries:
1622                if entry["in_replaced"] and not expanded_replaced.get(
1623                    entry["replaced_root"], False
1624                ):
1625                    continue
1626                out.append(entry)
1627            return out
1628
1629        while True:
1630            normalize_selection_conflicts()
1631            resolved = resolve_entry_modes(entries)
1632
1633            # ---- editors capture input first -------------------------------
1634            if scalar_editor_state is not None:
1635                _render_editor_frame(scalar_editor_state)
1636                key = read_single_key()
1637                done, status_message = _handle_scalar_editor(
1638                    scalar_editor_state, key, module_settings_overrides
1639                )
1640                if done:
1641                    scalar_editor_state = None
1642                continue
1643
1644            if list_editor_state is not None:
1645                _render_editor_frame(list_editor_state)
1646                key = read_single_key()
1647                done, status_message = _handle_list_editor(
1648                    list_editor_state, key, module_settings_overrides
1649                )
1650                if done:
1651                    list_editor_state = None
1652                continue
1653
1654            # ---- draw the current screen ---------------------------------
1655            print("\x1b[2J\x1b[H", end="")
1656
1657            if overlay == "help":
1658                screen_text = render_static_overlay(render_help_overlay())
1659            elif overlay == "legend":
1660                screen_text = render_static_overlay(render_legend_overlay())
1661            elif overlay == "quit":
1662                screen_text = render_static_overlay(render_quit_overlay())
1663            elif overlay == "save":
1664                screen_text = render_static_overlay(
1665                    render_save_overlay(
1666                        entries, resolved, save_selected_index, _config_target_path()
1667                    )
1668                )
1669            elif overlay == "overrides":
1670                screen_text = render_static_overlay(
1671                    render_overrides_overlay(
1672                        ov_scope,
1673                        ov_selected_index,
1674                        module_settings_overrides,
1675                        existing_module_settings,
1676                        describe_name,
1677                    )
1678                )
1679            elif active_screen == "targets":
1680                vis = visible_targets()
1681                if target_selected_index >= len(vis):
1682                    target_selected_index = max(0, len(vis) - 1)
1683                header, body, footer, focus = render_targets_lines(
1684                    entries, vis, target_selected_index, resolved, expanded_replaced
1685                )
1686                if overlay == "typerules":
1687                    header = header + [""] + render_type_rules_overlay(entries) + [""]
1688                if status_message:
1689                    footer = footer + [color_text("  " + status_message, COLOR_ACCENT)]
1690                screen_text, target_window_start = compose_scrolling_screen(
1691                    header, body, footer, target_window_start, focus
1692                )
1693            else:
1694                items = build_run_items(expanded_buckets)
1695                if run_selected_index >= len(items):
1696                    run_selected_index = max(0, len(items) - 1)
1697                header, body, footer, focus = render_run_settings_lines(
1698                    items, run_selected_index, describe_name
1699                )
1700                if status_message:
1701                    footer = footer + [color_text("  " + status_message, COLOR_ACCENT)]
1702                screen_text, run_window_start = compose_scrolling_screen(
1703                    header, body, footer, run_window_start, focus
1704                )
1705
1706            print(wrap_screen_text_for_terminal(screen_text))
1707            key = read_single_key()
1708
1709            # ---- overlay key handling -----------------------------------
1710            if overlay == "help":
1711                if key == "?" or key == "\x1b":
1712                    overlay = None
1713                continue
1714
1715            if overlay == "legend":
1716                if key == "h" or key == "\x1b":
1717                    overlay = None
1718                continue
1719
1720            if overlay == "quit":
1721                if key in ("y", "Y"):
1722                    quit_requested = True
1723                    break
1724                if key in ("n", "N") or key == "\x1b":
1725                    overlay = None
1726                continue
1727
1728            if overlay == "typerules":
1729                if key in ("y", "Y") or key == "\x1b":
1730                    overlay = None
1731                elif key.lower() == "s":
1732                    overlay = "save"
1733                    save_selected_index = 0
1734                continue
1735
1736            if overlay == "save":
1737                unset = unset_module_entries(entries, resolved)
1738                option_count = 2 if unset else 3
1739                if is_up_key(key):
1740                    save_selected_index = _move(save_selected_index, -1, option_count)
1741                elif is_down_key(key):
1742                    save_selected_index = _move(save_selected_index, 1, option_count)
1743                elif key == "\x1b" or key == "x":
1744                    overlay = None
1745                elif is_enter_key(key):
1746                    if unset:
1747                        if save_selected_index == 0:
1748                            overlay = None
1749                        else:
1750                            finalize_save(0)
1751                            GPA.pc.set_configuration_confirmed(True)
1752                            break
1753                    else:
1754                        if save_selected_index == 2:
1755                            overlay = None
1756                        elif save_selected_index == 1:
1757                            GPA.pc.set_configuration_confirmed(True)
1758                            finalize_save(1)
1759                            break
1760                        else:
1761                            finalize_save(0)
1762                            GPA.pc.set_configuration_confirmed(True)
1763                            break
1764                continue
1765
1766            if overlay == "overrides":
1767                names = CUSTOMIZABLE_SETTING_NAMES
1768                if key == "\x1b" or key == "x":
1769                    overlay = None
1770                    describe_name = ""
1771                elif is_up_key(key):
1772                    ov_selected_index = _move(ov_selected_index, -1, len(names))
1773                    describe_name = ""
1774                elif is_down_key(key):
1775                    ov_selected_index = _move(ov_selected_index, 1, len(names))
1776                    describe_name = ""
1777                elif key == "h" and names:
1778                    current = names[ov_selected_index]
1779                    describe_name = "" if describe_name == current else current
1780                elif is_enter_key(key) and names:
1781                    name = names[ov_selected_index]
1782                    scoped = get_scope_values(
1783                        ov_scope["scope_key"],
1784                        module_settings_overrides,
1785                        existing_module_settings,
1786                    )
1787                    value = scoped[name] if name in scoped else get_global_setting_value(name)
1788                    if isinstance(value, bool):
1789                        set_module_scoped_value(
1790                            module_settings_overrides, ov_scope["scope_key"], name, not value
1791                        )
1792                    elif get_enum_options(name) is not None:
1793                        options = get_enum_options(name)
1794                        try:
1795                            index = options.index(value)
1796                        except ValueError:
1797                            index = -1
1798                        set_module_scoped_value(
1799                            module_settings_overrides,
1800                            ov_scope["scope_key"],
1801                            name,
1802                            options[(index + 1) % len(options)],
1803                        )
1804                    elif isinstance(value, list):
1805                        list_editor_state = _new_list_editor(
1806                            name, value, scope_key=ov_scope["scope_key"]
1807                        )
1808                    elif isinstance(value, (int, float)):
1809                        scalar_editor_state = _new_scalar_editor(
1810                            name, value, scope_key=ov_scope["scope_key"]
1811                        )
1812                    else:
1813                        status_message = f"{name} cannot be edited here."
1814                elif key.lower() == "s":
1815                    overlay = "save"
1816                    save_selected_index = 0
1817                continue
1818
1819            # ---- global keys (no overlay) -------------------------------
1820            if key == "?":
1821                overlay = "help"
1822                continue
1823            if key == "q":
1824                overlay = "quit"
1825                continue
1826            if key.lower() == "s":
1827                overlay = "save"
1828                save_selected_index = 0
1829                continue
1830            if is_tab_key(key):
1831                active_screen = "run" if active_screen == "targets" else "targets"
1832                status_message = ""
1833                describe_name = ""
1834                continue
1835
1836            status_message = ""
1837
1838            if active_screen == "targets":
1839                vis = visible_targets()
1840                if not vis:
1841                    continue
1842                if target_selected_index >= len(vis):
1843                    target_selected_index = len(vis) - 1
1844                entry = vis[target_selected_index]
1845                record = resolved[entry["id"]]
1846
1847                if is_up_key(key):
1848                    target_selected_index = _move(target_selected_index, -1, len(vis))
1849                elif is_down_key(key):
1850                    target_selected_index = _move(target_selected_index, 1, len(vis))
1851                elif is_page_up_key(key):
1852                    target_selected_index = _move(target_selected_index, -10, len(vis))
1853                elif is_page_down_key(key):
1854                    target_selected_index = _move(target_selected_index, 10, len(vis))
1855                elif key == "y":
1856                    overlay = "typerules"
1857                elif key == "h":
1858                    overlay = "legend"
1859                elif is_right_key(key) or is_left_key(key):
1860                    if entry["is_replaced_root"]:
1861                        expanded_replaced[entry["id"]] = is_right_key(key)
1862                elif key in ("p", "t"):
1863                    if entry["is_replaced_root"] or entry["in_replaced"]:
1864                        status_message = (
1865                            f"{entry['id']} is inside a module that will be restructured "
1866                            "— its id won't exist at training time. Use P/T to target "
1867                            "by type instead."
1868                        )
1869                    else:
1870                        set_module_id_mode(
1871                            entry["id"], "perforated" if key == "p" else "tracked"
1872                        )
1873                elif key in ("P", "T"):
1874                    if entry["is_replaced_root"]:
1875                        status_message = (
1876                            "Set a mode on this module's children by type, not on the "
1877                            "container that will be restructured."
1878                        )
1879                    else:
1880                        set_module_name_mode(
1881                            entry["type_name"], "perforated" if key == "P" else "tracked"
1882                        )
1883                elif key == "x":
1884                    status_message = clear_module_mode(entry)
1885                elif is_enter_key(key):
1886                    scope, reason = get_override_scope_for_entry(entry, resolved)
1887                    if scope is None:
1888                        status_message = reason
1889                    else:
1890                        ov_scope = scope
1891                        ov_selected_index = 0
1892                        describe_name = ""
1893                        overlay = "overrides"
1894                continue
1895
1896            # ---- run settings screen -----------------------------------
1897            items = build_run_items(expanded_buckets)
1898            if not items:
1899                continue
1900            if run_selected_index >= len(items):
1901                run_selected_index = len(items) - 1
1902            item = items[run_selected_index]
1903
1904            if is_up_key(key) or is_down_key(key):
1905                step = -1 if is_up_key(key) else 1
1906                run_selected_index = _move(run_selected_index, step, len(items))
1907                while (
1908                    0 < run_selected_index < len(items) - 1
1909                    and items[run_selected_index]["type"] == "divider"
1910                ):
1911                    run_selected_index = _move(run_selected_index, step, len(items))
1912                describe_name = ""
1913            elif is_page_up_key(key):
1914                run_selected_index = _move(run_selected_index, -10, len(items))
1915            elif is_page_down_key(key):
1916                run_selected_index = _move(run_selected_index, 10, len(items))
1917            elif is_right_key(key):
1918                if item["type"] == "bucket":
1919                    expanded_buckets[item["bucket"]] = True
1920            elif is_left_key(key):
1921                if item["type"] == "bucket":
1922                    expanded_buckets[item["bucket"]] = False
1923                else:
1924                    expanded_buckets[item["bucket"]] = False
1925                    for idx, other in enumerate(items):
1926                        if other["type"] == "bucket" and other["bucket"] == item["bucket"]:
1927                            run_selected_index = idx
1928                            break
1929            elif key == "h" and item["type"] == "setting":
1930                describe_name = "" if describe_name == item["name"] else item["name"]
1931            elif is_enter_key(key):
1932                describe_name = ""
1933                if item["type"] == "bucket":
1934                    expanded_buckets[item["bucket"]] = not item["is_expanded"]
1935                elif item["type"] == "setting":
1936                    name = item["name"]
1937                    value = item["value"]
1938                    if isinstance(value, bool):
1939                        ok, message = apply_setting_value(name, not value)
1940                        status_message = message
1941                    elif get_enum_options(name) is not None:
1942                        ok, message = cycle_enum_setting(name, value)
1943                        status_message = message
1944                    elif isinstance(value, list):
1945                        list_editor_state = _new_list_editor(name, value, scope_key=None)
1946                    elif isinstance(value, (int, float)):
1947                        scalar_editor_state = _new_scalar_editor(name, value, scope_key=None)
1948                    else:
1949                        status_message = f"{name} cannot be edited from this screen."
1950            continue
1951
1952    except KeyboardInterrupt:
1953        quit_requested = True
1954    finally:
1955        if entered_alt_screen:
1956            exit_alternate_screen()
1957        GPA.pc.__dict__["_auto_persist_config"] = previous_auto_persist
1958
1959    if quit_requested:
1960        print("Configuration cancelled — training did not start.")
1961        print(
1962            "Re-run to configure again, or set configuration_confirmed=True "
1963            "(GPA.pc.set_configuration_confirmed(True)) to skip this screen."
1964        )
1965        sys.exit(0)

Interactive configuration TUI for perforation targets and run settings.

Two screens (Tab to switch): Targets (the module tree) and Run settings. See the ? overlay for the full key reference.