Coverage for little_loops / fsm / validation.py: 56%

264 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-18 03:19 -0500

1"""FSM loop validation logic. 

2 

3This module provides validation for FSM loop definitions, ensuring 

4structural correctness and catching common configuration errors. 

5 

6Validation checks: 

7- Initial state exists in states dict 

8- All referenced states exist 

9- At least one terminal state 

10- Evaluator configs have required fields for their type 

11- No conflicting routing (shorthand vs full route) 

12- Numeric fields in valid ranges (max_iterations > 0, backoff >= 0, timeout > 0) 

13""" 

14 

15from __future__ import annotations 

16 

17import logging 

18from collections import deque 

19from dataclasses import dataclass 

20from enum import Enum 

21from pathlib import Path 

22from typing import Any 

23 

24import yaml 

25 

26from little_loops.fsm.fragments import resolve_flow, resolve_fragments, resolve_inheritance 

27from little_loops.fsm.schema import EvaluateConfig, FSMLoop, ParameterSpec, StateConfig 

28 

29logger = logging.getLogger(__name__) 

30 

31 

32class ValidationSeverity(Enum): 

33 """Severity level for validation issues.""" 

34 

35 ERROR = "error" 

36 WARNING = "warning" 

37 

38 

39@dataclass 

40class ValidationError: 

41 """Structured validation error. 

42 

43 Attributes: 

44 message: Human-readable error description 

45 path: Path to the problematic element (e.g., "states.check.route") 

46 severity: Error severity (error or warning) 

47 """ 

48 

49 message: str 

50 path: str | None = None 

51 severity: ValidationSeverity = ValidationSeverity.ERROR 

52 

53 def __str__(self) -> str: 

54 """Format error for display.""" 

55 prefix = f"[{self.severity.value.upper()}]" 

56 if self.path: 

57 return f"{prefix} {self.path}: {self.message}" 

58 return f"{prefix} {self.message}" 

59 

60 

61# Evaluator type to required fields mapping 

62EVALUATOR_REQUIRED_FIELDS: dict[str, list[str]] = { 

63 "exit_code": [], 

64 "output_numeric": ["operator", "target"], 

65 "output_json": ["path", "operator", "target"], 

66 "output_contains": ["pattern"], 

67 "convergence": ["target"], 

68 "diff_stall": [], 

69 "llm_structured": [], 

70 "mcp_result": [], 

71 "harbor_scorer": [], 

72} 

73 

74# Valid comparison operators 

75VALID_OPERATORS = {"eq", "ne", "lt", "le", "gt", "ge"} 

76 

77# All top-level keys recognized by FSMLoop.from_dict() 

78KNOWN_TOP_LEVEL_KEYS: frozenset[str] = frozenset( 

79 { 

80 "name", 

81 "description", 

82 "initial", 

83 "states", 

84 "context", 

85 "parameters", 

86 "scope", 

87 "max_iterations", 

88 "max_edge_revisits", 

89 "backoff", 

90 "timeout", 

91 "default_timeout", 

92 "maintain", 

93 "llm", 

94 "on_handoff", 

95 "input_key", 

96 "config", 

97 "category", 

98 "labels", 

99 "commands", 

100 "targets", 

101 "import", 

102 "fragments", 

103 "from", 

104 "flow", 

105 "state_defs", 

106 } 

107) 

108 

109# Valid parameter types for the 'parameters:' block 

110VALID_PARAMETER_TYPES: frozenset[str] = frozenset( 

111 {"string", "integer", "number", "boolean", "enum", "path"} 

112) 

113 

114 

115def _validate_evaluator(state_name: str, evaluate: EvaluateConfig) -> list[ValidationError]: 

116 """Validate evaluator configuration for type-specific requirements. 

117 

118 Args: 

119 state_name: Name of the state containing this evaluator 

120 evaluate: The evaluator configuration to validate 

121 

122 Returns: 

123 List of validation errors found 

124 """ 

125 errors: list[ValidationError] = [] 

126 path = f"states.{state_name}.evaluate" 

127 

128 # Check that evaluator type is recognized 

129 valid_types = set(EVALUATOR_REQUIRED_FIELDS.keys()) 

130 if evaluate.type not in valid_types: 

131 errors.append( 

132 ValidationError( 

133 message=f"Unknown evaluator type '{evaluate.type}'. " 

134 f"Must be one of: {', '.join(sorted(valid_types))}", 

135 path=path, 

136 ) 

137 ) 

138 return errors # Can't check required fields for unknown type 

139 

140 # Check required fields for evaluator type 

141 required = EVALUATOR_REQUIRED_FIELDS.get(evaluate.type, []) 

142 for field_name in required: 

143 value = getattr(evaluate, field_name, None) 

144 if value is None: 

145 errors.append( 

146 ValidationError( 

147 message=f"Evaluator type '{evaluate.type}' requires '{field_name}' field", 

148 path=path, 

149 ) 

150 ) 

151 

152 # Validate operator if present 

153 if evaluate.operator is not None and evaluate.operator not in VALID_OPERATORS: 

154 errors.append( 

155 ValidationError( 

156 message=f"Invalid operator '{evaluate.operator}'. " 

157 f"Must be one of: {', '.join(sorted(VALID_OPERATORS))}", 

158 path=f"{path}.operator", 

159 ) 

160 ) 

161 

162 # Validate convergence-specific fields 

163 if evaluate.type == "convergence": 

164 if evaluate.direction not in ("minimize", "maximize"): 

165 errors.append( 

166 ValidationError( 

167 message=f"Invalid direction '{evaluate.direction}'. " 

168 "Must be 'minimize' or 'maximize'", 

169 path=f"{path}.direction", 

170 ) 

171 ) 

172 # Only validate tolerance if it's a numeric value (not an interpolation string) 

173 if ( 

174 evaluate.tolerance is not None 

175 and isinstance(evaluate.tolerance, (int, float)) 

176 and evaluate.tolerance < 0 

177 ): 

178 errors.append( 

179 ValidationError( 

180 message="Tolerance cannot be negative", 

181 path=f"{path}.tolerance", 

182 ) 

183 ) 

184 

185 # Validate llm_structured-specific fields 

186 if evaluate.type == "llm_structured": 

187 if evaluate.min_confidence < 0 or evaluate.min_confidence > 1: 

188 errors.append( 

189 ValidationError( 

190 message="min_confidence must be between 0 and 1", 

191 path=f"{path}.min_confidence", 

192 ) 

193 ) 

194 

195 # Validate diff_stall-specific fields 

196 if evaluate.type == "diff_stall": 

197 if evaluate.max_stall < 1: 

198 errors.append( 

199 ValidationError( 

200 message="max_stall must be >= 1", 

201 path=f"{path}.max_stall", 

202 ) 

203 ) 

204 

205 return errors 

206 

207 

208def _validate_parameters(fsm: FSMLoop) -> list[ValidationError]: 

209 """Validate the loop's top-level parameters: block. 

210 

211 Args: 

212 fsm: The FSM loop to validate 

213 

214 Returns: 

215 List of validation errors found 

216 """ 

217 errors: list[ValidationError] = [] 

218 

219 for param_name, param_spec in fsm.parameters.items(): 

220 path = f"parameters.{param_name}" 

221 

222 if param_spec.type not in VALID_PARAMETER_TYPES: 

223 errors.append( 

224 ValidationError( 

225 message=( 

226 f"Unknown parameter type '{param_spec.type}'. " 

227 f"Must be one of: {', '.join(sorted(VALID_PARAMETER_TYPES))}" 

228 ), 

229 path=path, 

230 ) 

231 ) 

232 

233 if param_spec.type == "enum" and not param_spec.values: 

234 errors.append( 

235 ValidationError( 

236 message="Parameter type 'enum' requires a 'values' list", 

237 path=path, 

238 ) 

239 ) 

240 

241 if param_spec.required and param_spec.default is not None: 

242 errors.append( 

243 ValidationError( 

244 message="Parameter cannot be both 'required: true' and have a 'default' value", 

245 path=path, 

246 ) 

247 ) 

248 

249 return errors 

250 

251 

252def _check_param_type(value: Any, spec: ParameterSpec) -> str | None: 

253 """Return an error message if value does not match spec.type, else None.""" 

254 if spec.type == "string" and not isinstance(value, str): 

255 return f"expected string, got {type(value).__name__}" 

256 if spec.type == "integer" and not isinstance(value, int): 

257 return f"expected integer, got {type(value).__name__}" 

258 if spec.type == "number" and not isinstance(value, (int, float)): 

259 return f"expected number, got {type(value).__name__}" 

260 if spec.type == "boolean" and not isinstance(value, bool): 

261 return f"expected boolean, got {type(value).__name__}" 

262 if spec.type == "enum" and spec.values and value not in spec.values: 

263 return f"expected one of {spec.values!r}, got {value!r}" 

264 return None 

265 

266 

267def _validate_with_bindings(fsm: FSMLoop, loop_dir: Path) -> list[ValidationError]: 

268 """Validate with: bindings against child loop parameter contracts. 

269 

270 Called from load_and_validate (not validate_fsm) because resolving child loops 

271 requires file-system access via the loop directory path. 

272 

273 Args: 

274 fsm: The parent FSM loop 

275 loop_dir: Directory to resolve child loop paths from 

276 

277 Returns: 

278 List of validation errors found 

279 """ 

280 errors: list[ValidationError] = [] 

281 

282 for state_name, state in fsm.states.items(): 

283 if state.loop is None or not state.with_: 

284 continue 

285 

286 # Try to resolve and load the child loop; skip if unavailable 

287 try: 

288 from little_loops.cli.loop._helpers import resolve_loop_path 

289 

290 loop_path = resolve_loop_path(state.loop, loop_dir) 

291 child_fsm, _ = load_and_validate(loop_path) 

292 except Exception: 

293 continue 

294 

295 if not child_fsm.parameters: 

296 continue # Child has no declared contract — nothing to cross-validate 

297 

298 path = f"states.{state_name}" 

299 

300 # Unknown with: keys (not declared by child) 

301 for key in state.with_: 

302 if key not in child_fsm.parameters: 

303 errors.append( 

304 ValidationError( 

305 message=( 

306 f"'with.{key}' is not a declared parameter of loop '{state.loop}'. " 

307 f"Declared: {', '.join(sorted(child_fsm.parameters))}" 

308 ), 

309 path=f"{path}.with.{key}", 

310 ) 

311 ) 

312 

313 # Required parameters not bound 

314 for param_name, param_spec in child_fsm.parameters.items(): 

315 if param_spec.required and param_name not in state.with_: 

316 errors.append( 

317 ValidationError( 

318 message=( 

319 f"Required parameter '{param_name}' of loop '{state.loop}' " 

320 f"is not bound in 'with'" 

321 ), 

322 path=f"{path}.with", 

323 ) 

324 ) 

325 

326 # Statically-detectable type mismatches (skip interpolation strings) 

327 for param_name, value in state.with_.items(): 

328 if param_name not in child_fsm.parameters: 

329 continue 

330 if isinstance(value, str) and "${" in value: 

331 continue 

332 type_error = _check_param_type(value, child_fsm.parameters[param_name]) 

333 if type_error: 

334 errors.append( 

335 ValidationError( 

336 message=f"Parameter '{param_name}': {type_error}", 

337 path=f"{path}.with.{param_name}", 

338 ) 

339 ) 

340 

341 return errors 

342 

343 

344def _validate_state_action(state_name: str, state: StateConfig) -> list[ValidationError]: 

345 """Validate state action configuration. 

346 

347 Args: 

348 state_name: Name of the state to validate 

349 state: The state configuration to validate 

350 

351 Returns: 

352 List of validation errors found 

353 """ 

354 errors: list[ValidationError] = [] 

355 path = f"states.{state_name}" 

356 

357 # params field is only valid for mcp_tool states 

358 if state.params and state.action_type != "mcp_tool": 

359 errors.append( 

360 ValidationError( 

361 message="'params' field is only valid when action_type is 'mcp_tool'", 

362 path=f"{path}.params", 

363 ) 

364 ) 

365 

366 # loop and action are mutually exclusive 

367 if state.loop is not None and state.action is not None: 

368 errors.append( 

369 ValidationError( 

370 message="'loop' and 'action' are mutually exclusive — " 

371 "a sub-loop state cannot also have an action", 

372 path=f"{path}", 

373 ) 

374 ) 

375 

376 # with: requires loop: to be set 

377 if state.with_ and state.loop is None: 

378 errors.append( 

379 ValidationError( 

380 message="'with' is only valid when 'loop' is set", 

381 path=f"{path}.with", 

382 ) 

383 ) 

384 

385 # FEAT-1283: type=learning requires a populated LearningConfig 

386 if state.type == "learning" and state.learning is not None: 

387 if not state.learning.targets: 

388 errors.append( 

389 ValidationError( 

390 message="type=learning requires non-empty 'learning.targets'", 

391 path=f"{path}.learning.targets", 

392 ) 

393 ) 

394 if state.learning.max_retries < 0: 

395 errors.append( 

396 ValidationError( 

397 message=( 

398 f"learning.max_retries must be >= 0, got {state.learning.max_retries}" 

399 ), 

400 path=f"{path}.learning.max_retries", 

401 ) 

402 ) 

403 if state.on_yes is None: 

404 errors.append( 

405 ValidationError( 

406 message="type=learning requires 'on_yes' (target for all-proven)", 

407 path=f"{path}.on_yes", 

408 ) 

409 ) 

410 if state.on_blocked is None and state.on_no is None: 

411 errors.append( 

412 ValidationError( 

413 message=( 

414 "type=learning requires 'on_blocked' or 'on_no' " 

415 "(target for refuted / retries_exhausted)" 

416 ), 

417 path=f"{path}", 

418 ) 

419 ) 

420 

421 # with: and context_passthrough are mutually exclusive 

422 if state.with_ and state.context_passthrough: 

423 errors.append( 

424 ValidationError( 

425 message=( 

426 "'with' and 'context_passthrough' are mutually exclusive — " 

427 "use 'with' for explicit parameter bindings or 'context_passthrough' " 

428 "for legacy bulk passthrough, not both" 

429 ), 

430 path=f"{path}", 

431 ) 

432 ) 

433 

434 return errors 

435 

436 

437def _validate_state_routing(state_name: str, state: StateConfig) -> list[ValidationError]: 

438 """Validate state routing configuration. 

439 

440 Checks for conflicting routing definitions (shorthand vs full route). 

441 

442 Args: 

443 state_name: Name of the state to validate 

444 state: The state configuration to validate 

445 

446 Returns: 

447 List of validation errors/warnings found 

448 """ 

449 errors: list[ValidationError] = [] 

450 path = f"states.{state_name}" 

451 

452 has_shorthand = ( 

453 state.on_yes is not None 

454 or state.on_no is not None 

455 or state.on_error is not None 

456 or state.on_partial is not None 

457 or state.on_blocked is not None 

458 or bool(state.extra_routes) 

459 ) 

460 has_route = state.route is not None 

461 

462 # Warn about conflicting definitions 

463 if has_shorthand and has_route: 

464 errors.append( 

465 ValidationError( 

466 message="Both shorthand routing (on_yes/on_no/on_error) " 

467 "and full route table defined. Route table will take precedence.", 

468 path=path, 

469 severity=ValidationSeverity.WARNING, 

470 ) 

471 ) 

472 

473 # Check for no valid transition definition 

474 has_next = state.next is not None 

475 has_terminal = state.terminal 

476 has_loop = state.loop is not None 

477 

478 if not has_shorthand and not has_route and not has_next and not has_terminal and not has_loop: 

479 errors.append( 

480 ValidationError( 

481 message="State has no transition defined. Add routing, 'next', " 

482 "or mark as 'terminal: true'", 

483 path=path, 

484 ) 

485 ) 

486 

487 # Validate retry field pairing: max_retries requires on_retry_exhausted and vice versa 

488 if state.max_retries is not None and state.on_retry_exhausted is None: 

489 errors.append( 

490 ValidationError( 

491 message="'max_retries' requires 'on_retry_exhausted' to also be set", 

492 path=path, 

493 ) 

494 ) 

495 if state.on_retry_exhausted is not None and state.max_retries is None: 

496 errors.append( 

497 ValidationError( 

498 message="'on_retry_exhausted' requires 'max_retries' to also be set", 

499 path=path, 

500 ) 

501 ) 

502 if state.max_retries is not None and state.max_retries < 1: 

503 errors.append( 

504 ValidationError( 

505 message=f"'max_retries' must be >= 1, got {state.max_retries}", 

506 path=path, 

507 ) 

508 ) 

509 

510 # Validate rate-limit retry field pairing (mirrors max_retries/on_retry_exhausted) 

511 if state.max_rate_limit_retries is not None and state.on_rate_limit_exhausted is None: 

512 errors.append( 

513 ValidationError( 

514 message="'max_rate_limit_retries' requires 'on_rate_limit_exhausted' to also be set", 

515 path=path, 

516 ) 

517 ) 

518 if state.on_rate_limit_exhausted is not None and state.max_rate_limit_retries is None: 

519 errors.append( 

520 ValidationError( 

521 message="'on_rate_limit_exhausted' requires 'max_rate_limit_retries' to also be set", 

522 path=path, 

523 ) 

524 ) 

525 if state.max_rate_limit_retries is not None and state.max_rate_limit_retries < 1: 

526 errors.append( 

527 ValidationError( 

528 message=f"'max_rate_limit_retries' must be >= 1, got {state.max_rate_limit_retries}", 

529 path=path, 

530 ) 

531 ) 

532 if ( 

533 state.rate_limit_backoff_base_seconds is not None 

534 and state.rate_limit_backoff_base_seconds < 1 

535 ): 

536 errors.append( 

537 ValidationError( 

538 message=( 

539 f"'rate_limit_backoff_base_seconds' must be >= 1, " 

540 f"got {state.rate_limit_backoff_base_seconds}" 

541 ), 

542 path=path, 

543 ) 

544 ) 

545 if state.rate_limit_max_wait_seconds is not None and state.rate_limit_max_wait_seconds < 1: 

546 errors.append( 

547 ValidationError( 

548 message=( 

549 f"'rate_limit_max_wait_seconds' must be >= 1, " 

550 f"got {state.rate_limit_max_wait_seconds}" 

551 ), 

552 path=path, 

553 ) 

554 ) 

555 if state.rate_limit_long_wait_ladder is not None: 

556 if len(state.rate_limit_long_wait_ladder) == 0: 

557 errors.append( 

558 ValidationError( 

559 message="'rate_limit_long_wait_ladder' must be non-empty if specified", 

560 path=path, 

561 ) 

562 ) 

563 else: 

564 for idx, value in enumerate(state.rate_limit_long_wait_ladder): 

565 if not isinstance(value, int) or value < 1: 

566 errors.append( 

567 ValidationError( 

568 message=( 

569 f"'rate_limit_long_wait_ladder[{idx}]' must be a " 

570 f"positive integer, got {value!r}" 

571 ), 

572 path=path, 

573 ) 

574 ) 

575 

576 # Validate throttle config when present 

577 if state.throttle is not None: 

578 t = state.throttle 

579 fields = { 

580 "normal_max": t.normal_max, 

581 "warn_max": t.warn_max, 

582 "hard_max": t.hard_max, 

583 } 

584 for field_name, val in fields.items(): 

585 if val is not None and (not isinstance(val, int) or val < 1): 

586 errors.append( 

587 ValidationError( 

588 message=f"'throttle.{field_name}' must be a positive integer, got {val!r}", 

589 path=path, 

590 ) 

591 ) 

592 # Enforce ordering when all three are set 

593 if t.normal_max is not None and t.warn_max is not None and t.normal_max >= t.warn_max: 

594 errors.append( 

595 ValidationError( 

596 message=( 

597 f"'throttle.normal_max' ({t.normal_max}) must be less than " 

598 f"'throttle.warn_max' ({t.warn_max})" 

599 ), 

600 path=path, 

601 ) 

602 ) 

603 if t.warn_max is not None and t.hard_max is not None and t.warn_max >= t.hard_max: 

604 errors.append( 

605 ValidationError( 

606 message=( 

607 f"'throttle.warn_max' ({t.warn_max}) must be less than " 

608 f"'throttle.hard_max' ({t.hard_max})" 

609 ), 

610 path=path, 

611 ) 

612 ) 

613 

614 return errors 

615 

616 

617def _validate_targets(fsm: FSMLoop) -> list[ValidationError]: 

618 """Validate top-level targets[] entries (ENH-1552). 

619 

620 Rejects any targets[].states[] entry whose sibling file: value does not 

621 end with a .yaml extension. 

622 """ 

623 errors: list[ValidationError] = [] 

624 for i, target in enumerate(fsm.targets): 

625 if target.file is not None and not target.file.endswith(".yaml"): 

626 errors.append( 

627 ValidationError( 

628 message=(f"targets[{i}].file must be a .yaml file, got '{target.file}'"), 

629 path=f"targets[{i}].file", 

630 ) 

631 ) 

632 return errors 

633 

634 

635def validate_fsm(fsm: FSMLoop) -> list[ValidationError]: 

636 """Validate FSM structure and return list of errors. 

637 

638 Performs comprehensive validation: 

639 - Initial state exists 

640 - All referenced states exist 

641 - At least one terminal state 

642 - Evaluator configurations are valid 

643 - Routing configurations are valid 

644 - Numeric fields are in valid ranges (max_iterations > 0, backoff >= 0, timeout > 0) 

645 

646 Args: 

647 fsm: The FSM loop to validate 

648 

649 Returns: 

650 List of validation errors (empty if valid) 

651 """ 

652 errors: list[ValidationError] = [] 

653 defined_states = fsm.get_all_state_names() 

654 

655 # Warn when no top-level description: field is set. The field is optional 

656 # for FSM execution but required for goal-alignment skills (debug-loop-run, 

657 # audit-loop-run) and for ll-loop show --json to surface intent text. 

658 if not fsm.description: 

659 errors.append( 

660 ValidationError( 

661 path="<root>", 

662 message=("No 'description' field defined. Add a top-level description: key."), 

663 severity=ValidationSeverity.WARNING, 

664 ) 

665 ) 

666 

667 # Validate parameters block 

668 errors.extend(_validate_parameters(fsm)) 

669 

670 # Validate targets block (ENH-1552) 

671 errors.extend(_validate_targets(fsm)) 

672 

673 # Check initial state exists 

674 if fsm.initial not in defined_states: 

675 errors.append( 

676 ValidationError( 

677 message=f"Initial state '{fsm.initial}' not found in states", 

678 path="initial", 

679 ) 

680 ) 

681 

682 # Check at least one terminal state 

683 terminal_states = fsm.get_terminal_states() 

684 if not terminal_states: 

685 errors.append( 

686 ValidationError( 

687 message="No terminal state defined. At least one state must have 'terminal: true'", 

688 path="states", 

689 ) 

690 ) 

691 

692 # Validate each state 

693 for state_name, state in fsm.states.items(): 

694 # Check all referenced states exist 

695 refs = state.get_referenced_states() 

696 for ref in refs: 

697 # $current is a special token for retry 

698 if ref != "$current" and ref not in defined_states: 

699 errors.append( 

700 ValidationError( 

701 message=f"References unknown state '{ref}'", 

702 path=f"states.{state_name}", 

703 ) 

704 ) 

705 

706 # Validate action configuration 

707 errors.extend(_validate_state_action(state_name, state)) 

708 

709 # Validate evaluator if present 

710 if state.evaluate is not None: 

711 errors.extend(_validate_evaluator(state_name, state.evaluate)) 

712 

713 # Validate routing configuration 

714 errors.extend(_validate_state_routing(state_name, state)) 

715 

716 # Check numeric field ranges 

717 if fsm.max_iterations <= 0: 

718 errors.append( 

719 ValidationError( 

720 message=f"max_iterations must be > 0, got {fsm.max_iterations}", 

721 path="max_iterations", 

722 ) 

723 ) 

724 if fsm.max_edge_revisits <= 0: 

725 errors.append( 

726 ValidationError( 

727 message=f"max_edge_revisits must be > 0, got {fsm.max_edge_revisits}", 

728 path="max_edge_revisits", 

729 ) 

730 ) 

731 if fsm.backoff is not None and fsm.backoff < 0: 

732 errors.append( 

733 ValidationError( 

734 message=f"backoff must be >= 0, got {fsm.backoff}", 

735 path="backoff", 

736 ) 

737 ) 

738 if fsm.timeout is not None and fsm.timeout <= 0: 

739 errors.append( 

740 ValidationError( 

741 message=f"timeout must be > 0, got {fsm.timeout}", 

742 path="timeout", 

743 ) 

744 ) 

745 if fsm.llm.max_tokens <= 0: 

746 errors.append( 

747 ValidationError( 

748 message=f"llm.max_tokens must be > 0, got {fsm.llm.max_tokens}", 

749 path="llm.max_tokens", 

750 ) 

751 ) 

752 if fsm.llm.timeout <= 0: 

753 errors.append( 

754 ValidationError( 

755 message=f"llm.timeout must be > 0, got {fsm.llm.timeout}", 

756 path="llm.timeout", 

757 ) 

758 ) 

759 

760 # Check for unreachable states (warning only) 

761 reachable = _find_reachable_states(fsm) 

762 unreachable = defined_states - reachable 

763 for state_name in unreachable: 

764 errors.append( 

765 ValidationError( 

766 message="State is not reachable from initial state", 

767 path=f"states.{state_name}", 

768 severity=ValidationSeverity.WARNING, 

769 ) 

770 ) 

771 

772 return errors 

773 

774 

775def _find_reachable_states(fsm: FSMLoop) -> set[str]: 

776 """Find all states reachable from the initial state. 

777 

778 Uses breadth-first search to find all reachable states. 

779 

780 Args: 

781 fsm: The FSM loop to analyze 

782 

783 Returns: 

784 Set of reachable state names 

785 """ 

786 reachable: set[str] = set() 

787 to_visit: deque[str] = deque([fsm.initial]) 

788 

789 while to_visit: 

790 current = to_visit.popleft() 

791 if current in reachable or current not in fsm.states: 

792 continue 

793 

794 reachable.add(current) 

795 state = fsm.states[current] 

796 refs = state.get_referenced_states() 

797 

798 for ref in refs: 

799 if ref != "$current" and ref not in reachable: 

800 to_visit.append(ref) 

801 

802 return reachable 

803 

804 

805def load_and_validate(path: Path) -> tuple[FSMLoop, list[ValidationError]]: 

806 """Load YAML file and validate FSM structure. 

807 

808 Args: 

809 path: Path to the YAML file to load 

810 

811 Returns: 

812 Tuple of (validated FSMLoop instance, list of WARNING-severity ValidationErrors) 

813 

814 Raises: 

815 FileNotFoundError: If the file doesn't exist 

816 yaml.YAMLError: If the file is not valid YAML 

817 ValueError: If validation fails (contains error details) 

818 """ 

819 if not path.exists(): 

820 raise FileNotFoundError(f"FSM file not found: {path}") 

821 

822 with open(path) as f: 

823 data: dict[str, Any] = yaml.safe_load(f) 

824 

825 if not isinstance(data, dict): 

826 raise ValueError(f"FSM file must contain a YAML mapping, got {type(data)}") 

827 

828 # Resolve `from:` inheritance before any further checks, so a child loop 

829 # can omit fields its parent provides (including `initial`/`states`) and 

830 # so a parent's `import:`/`fragments:` blocks survive into the merged 

831 # result for the subsequent `resolve_fragments` pass. 

832 data = resolve_inheritance(data, path.parent) 

833 

834 # Expand flow: linear shorthand into states: before required-fields check 

835 data = resolve_flow(data) 

836 

837 # Check required fields before parsing 

838 missing = [] 

839 for field in ["name", "initial"]: 

840 if field not in data: 

841 missing.append(field) 

842 if "states" not in data: 

843 missing.append("states (or flow)") 

844 

845 if missing: 

846 raise ValueError(f"FSM file missing required fields: {', '.join(missing)}") 

847 

848 # Check for unknown top-level keys before parsing 

849 unknown_key_warnings: list[ValidationError] = [] 

850 unknown = set(data.keys()) - KNOWN_TOP_LEVEL_KEYS 

851 if unknown: 

852 unknown_key_warnings.append( 

853 ValidationError( 

854 path="<root>", 

855 message=f"Unknown top-level keys: {', '.join(sorted(unknown))}", 

856 severity=ValidationSeverity.WARNING, 

857 ) 

858 ) 

859 

860 # Resolve fragment libraries before parsing into dataclass 

861 data = resolve_fragments(data, path.parent) 

862 

863 # Parse into dataclass 

864 fsm = FSMLoop.from_dict(data) 

865 

866 # Validate 

867 errors = validate_fsm(fsm) 

868 

869 # Validate with: bindings against child loop parameters (requires file-system access) 

870 errors.extend(_validate_with_bindings(fsm, path.parent)) 

871 

872 # Filter to errors only (not warnings) for raising 

873 error_list = [e for e in errors if e.severity == ValidationSeverity.ERROR] 

874 

875 if error_list: 

876 error_messages = "\n ".join(str(e) for e in error_list) 

877 raise ValueError(f"FSM validation failed:\n {error_messages}") 

878 

879 # Collect all warnings (unknown-key warnings + structural warnings) 

880 struct_warnings = [e for e in errors if e.severity == ValidationSeverity.WARNING] 

881 all_warnings = unknown_key_warnings + struct_warnings 

882 for warning in all_warnings: 

883 logger.warning(str(warning)) 

884 

885 return fsm, all_warnings