Skip to content

Commit d45ca8d

Browse files
authored
feat: UUID-based cycle generation with bug fixes and UX improvements (#595)
* feat: UUID-based cycle generation with improved TUI and checkpoint v4 Refactor dataset generation to use UUID-based tracking with cycle-based topic assignment, fixing an issue where topic cycling exited early without generating the expected number of samples. Adds simple/headless TUI mode with progress bars, dynamic Cycle/Step labels, and section headings for topic and dataset generation phases. Bumps checkpoint format to v4 for (uuid, cycle) tuple tracking. Includes prompt timeout for checkpoint resume, improved failure counting after checkpoint clear, and updated docs and tests. * fix: TUI improvements and minor cleanups Add section headings for Topic Generation and Dataset Generation in simple TUI mode. Fix floating bullet on checkpoint status empty line. Clarify cloud upload prompt to specify graph and dataset. Remove redundant help text from --cloud-upload option. Sync uv.lock with version bump and transformers 5.0.0. * fix: restore full topic path context in cycle-based generation Cycle-based generation was only passing the leaf topic text to build_prompt(), losing the hierarchical path context that step-based generation preserves. Now uses topic_model.get_path_by_id() to recover the full root-to-leaf path before building prompts. Also documents this requirement in CLAUDE.md to prevent future regressions. * fix: remove duplicate stop_requested initialization in generator * fix: replace missing _refresh_left call in DatasetGenerationTUI on_llm_retry() called _refresh_left() which only exists on TopicGenerationTUI. Replace with inline events panel update matching the pattern used by log_event() in the same class. Add unit test to prevent regression. * fix: remove duplicate generation_stopped event handler in dataset_manager The second elif handler for "generation_stopped" was unreachable dead code (duplicate condition). It also lacked simple_progress cleanup that the first handler correctly includes. * fix: add Windows fallback for _prompt_with_timeout select.select on sys.stdin raises OSError on Windows. Fall back to click.prompt (blocking, no countdown) when platform is Windows. * fix: exclude root node from graph get_unique_topics The root node contains the generation seed prompt, not a topic for sample generation. Its UUID was being included in cycle-based generation, causing samples to be generated from the raw prompt text. * fix: target retry logic to specific failed (uuid, cycle) tuples Previously, when a UUID failed on one cycle, all (uuid, cycle) tuples were removed from the completed set — including cycles that succeeded. This caused unnecessary re-generation of already-completed work. Now failure records include the cycle number, and retry logic removes only the specific (uuid, cycle) that failed. Includes a legacy fallback for checkpoint files created before this change. * chore: remove internal PLAN files and warn on checkpoint interval adjustment Remove PLAN-generation-refactor.md and PLAN-remaining-work.md which are internal working documents that should not ship with the codebase. Add logger.warning when checkpoint_interval is silently bumped to match concurrency/batch_size, so users understand the adjustment. * fix: use config mode for topic file loading, warn on extension mismatch Previously, a .json file extension would override the config mode and force graph loading, even when mode was set to 'tree'. Now config mode always takes precedence. A TUI warning is shown when the file extension does not match the configured mode, guiding users to fix either the extension or the --mode flag. * fix: save partial results on Ctrl+C when checkpointing is not enabled Previously, Ctrl+C without checkpointing showed a misleading "Stopping after current checkpoint" message and discarded all generated samples. Now the message is context-aware, and partial results are saved to the output file when no checkpoint is configured. * fix: address PR feedback - simplify input handling and add accounting fields - Remove redundant empty-string check in _prompt_with_timeout (both branches returned default) - Add unaccounted field to cycle-based generation_complete event for parity with step-based generation accounting
1 parent 311ba99 commit d45ca8d

24 files changed

Lines changed: 1888 additions & 655 deletions

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ The engine includes robust JSON parsing with regex extraction and retry logic fo
5959
- Failed samples tracked separately from successful ones
6060
- Comprehensive error reporting in final summary
6161

62+
### Topic Path Context in Generation
63+
Dataset generation must always pass the **full hierarchical path** (root -> ... -> leaf) to `build_prompt()`, not just the leaf topic text. This applies to all generation modes (step-based and cycle-based). The full path provides essential context for the LLM to generate domain-specific samples. When modifying generation logic, verify that `subtopics_list` receives the complete path from `TopicModel.get_path_by_id()` or `TopicPath.path`, never just the leaf text in isolation.
64+
6265
### System Message Control
6366
The `sys_msg` parameter controls whether system messages are included in the final dataset format - this affects training data structure.
6467

deepfabric/cli.py

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
import json
33
import math
44
import os
5+
import platform
6+
import select
57
import signal
68
import sys
79

@@ -419,6 +421,35 @@ def _trigger_cloud_upload(
419421
)
420422

421423

424+
def _prompt_with_timeout(
425+
choices: list[str],
426+
default: str,
427+
timeout: int = 20,
428+
) -> str:
429+
"""Prompt for a choice with a visible countdown, auto-selecting default on timeout."""
430+
if platform.system() == "Windows":
431+
return click.prompt(
432+
f" Choose [{'/'.join(choices)}]",
433+
type=click.Choice(choices),
434+
default=default,
435+
)
436+
valid = set(choices)
437+
for remaining in range(timeout, 0, -1):
438+
sys.stdout.write(f"\r Choose [{'/'.join(choices)}] (auto-{default} in {remaining:2d}s): ")
439+
sys.stdout.flush()
440+
ready, _, _ = select.select([sys.stdin], [], [], 1.0)
441+
if ready:
442+
line = sys.stdin.readline().strip()
443+
sys.stdout.write("\n")
444+
sys.stdout.flush()
445+
if line in valid:
446+
return line
447+
return default
448+
sys.stdout.write("\n")
449+
sys.stdout.flush()
450+
return default
451+
452+
422453
def _run_generation(
423454
*,
424455
preparation: GenerationPreparation,
@@ -470,14 +501,10 @@ def _run_generation(
470501
tui.console.print(" [cyan]3)[/cyan] Abort")
471502
tui.console.print()
472503

473-
choice = click.prompt(
474-
"Choose an option",
475-
type=click.Choice(["1", "2", "3"]),
476-
default="1",
477-
)
504+
choice = _prompt_with_timeout(["1", "2", "3"], default="1", timeout=20)
478505

479506
if choice == "1":
480-
# User wants to resume
507+
# User wants to resume (or auto-selected after timeout)
481508
options.resume = True
482509
elif choice == "2":
483510
# Clear and start fresh
@@ -493,7 +520,7 @@ def _run_generation(
493520
if engine.load_checkpoint(retry_failed=options.retry_failed):
494521
samples_done = engine._flushed_samples_count
495522
failures_done = engine._flushed_failures_count
496-
ids_processed = len(engine._processed_ids)
523+
ids_processed = len(engine._completed)
497524
retry_msg = " (retrying failed samples)" if options.retry_failed else ""
498525

499526
# Update TUI status panel with checkpoint progress
@@ -503,17 +530,18 @@ def _run_generation(
503530
if failures_done > 0:
504531
tui.info(
505532
f"Resuming from checkpoint: {samples_done} samples, "
506-
f"{failures_done} failed, {ids_processed} IDs processed{retry_msg}"
533+
f"{failures_done} failed, {ids_processed} UUIDs processed{retry_msg}"
507534
)
508535
else:
509536
tui.info(
510537
f"Resuming from checkpoint: {samples_done} samples, "
511-
f"{ids_processed} IDs processed{retry_msg}"
538+
f"{ids_processed} UUIDs processed{retry_msg}"
512539
)
513540
else:
514541
tui.info("No checkpoint found, starting fresh generation")
515542

516-
# Set up graceful Ctrl+C handling for checkpoint-based stop
543+
# Set up graceful Ctrl+C handling
544+
has_checkpoint = generation_params.get("checkpoint_interval") is not None
517545
interrupt_count = 0
518546

519547
def handle_sigint(_signum, _frame):
@@ -522,7 +550,10 @@ def handle_sigint(_signum, _frame):
522550

523551
if interrupt_count == 1:
524552
engine.stop_requested = True
525-
tui.warning("Stopping after current checkpoint... (Ctrl+C again to force quit)")
553+
if has_checkpoint:
554+
tui.warning("Stopping after current checkpoint... (Ctrl+C again to force quit)")
555+
else:
556+
tui.warning("Stopping... partial results will be saved. (Ctrl+C again to force quit)")
526557
dataset_tui = get_dataset_tui()
527558
dataset_tui.log_event("⚠ Graceful stop requested")
528559
dataset_tui.status_stop_requested()
@@ -547,12 +578,22 @@ def handle_sigint(_signum, _frame):
547578
finally:
548579
signal.signal(signal.SIGINT, original_handler)
549580

550-
# If gracefully stopped, don't save partial dataset or clean up checkpoints
581+
output_config = preparation.config.get_output_config()
582+
output_save_path = options.output_save_as or output_config["save_as"]
583+
584+
# If gracefully stopped, handle based on checkpoint availability
551585
if engine.stop_requested:
586+
if has_checkpoint:
587+
# Checkpoint on disk — user can resume later
588+
return
589+
# No checkpoint — save whatever was generated so far
590+
if dataset and len(dataset) > 0:
591+
tui.info(f"Saving {len(dataset)} samples generated before stop")
592+
save_dataset(dataset, output_save_path, preparation.config, engine=engine)
593+
else:
594+
tui.warning("No samples were generated before stop")
552595
return
553596

554-
output_config = preparation.config.get_output_config()
555-
output_save_path = options.output_save_as or output_config["save_as"]
556597
save_dataset(dataset, output_save_path, preparation.config, engine=engine)
557598

558599
# Clean up checkpoint files after successful completion
@@ -652,7 +693,6 @@ def handle_sigint(_signum, _frame):
652693
type=click.Choice(["all", "dataset", "graph", "none"], case_sensitive=False),
653694
default=None,
654695
help="Upload to DeepFabric Cloud (experimental): all, dataset, graph, or none. "
655-
"Enables headless mode for CI. Requires DEEPFABRIC_API_KEY or prior auth.",
656696
)
657697
@click.option(
658698
"--checkpoint-interval",
@@ -783,7 +823,9 @@ def generate( # noqa: PLR0913
783823

784824
# Compute checkpoint directory once for consistent use throughout generation
785825
# Use config file for hash, fallback to output path for config-less runs
786-
path_source = options.config_file or options.output_save_as or preparation.config.output.save_as
826+
path_source = (
827+
options.config_file or options.output_save_as or preparation.config.output.save_as
828+
)
787829
checkpoint_dir = options.checkpoint_path or get_checkpoint_dir(path_source)
788830

789831
# Auto-infer topics-load when resuming from checkpoint
@@ -1295,23 +1337,34 @@ def validate(config_file: str, check_api: bool) -> None: # noqa: PLR0912
12951337
f"estimated_paths={estimated_paths} ({degree}^{depth})"
12961338
)
12971339

1298-
# Output summary with step size and checkpoint info
1340+
# Output summary with cycle-based generation info
12991341
num_samples = config.output.num_samples
13001342
batch_size = config.output.batch_size
1301-
# Calculate num_steps - handle 'auto' and percentage strings
1302-
if isinstance(num_samples, int):
1303-
num_steps = math.ceil(num_samples / batch_size)
1304-
output_info = f"Output: num_samples={num_samples}, batch_size={batch_size}, num_steps={num_steps}"
1305-
else:
1306-
# For 'auto' or percentage, we can't compute steps without topic count
1307-
output_info = f"Output: num_samples={num_samples}, batch_size={batch_size}"
13081343

1309-
# Add checkpoint info if enabled
1344+
# Show output configuration
1345+
output_info = f"Output: num_samples={num_samples}, concurrency={batch_size}"
13101346
if config.output.checkpoint:
13111347
checkpoint = config.output.checkpoint
13121348
output_info += f", checkpoint_interval={checkpoint.interval}"
13131349
tui.info(output_info)
13141350

1351+
# Calculate and display cycle-based generation info
1352+
if isinstance(num_samples, int):
1353+
cycles_needed = math.ceil(num_samples / estimated_paths)
1354+
final_cycle_size = num_samples - (cycles_needed - 1) * estimated_paths
1355+
is_partial = final_cycle_size < estimated_paths
1356+
1357+
tui.info(
1358+
f" → Cycles needed: {cycles_needed} "
1359+
f"({num_samples} samples ÷ {estimated_paths} unique topics)"
1360+
)
1361+
if is_partial:
1362+
tui.info(f" → Final cycle: {final_cycle_size} topics (partial)")
1363+
elif num_samples == "auto":
1364+
tui.info(f" → Will generate 1 sample per unique topic ({estimated_paths} samples)")
1365+
else:
1366+
tui.info(" → Samples calculated at runtime based on topic count")
1367+
13151368
if config.huggingface:
13161369
hf_config = config.get_huggingface_config()
13171370
tui.info(f"Hugging Face: repo={hf_config.get('repository', 'not set')}")
@@ -1893,7 +1946,8 @@ def checkpoint_status(config_file: str) -> None:
18931946
# Check if checkpoint exists
18941947
if not metadata_path.exists():
18951948
tui.info(f"No checkpoint found at: {metadata_path}")
1896-
tui.info("\nTo enable checkpointing, run:")
1949+
tui.console.print()
1950+
tui.info("To enable checkpointing, run:")
18971951
tui.info(f" deepfabric generate {config_file} --checkpoint-interval 10")
18981952
return
18991953

deepfabric/cloud_upload.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -778,7 +778,7 @@ def handle_cloud_upload( # noqa: PLR0911
778778

779779
# Build prompt based on what's available
780780
if has_dataset and has_graph:
781-
prompt_text = " Upload to DeepFabric Cloud?"
781+
prompt_text = " Upload graph and dataset to DeepFabric Cloud?"
782782
hint = "[dim](Y=both, n=skip, c=choose)[/dim]"
783783
elif has_dataset:
784784
prompt_text = " Upload dataset to DeepFabric Cloud?"

deepfabric/config.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -630,9 +630,7 @@ def get_generation_params(self, **overrides) -> dict:
630630
# Note: checkpoint_path can be None, meaning "auto-resolve" at runtime
631631
"checkpoint_interval": self.output.checkpoint.interval if self.output.checkpoint else None,
632632
"checkpoint_path": self.output.checkpoint.path if self.output.checkpoint else None,
633-
"checkpoint_retry_failed": (
634-
self.output.checkpoint.retry_failed if self.output.checkpoint else False
635-
),
633+
"checkpoint_retry_failed": self.output.checkpoint.retry_failed if self.output.checkpoint else False,
636634
}
637635

638636
# Tool config

deepfabric/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
CHECKPOINT_METADATA_SUFFIX = ".checkpoint.json"
9494
CHECKPOINT_SAMPLES_SUFFIX = ".checkpoint.jsonl"
9595
CHECKPOINT_FAILURES_SUFFIX = ".checkpoint.failures.jsonl"
96-
CHECKPOINT_VERSION = 3 # Increment when checkpoint format changes
96+
CHECKPOINT_VERSION = 4 # v4: (uuid, cycle) tuple tracking for cycle-based generation
9797

9898
# Stream simulation defaults
9999
STREAM_SIM_CHUNK_SIZE = 8 # characters per chunk

0 commit comments

Comments
 (0)