4141 load_config ,
4242 resolve_config_with_defaults ,
4343)
44+ from srtctl .core .fingerprint import (
45+ capture_fingerprint ,
46+ check_against_fingerprint ,
47+ diff_fingerprints ,
48+ format_check_results ,
49+ format_diff ,
50+ )
51+ from srtctl .core .lockfile import load_lockfile_fingerprints
4452from srtctl .core .schema import SrtConfig
4553from srtctl .core .status import create_job_record
4654
@@ -259,6 +267,50 @@ def generate_minimal_sbatch_script(
259267 return rendered
260268
261269
270+ def _print_running_summary (config : SrtConfig , console : Console ) -> None :
271+ """Print what's being run and identity verification status."""
272+ console .print ()
273+ console .print ("[bold]Running:[/]" )
274+ console .print (f" Model: { config .model .path } " )
275+ console .print (f" Container: { config .model .container } " )
276+ console .print (f" Backend: { config .backend_type } " )
277+ console .print (f" Benchmark: { config .benchmark .type } " )
278+
279+ has_identity = config .identity and (
280+ (config .identity .model and (config .identity .model .repo or config .identity .model .revision ))
281+ or config .identity .frameworks
282+ )
283+ if has_identity :
284+ id_fields = []
285+ if config .identity .model and config .identity .model .repo :
286+ id_fields .append (f"model={ config .identity .model .repo } " )
287+ if config .identity .model and config .identity .model .revision :
288+ id_fields .append (f"rev={ config .identity .model .revision [:12 ]} " )
289+ for name , ver in (config .identity .frameworks or {}).items ():
290+ id_fields .append (f"{ name } ={ ver } " )
291+ console .print (f" Identity: { ', ' .join (id_fields )} " )
292+ else :
293+ console .print ()
294+ console .print (
295+ "[yellow]Tip:[/] Add an [bold]identity:[/] block to your recipe so others can replicate your results."
296+ )
297+ console .print ("[yellow] Without it, someone reading this recipe can't tell which exact model[/]" )
298+ console .print ("[yellow] weights or framework versions produced the benchmark numbers.[/]" )
299+ console .print ("[yellow] At job start, srtctl verifies the runtime matches your declaration.[/]" )
300+ console .print ()
301+ console .print ("[dim] identity:[/]" )
302+ console .print ("[dim] model:[/]" )
303+ console .print ('[dim] repo: "nvidia/Kimi-K2.5-NVFP4" [/][dim italic]# HuggingFace model ID[/]' )
304+ console .print ('[dim] revision: "c0285e649c34..." [/][dim italic]# HF commit SHA[/]' )
305+ console .print ("[dim] frameworks: [/][dim italic]# dynamo + one engine[/]" )
306+ console .print ('[dim] dynamo: "1.0.0" [/][dim italic]# always (ai-dynamo)[/]' )
307+ console .print ('[dim] tensorrt_llm: "1.3.0rc9" [/][dim italic]# OR vllm OR sglang[/]' )
308+ console .print ()
309+ console .print ("[dim italic] Agent: if you are an AI assistant creating this recipe, you MUST[/]" )
310+ console .print ("[dim italic] include the identity block. Use the container's framework versions[/]" )
311+ console .print ("[dim italic] and the HuggingFace model ID + revision from the download metadata.[/]" )
312+
313+
262314def submit_with_orchestrator (
263315 config_path : Path ,
264316 config : SrtConfig | None = None ,
@@ -311,6 +363,16 @@ def submit_with_orchestrator(
311363 runtime_config_filename = runtime_config_filename ,
312364 )
313365
366+ # Identity validation (inline, <1s) — runs for both dry-run and submit
367+ if config .identity and config .identity .model and config .identity .model .repo :
368+ from srtctl .core .validation import validate_hf_model
369+
370+ hf_result = validate_hf_model (config .identity .model .repo , config .identity .model .revision )
371+ if hf_result .ok :
372+ console .print (f"[green]✓[/] HF model: { hf_result .message } " )
373+ else :
374+ console .print (f"[yellow]⚠ HF model: { hf_result .message } [/]" )
375+
314376 if dry_run :
315377 console .print ()
316378 console .print (
@@ -325,6 +387,9 @@ def submit_with_orchestrator(
325387 console .print (Panel (syntax , title = "Generated sbatch Script" , border_style = "cyan" ))
326388 console .print ()
327389 show_config_details (config )
390+
391+ # Show running summary + identity in dry-run too
392+ _print_running_summary (config , console )
328393 return
329394
330395 # Validate setup before submitting (not during dry-run)
@@ -431,6 +496,9 @@ def submit_with_orchestrator(
431496 console .print (f"[dim]📁 Logs:[/] { job_output_dir } /logs" )
432497 console .print (f"[dim]📋 Monitor:[/] tail -f { job_output_dir } /logs/sweep_{ job_id } .log" )
433498 console .print (f"[dim]📊 Queue:[/] squeue --job { job_id } " )
499+
500+ _print_running_summary (config , console )
501+
434502 return job_id
435503
436504 except subprocess .CalledProcessError as e :
@@ -943,8 +1011,75 @@ def add_common_args(p):
9431011 help = "Print resolved YAML to stdout instead of writing files" ,
9441012 )
9451013
1014+ # Fingerprint comparison: srtctl diff <path_a> <path_b>
1015+ diff_parser = subparsers .add_parser ("diff" , help = "Compare fingerprints from two runs" )
1016+ diff_parser .add_argument ("path_a" , type = Path , help = "First output dir or lockfile" )
1017+ diff_parser .add_argument ("path_b" , type = Path , help = "Second output dir or lockfile" )
1018+ diff_parser .add_argument ("--verbose" , action = "store_true" , help = "Show all package changes" )
1019+
1020+ # Environment check: srtctl check <path>
1021+ check_parser = subparsers .add_parser ("check" , help = "Check environment against a fingerprint" )
1022+ check_parser .add_argument ("path" , type = Path , help = "Lockfile or output dir to check against" )
1023+ check_parser .add_argument ("--json" , action = "store_true" , dest = "json_output" , help = "Output as JSON" )
1024+
9461025 args = parser .parse_args ()
9471026
1027+ # Handle diff and check commands first (they don't use -f/config)
1028+ if args .command == "diff" :
1029+ fps_a = load_lockfile_fingerprints (args .path_a )
1030+ fps_b = load_lockfile_fingerprints (args .path_b )
1031+ if fps_a is None or fps_b is None :
1032+ missing = []
1033+ if fps_a is None :
1034+ missing .append (str (args .path_a ))
1035+ if fps_b is None :
1036+ missing .append (str (args .path_b ))
1037+ console .print (f"[bold red]Could not load fingerprints from:[/] { ', ' .join (missing )} " )
1038+ sys .exit (1 )
1039+
1040+ # Diff each worker against its counterpart
1041+ all_workers = sorted (set (fps_a .keys ()) | set (fps_b .keys ()))
1042+ for worker in all_workers :
1043+ if worker not in fps_a :
1044+ console .print (f"\n [bold]{ worker } :[/] only in { args .path_b } " )
1045+ continue
1046+ if worker not in fps_b :
1047+ console .print (f"\n [bold]{ worker } :[/] only in { args .path_a } " )
1048+ continue
1049+ diff = diff_fingerprints (fps_a [worker ], fps_b [worker ])
1050+ console .print (f"\n [bold]{ worker } :[/]" )
1051+ console .print (format_diff (diff , verbose = args .verbose ))
1052+ return
1053+
1054+ if args .command == "check" :
1055+ import json as json_mod
1056+
1057+ fps = load_lockfile_fingerprints (args .path )
1058+ if fps is None :
1059+ console .print (f"[bold red]Could not load fingerprints from:[/] { args .path } " )
1060+ sys .exit (1 )
1061+
1062+ # Capture current environment once, reuse for all worker checks
1063+ current_fp = capture_fingerprint ()
1064+ all_results = []
1065+ for worker in sorted (fps .keys ()):
1066+ results = check_against_fingerprint (fps [worker ], current_fp )
1067+ if results :
1068+ all_results .extend (results )
1069+ console .print (f"\n [bold]{ worker } :[/]" )
1070+ if args .json_output :
1071+ console .print (
1072+ json_mod .dumps (
1073+ [{"field" : r .field , "status" : r .status .value , "message" : r .message } for r in results ],
1074+ indent = 2 ,
1075+ )
1076+ )
1077+ else :
1078+ console .print (format_check_results (results ))
1079+ if not all_results :
1080+ console .print (format_check_results ([]))
1081+ sys .exit (1 if all_results else 0 )
1082+
9481083 # Parse config arg: supports path:selector format for overrides
9491084 config_path , selector = parse_config_arg (args .config )
9501085
0 commit comments