topoexploit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
topogpt3/inference.py ADDED
@@ -0,0 +1,731 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ TopoGPT3 inference engine.
4
+
5
+ Production-grade autoregressive code completion pipeline for TopoGPT3
6
+ checkpoints. Loads weights from safetensors, aligns the underlying TopoGPT2
7
+ architecture against the stored tensors, optionally applies the Gauss
8
+ complex-multiply patch for numerical parity with training, and performs
9
+ sampling with repetition penalty and top-k filtering.
10
+
11
+ The pipeline is decomposed into single-responsibility collaborators wired by
12
+ an orchestrator. All paths, sampling parameters, safety bounds and string
13
+ identifiers live inside InferenceSettings so that business logic contains no
14
+ magic numbers or hardcoded constants.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import logging
20
+ import sys
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List, Mapping, Optional
25
+
26
+ import torch
27
+ from safetensors import safe_open
28
+ from safetensors.torch import load_file
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ScalePreset:
33
+ """Immutable architecture preset for a named model scale."""
34
+
35
+ d_model: int
36
+ n_heads: int
37
+ n_layers: int
38
+ max_seq_len: int
39
+
40
+
41
+ @dataclass
42
+ class InferenceSettings:
43
+ """Centralized configuration container for the inference pipeline.
44
+
45
+ Every value consumed downstream resides here. Adding a new tunable means
46
+ extending this class; no other module should embed literals.
47
+ """
48
+
49
+ model_scale: str = "small"
50
+ device: str = field(
51
+ default_factory=lambda: "cuda" if torch.cuda.is_available() else "cpu"
52
+ )
53
+ seed: int = 42
54
+
55
+ checkpoint_dir: str = "checkpoints_topogpt3"
56
+ checkpoint_name: str = "last"
57
+ model_filename: str = "model.safetensors"
58
+ state_filename: str = "state.json"
59
+
60
+ topogpt3_source_filename: str = "train.py"
61
+ topogpt2_source_filename: str = "model.py"
62
+ source_directory: str = "."
63
+
64
+ tokenizer_encoding: str = "gpt2"
65
+ weight_probe_key: str = "layers.0.attn.k_proj.weight"
66
+
67
+ apply_gauss_patch_at_load: bool = True
68
+ strict_state_dict: bool = False
69
+
70
+ prompt: str = "def main("
71
+ max_new_tokens: int = 256
72
+ temperature: float = 0.3
73
+ top_k: int = 50
74
+ repetition_penalty: float = 1.1
75
+ auto_continue: bool = False
76
+ max_continuations: int = 3
77
+ continuation_tail_lines: int = 2
78
+
79
+ log_level: str = "INFO"
80
+ log_name: str = "TopoGPT3Inference"
81
+ log_format: str = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
82
+ log_date_format: str = "%Y-%m-%d %H:%M:%S"
83
+
84
+ result_separator_char: str = "="
85
+ result_separator_width: int = 72
86
+ prompt_label: str = "PROMPT"
87
+ completion_label: str = "COMPLETION"
88
+ elapsed_floor_seconds: float = 1e-3
89
+
90
+ min_max_new_tokens: int = 1
91
+ max_max_new_tokens: int = 8192
92
+ min_temperature: float = 1e-3
93
+ max_temperature: float = 5.0
94
+ min_top_k: int = 0
95
+ min_repetition_penalty: float = 0.5
96
+ max_repetition_penalty: float = 5.0
97
+ max_checkpoint_bytes: int = 50 * (1024 ** 3)
98
+ min_checkpoint_bytes: int = 1024
99
+
100
+ runtime_module_alias: str = "topogpt3_inference_runtime"
101
+
102
+ @staticmethod
103
+ def scale_presets() -> Mapping[str, ScalePreset]:
104
+ """Return the architecture preset table indexed by scale name."""
105
+ return {
106
+ "micro": ScalePreset(d_model=64, n_heads=4, n_layers=2,
107
+ max_seq_len=256),
108
+ "small": ScalePreset(d_model=256, n_heads=8, n_layers=6,
109
+ max_seq_len=512),
110
+ "medium": ScalePreset(d_model=512, n_heads=8, n_layers=12,
111
+ max_seq_len=1024),
112
+ "large": ScalePreset(d_model=512, n_heads=8, n_layers=13,
113
+ max_seq_len=1024),
114
+ "gpt2": ScalePreset(d_model=768, n_heads=12, n_layers=12,
115
+ max_seq_len=2048),
116
+ }
117
+
118
+ def preset(self) -> ScalePreset:
119
+ """Return the resolved preset for the configured model scale."""
120
+ table = self.scale_presets()
121
+ if self.model_scale not in table:
122
+ raise ValueError(
123
+ f"Unknown model scale '{self.model_scale}'. "
124
+ f"Allowed: {sorted(table.keys())}"
125
+ )
126
+ return table[self.model_scale]
127
+
128
+ def validate(self) -> None:
129
+ """Raise ValueError if any setting falls outside its safety bounds."""
130
+ if not (self.min_max_new_tokens
131
+ <= self.max_new_tokens <= self.max_max_new_tokens):
132
+ raise ValueError(
133
+ f"max_new_tokens={self.max_new_tokens} outside "
134
+ f"[{self.min_max_new_tokens}, {self.max_max_new_tokens}]"
135
+ )
136
+ if not (self.min_temperature
137
+ <= self.temperature <= self.max_temperature):
138
+ raise ValueError(
139
+ f"temperature={self.temperature} outside "
140
+ f"[{self.min_temperature}, {self.max_temperature}]"
141
+ )
142
+ if self.top_k < self.min_top_k:
143
+ raise ValueError(
144
+ f"top_k={self.top_k} must be >= {self.min_top_k}"
145
+ )
146
+ if not (self.min_repetition_penalty
147
+ <= self.repetition_penalty <= self.max_repetition_penalty):
148
+ raise ValueError(
149
+ f"repetition_penalty={self.repetition_penalty} outside "
150
+ f"[{self.min_repetition_penalty}, "
151
+ f"{self.max_repetition_penalty}]"
152
+ )
153
+ if self.result_separator_width <= 0:
154
+ raise ValueError("result_separator_width must be positive")
155
+ if self.elapsed_floor_seconds <= 0:
156
+ raise ValueError("elapsed_floor_seconds must be positive")
157
+ self.preset()
158
+
159
+
160
+ class InferenceLoggerFactory:
161
+ """Builds a stdout-attached logger from inference settings."""
162
+
163
+ @staticmethod
164
+ def build(settings: InferenceSettings) -> logging.Logger:
165
+ """Return a configured Logger with a single deduplicated stdout handler."""
166
+ logger = logging.getLogger(settings.log_name)
167
+ level = getattr(logging, settings.log_level.upper(), logging.INFO)
168
+ logger.setLevel(level)
169
+ if not logger.handlers:
170
+ handler = logging.StreamHandler(sys.stdout)
171
+ handler.setFormatter(logging.Formatter(
172
+ fmt=settings.log_format,
173
+ datefmt=settings.log_date_format,
174
+ ))
175
+ logger.addHandler(handler)
176
+ logger.propagate = False
177
+ return logger
178
+
179
+
180
+ class SecurePathResolver:
181
+ """Resolves filesystem paths while rejecting traversal outside their root."""
182
+
183
+ @staticmethod
184
+ def resolve_under(root: Path, *parts: str) -> Path:
185
+ """Join `parts` under `root` and return the canonical resolved path.
186
+
187
+ Raises ValueError if the resolved path escapes `root`.
188
+ """
189
+ resolved_root = root.resolve()
190
+ candidate = resolved_root.joinpath(*parts).resolve()
191
+ try:
192
+ candidate.relative_to(resolved_root)
193
+ except ValueError as exc:
194
+ raise ValueError(
195
+ f"Path '{candidate}' escapes root '{resolved_root}'."
196
+ ) from exc
197
+ return candidate
198
+
199
+ @staticmethod
200
+ def require_existing_file(path: Path,
201
+ expected_suffix: Optional[str] = None) -> Path:
202
+ """Validate `path` points to an existing regular file with the expected suffix."""
203
+ if not path.exists():
204
+ raise FileNotFoundError(f"Required file not found: {path}")
205
+ if not path.is_file():
206
+ raise ValueError(f"Path is not a regular file: {path}")
207
+ if expected_suffix is not None and path.suffix != expected_suffix:
208
+ raise ValueError(
209
+ f"Expected suffix '{expected_suffix}', got '{path.suffix}': {path}"
210
+ )
211
+ return path
212
+
213
+
214
+ class SourceModuleLoader:
215
+ """Resolves the TopoGPT3 runtime module via the package import system."""
216
+
217
+ def __init__(self, settings: InferenceSettings, logger: logging.Logger):
218
+ self._settings = settings
219
+ self._logger = logger
220
+
221
+ def load(self) -> Any:
222
+ """Return the topogpt3.train module which re-exports model symbols."""
223
+ from . import train as runtime_module
224
+ self._logger.info(
225
+ "Loaded TopoGPT3 runtime module: %s", runtime_module.__name__,
226
+ )
227
+ return runtime_module
228
+
229
+
230
+ class CheckpointPaths:
231
+ """Computes and validates checkpoint file paths under a single root."""
232
+
233
+ def __init__(self, settings: InferenceSettings):
234
+ self._settings = settings
235
+ self._root = Path(settings.checkpoint_dir).resolve()
236
+ self._slot = SecurePathResolver.resolve_under(
237
+ self._root, settings.checkpoint_name,
238
+ )
239
+
240
+ @property
241
+ def slot_dir(self) -> Path:
242
+ """Directory holding the active checkpoint slot."""
243
+ return self._slot
244
+
245
+ def model_file(self) -> Path:
246
+ """Resolved path to the safetensors weights file inside the slot."""
247
+ return SecurePathResolver.resolve_under(
248
+ self._slot, self._settings.model_filename,
249
+ )
250
+
251
+ def state_file(self) -> Path:
252
+ """Resolved path to the JSON training-state file inside the slot."""
253
+ return SecurePathResolver.resolve_under(
254
+ self._slot, self._settings.state_filename,
255
+ )
256
+
257
+ def assert_ready(self) -> None:
258
+ """Verify weights exist and the on-disk size lies within safety bounds."""
259
+ weights = self.model_file()
260
+ SecurePathResolver.require_existing_file(
261
+ weights, expected_suffix=Path(self._settings.model_filename).suffix,
262
+ )
263
+ size_bytes = weights.stat().st_size
264
+ if size_bytes < self._settings.min_checkpoint_bytes:
265
+ raise ValueError(
266
+ f"Checkpoint too small ({size_bytes} bytes): {weights}"
267
+ )
268
+ if size_bytes > self._settings.max_checkpoint_bytes:
269
+ raise ValueError(
270
+ f"Checkpoint exceeds allowed size "
271
+ f"({size_bytes} > {self._settings.max_checkpoint_bytes} bytes): "
272
+ f"{weights}"
273
+ )
274
+
275
+
276
+ class WeightShapeProbe:
277
+ """Reads tensor metadata from safetensors to infer architecture details."""
278
+
279
+ def __init__(self, settings: InferenceSettings, logger: logging.Logger):
280
+ self._settings = settings
281
+ self._logger = logger
282
+
283
+ def detect_n_kv_heads(self, weights_path: Path,
284
+ d_model: int, n_heads: int) -> Optional[int]:
285
+ """Recover N_KV_HEADS used at training by inspecting the k_proj shape.
286
+
287
+ Returns None when the probe key is absent, signalling the caller to
288
+ fall back to scale defaults rather than guess.
289
+ """
290
+ if n_heads <= 0 or d_model <= 0:
291
+ raise ValueError("d_model and n_heads must be positive")
292
+ if d_model % n_heads != 0:
293
+ raise ValueError("d_model must be divisible by n_heads")
294
+ d_head = d_model // n_heads
295
+
296
+ probe_key = self._settings.weight_probe_key
297
+ with safe_open(str(weights_path), framework="pt", device="cpu") as handle:
298
+ keys = set(handle.keys())
299
+ if probe_key not in keys:
300
+ self._logger.warning(
301
+ "Probe key '%s' missing from checkpoint; "
302
+ "falling back to scale defaults.",
303
+ probe_key,
304
+ )
305
+ return None
306
+ tensor = handle.get_tensor(probe_key)
307
+ k_dim = int(tensor.shape[0])
308
+ if k_dim % d_head != 0:
309
+ raise ValueError(
310
+ f"k_proj output dim ({k_dim}) is not divisible by d_head ({d_head})"
311
+ )
312
+ n_kv = k_dim // d_head
313
+ self._logger.info(
314
+ "Detected N_KV_HEADS=%d (k_dim=%d, d_head=%d).",
315
+ n_kv, k_dim, d_head,
316
+ )
317
+ return n_kv
318
+
319
+
320
+ class TopoGPT2ConfigAligner:
321
+ """Builds a TopoGPT2Config matching the loaded checkpoint and tokenizer."""
322
+
323
+ def __init__(self, settings: InferenceSettings, source_module: Any,
324
+ logger: logging.Logger):
325
+ self._settings = settings
326
+ self._source = source_module
327
+ self._logger = logger
328
+
329
+ def build(self, n_kv_heads: Optional[int], vocab_size: int) -> Any:
330
+ """Return a TopoGPT2Config dataclass ready to instantiate the model."""
331
+ cfg_cls = getattr(self._source, "TopoGPT2Config")
332
+ kwargs: Dict[str, Any] = {
333
+ "SCALE": self._settings.model_scale,
334
+ "DEVICE": self._settings.device,
335
+ "RANDOM_SEED": self._settings.seed,
336
+ "CHECKPOINT_DIR": self._settings.checkpoint_dir,
337
+ }
338
+ if n_kv_heads is not None and n_kv_heads > 0:
339
+ kwargs["N_KV_HEADS"] = n_kv_heads
340
+ cfg = cfg_cls(**kwargs)
341
+ cfg.VOCAB_SIZE = vocab_size
342
+ self._logger.info(
343
+ "Aligned TopoGPT2Config: scale=%s d_model=%d n_heads=%d "
344
+ "n_layers=%d n_kv_heads=%d vocab=%d max_seq=%d device=%s",
345
+ cfg.SCALE, cfg.D_MODEL, cfg.N_HEADS, cfg.N_LAYERS,
346
+ cfg.N_KV_HEADS, cfg.VOCAB_SIZE, cfg.MAX_SEQ_LEN, cfg.DEVICE,
347
+ )
348
+ return cfg
349
+
350
+
351
+ class TokenizerFactory:
352
+ """Builds a BPETokenizer instance using the configured encoding."""
353
+
354
+ def __init__(self, settings: InferenceSettings, source_module: Any):
355
+ self._settings = settings
356
+ self._source = source_module
357
+
358
+ def build(self) -> Any:
359
+ """Return an instance of BPETokenizer bound to the configured encoding."""
360
+ tokenizer_cls = getattr(self._source, "BPETokenizer")
361
+ return tokenizer_cls(self._settings.tokenizer_encoding)
362
+
363
+
364
+ class GaussPatchApplier:
365
+ """Applies the idempotent Gauss complex-multiply patch when enabled."""
366
+
367
+ def __init__(self, settings: InferenceSettings, source_module: Any,
368
+ logger: logging.Logger):
369
+ self._settings = settings
370
+ self._source = source_module
371
+ self._logger = logger
372
+
373
+ def apply_if_enabled(self) -> None:
374
+ """Patch QuaternionSpectralLayer to use the 3-multiply Gauss contract."""
375
+ if not self._settings.apply_gauss_patch_at_load:
376
+ self._logger.info("Gauss complex-multiply patch disabled by settings.")
377
+ return
378
+ patch_fn = getattr(self._source, "apply_gauss_patch")
379
+ patch_fn(self._logger)
380
+
381
+
382
+ class ModelAssembler:
383
+ """Instantiates the model and loads weights from safetensors."""
384
+
385
+ def __init__(self, settings: InferenceSettings, source_module: Any,
386
+ logger: logging.Logger):
387
+ self._settings = settings
388
+ self._source = source_module
389
+ self._logger = logger
390
+
391
+ def assemble(self, aligned_cfg: Any,
392
+ paths: CheckpointPaths) -> torch.nn.Module:
393
+ """Build the TopoGPT2 graph, load weights into it, and return it in eval mode."""
394
+ model_cls = getattr(self._source, "TopoGPT2")
395
+ model = model_cls(aligned_cfg)
396
+ model.to(aligned_cfg.DEVICE)
397
+ model.eval()
398
+
399
+ params = model.count_params()
400
+ self._logger.info(
401
+ "Model parameters: total=%s trainable=%s",
402
+ f"{params['total']:,}", f"{params['trainable']:,}",
403
+ )
404
+
405
+ weights_path = paths.model_file()
406
+ self._logger.info("Loading weights from %s", weights_path)
407
+ state_dict = load_file(str(weights_path), device=aligned_cfg.DEVICE)
408
+ missing, unexpected = model.load_state_dict(
409
+ state_dict, strict=self._settings.strict_state_dict,
410
+ )
411
+ if missing:
412
+ self._logger.info("Missing keys (likely tied weights): %s", missing)
413
+ if unexpected:
414
+ self._logger.info("Unexpected keys ignored: %s", unexpected)
415
+ self._logger.info("Weights loaded successfully.")
416
+ return model
417
+
418
+
419
+ class SeedSynchronizer:
420
+ """Applies deterministic seeds across torch, CUDA and the model package."""
421
+
422
+ def __init__(self, settings: InferenceSettings, source_module: Any,
423
+ logger: logging.Logger):
424
+ self._settings = settings
425
+ self._source = source_module
426
+ self._logger = logger
427
+
428
+ def apply(self) -> None:
429
+ """Seed all relevant RNGs using the model package helper when available."""
430
+ torch.manual_seed(self._settings.seed)
431
+ if torch.cuda.is_available():
432
+ torch.cuda.manual_seed_all(self._settings.seed)
433
+ set_seed_fn = getattr(self._source, "set_seed", None)
434
+ if callable(set_seed_fn):
435
+ set_seed_fn(self._settings.seed, self._settings.device)
436
+ self._logger.info(
437
+ "Seed synchronized (seed=%d device=%s).",
438
+ self._settings.seed, self._settings.device,
439
+ )
440
+
441
+
442
+ @dataclass(frozen=True)
443
+ class SamplingPolicy:
444
+ """Immutable sampling parameters consumed by the generation engine."""
445
+
446
+ max_new_tokens: int
447
+ temperature: float
448
+ top_k: int
449
+ repetition_penalty: float
450
+
451
+ @classmethod
452
+ def from_settings(cls, settings: InferenceSettings) -> "SamplingPolicy":
453
+ """Construct a SamplingPolicy from inference settings."""
454
+ return cls(
455
+ max_new_tokens=settings.max_new_tokens,
456
+ temperature=settings.temperature,
457
+ top_k=settings.top_k,
458
+ repetition_penalty=settings.repetition_penalty,
459
+ )
460
+
461
+
462
+ @dataclass(frozen=True)
463
+ class GenerationReport:
464
+ """Quantitative summary of a single generation call."""
465
+
466
+ prompt: str
467
+ output: str
468
+ prompt_tokens: int
469
+ new_tokens: int
470
+ elapsed_seconds: float
471
+
472
+ def tokens_per_second(self, elapsed_floor: float) -> float:
473
+ """Return throughput in tokens/sec, clamped to avoid divide-by-zero."""
474
+ return self.new_tokens / max(self.elapsed_seconds, elapsed_floor)
475
+
476
+
477
+ class GenerationEngine:
478
+ """Runs autoregressive sampling against a loaded model and tokenizer."""
479
+
480
+ def __init__(self, settings: InferenceSettings, logger: logging.Logger):
481
+ self._settings = settings
482
+ self._logger = logger
483
+
484
+ @torch.no_grad()
485
+ def run(self, model: torch.nn.Module, tokenizer: Any, prompt: str,
486
+ policy: SamplingPolicy) -> GenerationReport:
487
+ """Generate a completion for `prompt` and return a GenerationReport."""
488
+ prompt_ids = tokenizer.encode(prompt)
489
+ if len(prompt_ids) == 0:
490
+ raise ValueError("Prompt encodes to zero tokens; refusing to generate.")
491
+
492
+ device = self._settings.device
493
+ input_ids = torch.tensor([prompt_ids], dtype=torch.long, device=device)
494
+
495
+ self._logger.info(
496
+ "Sampling: temp=%.3f top_k=%d rep_pen=%.3f "
497
+ "max_new=%d prompt_tokens=%d",
498
+ policy.temperature, policy.top_k, policy.repetition_penalty,
499
+ policy.max_new_tokens, len(prompt_ids),
500
+ )
501
+
502
+ start = time.time()
503
+ if self._settings.auto_continue:
504
+ output_ids = model.generate_with_continuation(
505
+ input_ids,
506
+ tokenizer=tokenizer,
507
+ max_new_tokens=policy.max_new_tokens,
508
+ temperature=policy.temperature,
509
+ top_k=policy.top_k,
510
+ repetition_penalty=policy.repetition_penalty,
511
+ max_continuations=self._settings.max_continuations,
512
+ tail_lines=self._settings.continuation_tail_lines,
513
+ )
514
+ else:
515
+ output_ids = model.generate(
516
+ input_ids,
517
+ max_new_tokens=policy.max_new_tokens,
518
+ temperature=policy.temperature,
519
+ top_k=policy.top_k,
520
+ repetition_penalty=policy.repetition_penalty,
521
+ )
522
+ elapsed = time.time() - start
523
+
524
+ full_text = tokenizer.decode(output_ids[0].tolist())
525
+ new_tokens = int(output_ids.shape[1]) - len(prompt_ids)
526
+ return GenerationReport(
527
+ prompt=prompt,
528
+ output=full_text,
529
+ prompt_tokens=len(prompt_ids),
530
+ new_tokens=new_tokens,
531
+ elapsed_seconds=elapsed,
532
+ )
533
+
534
+
535
+ class ResultRenderer:
536
+ """Prints a GenerationReport to stdout using settings-defined formatting."""
537
+
538
+ def __init__(self, settings: InferenceSettings, logger: logging.Logger):
539
+ self._settings = settings
540
+ self._logger = logger
541
+
542
+ def render(self, report: GenerationReport) -> None:
543
+ """Emit a banner with prompt and completion, plus a throughput log line."""
544
+ s = self._settings
545
+ sep = s.result_separator_char * s.result_separator_width
546
+ completion = (
547
+ report.output[len(report.prompt):]
548
+ if report.output.startswith(report.prompt)
549
+ else report.output
550
+ )
551
+ self._logger.info(
552
+ "Generated %d new tokens in %.2fs (%.1f tok/s)",
553
+ report.new_tokens, report.elapsed_seconds,
554
+ report.tokens_per_second(s.elapsed_floor_seconds),
555
+ )
556
+ out = sys.stdout
557
+ out.write("\n" + sep + "\n")
558
+ out.write(f"[{s.prompt_label}] {report.prompt}\n")
559
+ out.write(f"[{s.completion_label}]\n{completion}\n")
560
+ out.write(sep + "\n")
561
+ out.flush()
562
+
563
+
564
+ class InferencePipeline:
565
+ """Orchestrator wiring loader, builder, engine and renderer."""
566
+
567
+ def __init__(self, settings: InferenceSettings,
568
+ logger: Optional[logging.Logger] = None):
569
+ settings.validate()
570
+ self._settings = settings
571
+ self._logger = logger or InferenceLoggerFactory.build(settings)
572
+
573
+ def execute(self) -> GenerationReport:
574
+ """Run the full inference pipeline end-to-end and return the report."""
575
+ source_module = SourceModuleLoader(
576
+ self._settings, self._logger,
577
+ ).load()
578
+
579
+ SeedSynchronizer(
580
+ self._settings, source_module, self._logger,
581
+ ).apply()
582
+
583
+ GaussPatchApplier(
584
+ self._settings, source_module, self._logger,
585
+ ).apply_if_enabled()
586
+
587
+ paths = CheckpointPaths(self._settings)
588
+ paths.assert_ready()
589
+
590
+ preset = self._settings.preset()
591
+ n_kv = WeightShapeProbe(
592
+ self._settings, self._logger,
593
+ ).detect_n_kv_heads(
594
+ paths.model_file(),
595
+ d_model=preset.d_model,
596
+ n_heads=preset.n_heads,
597
+ )
598
+
599
+ tokenizer = TokenizerFactory(self._settings, source_module).build()
600
+ aligned_cfg = TopoGPT2ConfigAligner(
601
+ self._settings, source_module, self._logger,
602
+ ).build(n_kv_heads=n_kv, vocab_size=tokenizer.vocab_size)
603
+
604
+ model = ModelAssembler(
605
+ self._settings, source_module, self._logger,
606
+ ).assemble(aligned_cfg, paths)
607
+
608
+ engine = GenerationEngine(self._settings, self._logger)
609
+ report = engine.run(
610
+ model, tokenizer, self._settings.prompt,
611
+ SamplingPolicy.from_settings(self._settings),
612
+ )
613
+ ResultRenderer(self._settings, self._logger).render(report)
614
+ return report
615
+
616
+
617
+ class CliArgumentParser:
618
+ """Translates command-line arguments into an InferenceSettings instance."""
619
+
620
+ @staticmethod
621
+ def build_parser() -> argparse.ArgumentParser:
622
+ """Return the configured argparse.ArgumentParser."""
623
+ defaults = InferenceSettings()
624
+ parser = argparse.ArgumentParser(
625
+ description=(
626
+ "TopoGPT3 inference engine "
627
+ "(Grassmannian-trained, Gauss complex-multiply enabled)."
628
+ ),
629
+ )
630
+ parser.add_argument(
631
+ "--prompt", type=str, default=defaults.prompt,
632
+ help="Code prefix to complete.",
633
+ )
634
+ parser.add_argument(
635
+ "--scale", type=str, default=defaults.model_scale,
636
+ choices=sorted(InferenceSettings.scale_presets().keys()),
637
+ help="Model scale preset.",
638
+ )
639
+ parser.add_argument(
640
+ "--max-new", type=int, default=defaults.max_new_tokens,
641
+ help="Maximum number of new tokens to generate.",
642
+ )
643
+ parser.add_argument(
644
+ "--temp", type=float, default=defaults.temperature,
645
+ help="Sampling temperature (lower means more deterministic).",
646
+ )
647
+ parser.add_argument(
648
+ "--top-k", type=int, default=defaults.top_k,
649
+ help="Top-k filtering (0 disables it).",
650
+ )
651
+ parser.add_argument(
652
+ "--rep-penalty", type=float, default=defaults.repetition_penalty,
653
+ help="Repetition penalty (values above 1 reduce repeats).",
654
+ )
655
+ parser.add_argument(
656
+ "--device", type=str, default=defaults.device,
657
+ help="Compute device, e.g. cuda, cpu, cuda:0.",
658
+ )
659
+ parser.add_argument(
660
+ "--ckpt-dir", type=str, default=defaults.checkpoint_dir,
661
+ help="Root directory holding TopoGPT3 checkpoints.",
662
+ )
663
+ parser.add_argument(
664
+ "--ckpt-name", type=str, default=defaults.checkpoint_name,
665
+ help="Checkpoint slot to load (e.g. 'last').",
666
+ )
667
+ parser.add_argument(
668
+ "--source-dir", type=str, default=defaults.source_directory,
669
+ help="Directory containing train.py and model.py.",
670
+ )
671
+ parser.add_argument(
672
+ "--no-gauss", action="store_true",
673
+ help="Disable the Gauss complex-multiply patch at load.",
674
+ )
675
+ parser.add_argument(
676
+ "--strict-load", action="store_true",
677
+ help="Require strict state_dict matching when loading weights.",
678
+ )
679
+ parser.add_argument(
680
+ "--seed", type=int, default=defaults.seed,
681
+ help="Deterministic seed for sampling reproducibility.",
682
+ )
683
+ parser.add_argument(
684
+ "--auto-continue", "-C", action="store_true",
685
+ default=defaults.auto_continue,
686
+ help="Auto-continue truncated responses by feeding tail lines back.",
687
+ )
688
+ parser.add_argument(
689
+ "--max-continuations", type=int, default=defaults.max_continuations,
690
+ help="Max number of continuation rounds (requires --auto-continue).",
691
+ )
692
+ parser.add_argument(
693
+ "--log-level", type=str, default=defaults.log_level,
694
+ choices=["DEBUG", "INFO", "WARNING", "ERROR"],
695
+ help="Logging verbosity.",
696
+ )
697
+ return parser
698
+
699
+ @staticmethod
700
+ def parse(argv: Optional[List[str]] = None) -> InferenceSettings:
701
+ """Parse `argv` (or sys.argv) and return a populated InferenceSettings."""
702
+ namespace = CliArgumentParser.build_parser().parse_args(argv)
703
+ return InferenceSettings(
704
+ prompt=namespace.prompt,
705
+ model_scale=namespace.scale,
706
+ max_new_tokens=namespace.max_new,
707
+ temperature=namespace.temp,
708
+ top_k=namespace.top_k,
709
+ repetition_penalty=namespace.rep_penalty,
710
+ device=namespace.device,
711
+ checkpoint_dir=namespace.ckpt_dir,
712
+ checkpoint_name=namespace.ckpt_name,
713
+ source_directory=namespace.source_dir,
714
+ apply_gauss_patch_at_load=not namespace.no_gauss,
715
+ strict_state_dict=namespace.strict_load,
716
+ seed=namespace.seed,
717
+ auto_continue=namespace.auto_continue,
718
+ max_continuations=namespace.max_continuations,
719
+ log_level=namespace.log_level,
720
+ )
721
+
722
+
723
+ def main(argv: Optional[List[str]] = None) -> int:
724
+ """CLI entry point. Returns a process exit code."""
725
+ settings = CliArgumentParser.parse(argv)
726
+ InferencePipeline(settings).execute()
727
+ return 0
728
+
729
+
730
+ if __name__ == "__main__":
731
+ sys.exit(main())