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, ""
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.
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.
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).
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).
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).
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.
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" / Nonesource: "id" / "type" / "inherited" / Noneorigin_id: the id the mode was set on (for inherited rows)overridden: {"mode", "source"} when a direct setting here is ignored
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.
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).
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.
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.
Render text dimmed.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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
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
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 ]
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.
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 ]
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.
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.
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.
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.
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.