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

670 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-08 15:34 -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 

18import re 

19from collections import deque 

20from dataclasses import dataclass 

21from enum import Enum 

22from pathlib import Path 

23from typing import Any 

24 

25import yaml 

26 

27from little_loops.fsm.evaluators import _NUMERIC_OPERATORS 

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

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

30 

31logger = logging.getLogger(__name__) 

32 

33 

34class ValidationSeverity(Enum): 

35 """Severity level for validation issues.""" 

36 

37 ERROR = "error" 

38 WARNING = "warning" 

39 

40 

41@dataclass 

42class ValidationError: 

43 """Structured validation error. 

44 

45 Attributes: 

46 message: Human-readable error description 

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

48 severity: Error severity (error or warning) 

49 """ 

50 

51 message: str 

52 path: str | None = None 

53 severity: ValidationSeverity = ValidationSeverity.ERROR 

54 

55 def __str__(self) -> str: 

56 """Format error for display.""" 

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

58 if self.path: 

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

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

61 

62 

63# Evaluator type to required fields mapping 

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

65 "exit_code": [], 

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

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

68 "output_contains": ["pattern"], 

69 "convergence": ["target"], 

70 "diff_stall": [], 

71 "action_stall": [], 

72 "llm_structured": [], 

73 "mcp_result": [], 

74 "harbor_scorer": [], 

75 "comparator": ["baseline_path"], 

76 "contract": ["pairs"], 

77} 

78 

79# Non-LLM evaluator types: all evaluator types except llm_structured 

80# Derived from EVALUATOR_REQUIRED_FIELDS so new types are automatically included 

81NON_LLM_EVALUATOR_TYPES: frozenset[str] = frozenset(EVALUATOR_REQUIRED_FIELDS.keys()) - { 

82 "llm_structured", 

83 "comparator", 

84 "contract", 

85} 

86 

87# Meta-loop detector: action string patterns that indicate harness artifact writes 

88_META_LOOP_ACTION_PATTERNS: tuple[re.Pattern[str], ...] = ( 

89 re.compile(r"loops/[\w-]+\.yaml"), 

90 re.compile(r"skills/[\w-]+/SKILL\.md"), 

91 re.compile(r"agents/[\w-]+\.md"), 

92 re.compile(r"commands/[\w-]+\.md"), 

93 re.compile(r"\.claude/(CLAUDE\.md|settings)"), 

94) 

95 

96# Action string tokens that indicate meta-loop behavior 

97_META_LOOP_ACTION_TOKENS: frozenset[str] = frozenset({"yaml_state_editor", "replace_action"}) 

98 

99# Import paths that identify a loop as a meta-loop (harness optimization framework) 

100_META_LOOP_IMPORT_TRIGGERS: frozenset[str] = frozenset({"lib/benchmark.yaml"}) 

101 

102# MR-3: shared-tmp path detector. The runner injects ${context.run_dir} resolving 

103# to .loops/runs/<loop>-<timestamp>/; loops that hardcode .loops/tmp/ instead 

104# cause state corruption under concurrent runs (ll-parallel, retries, etc.). 

105_SHARED_TMP_PATH_RE = re.compile(r"\.loops/tmp/[\w./-]+") 

106 

107# ENH-1961: Regex for extracting captured variable names from ${captured.<var>.*} references 

108_CAPTURED_REF_RE = re.compile(r"\$\{captured\.(\w+)") 

109 

110# ENH-1819: Regex patterns for detecting multimodal evaluation in prompt actions 

111_MULTIMODAL_EVAL_PATTERNS: tuple[re.Pattern[str], ...] = ( 

112 re.compile(r"Read the screenshot", re.IGNORECASE), 

113 re.compile(r"view the (generated )?(website|page|image)", re.IGNORECASE), 

114 re.compile(r"screenshot\.(png|jpg|jpeg|webp)"), 

115 re.compile(r"\.(png|jpg|jpeg|webp)\b.*\b(read|view|evaluate|score|judge)", re.IGNORECASE), 

116) 

117 

118# Valid comparison operators 

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

120 

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

122KNOWN_TOP_LEVEL_KEYS: frozenset[str] = frozenset( 

123 { 

124 "name", 

125 "description", 

126 "initial", 

127 "states", 

128 "context", 

129 "parameters", 

130 "scope", 

131 "max_iterations", 

132 "on_max_iterations", 

133 "max_edge_revisits", 

134 "backoff", 

135 "timeout", 

136 "default_timeout", 

137 "maintain", 

138 "llm", 

139 "on_handoff", 

140 "input_key", 

141 "required_inputs", 

142 "config", 

143 "category", 

144 "labels", 

145 "commands", 

146 "targets", 

147 "circuit", 

148 "meta_self_eval_ok", 

149 "shared_state_ok", 

150 "partial_route_ok", 

151 "artifact_versioning", 

152 "artifact_versioning_ok", 

153 "import", 

154 "fragments", 

155 "from", 

156 "flow", 

157 "state_defs", 

158 } 

159) 

160 

161# Special tokens accepted as the `on_repeated_failure` target on the 

162# stall detector — `"abort"` means terminate via _finish("stall_detected"). 

163STALL_SPECIAL_TOKENS: frozenset[str] = frozenset({"abort"}) 

164 

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

166VALID_PARAMETER_TYPES: frozenset[str] = frozenset( 

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

168) 

169 

170 

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

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

173 

174 Args: 

175 state_name: Name of the state containing this evaluator 

176 evaluate: The evaluator configuration to validate 

177 

178 Returns: 

179 List of validation errors found 

180 """ 

181 errors: list[ValidationError] = [] 

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

183 

184 # Check that evaluator type is recognized 

185 valid_types = set(EVALUATOR_REQUIRED_FIELDS.keys()) 

186 if evaluate.type not in valid_types: 

187 errors.append( 

188 ValidationError( 

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

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

191 path=path, 

192 ) 

193 ) 

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

195 

196 # Check required fields for evaluator type 

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

198 for field_name in required: 

199 value = getattr(evaluate, field_name, None) 

200 if value is None: 

201 errors.append( 

202 ValidationError( 

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

204 path=path, 

205 ) 

206 ) 

207 

208 # Validate operator if present 

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

210 errors.append( 

211 ValidationError( 

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

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

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

215 ) 

216 ) 

217 

218 # Validate convergence-specific fields 

219 if evaluate.type == "convergence": 

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

221 errors.append( 

222 ValidationError( 

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

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

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

226 ) 

227 ) 

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

229 if ( 

230 evaluate.tolerance is not None 

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

232 and evaluate.tolerance < 0 

233 ): 

234 errors.append( 

235 ValidationError( 

236 message="Tolerance cannot be negative", 

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

238 ) 

239 ) 

240 

241 # Validate llm_structured-specific fields 

242 if evaluate.type == "llm_structured": 

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

244 errors.append( 

245 ValidationError( 

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

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

248 ) 

249 ) 

250 

251 # Validate diff_stall-specific fields 

252 if evaluate.type == "diff_stall": 

253 if evaluate.max_stall < 1: 

254 errors.append( 

255 ValidationError( 

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

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

258 ) 

259 ) 

260 

261 # Validate action_stall-specific fields 

262 if evaluate.type == "action_stall": 

263 if evaluate.max_repeat < 1: 

264 errors.append( 

265 ValidationError( 

266 message="max_repeat must be >= 1", 

267 path=f"{path}.max_repeat", 

268 ) 

269 ) 

270 

271 return errors 

272 

273 

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

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

276 

277 Args: 

278 fsm: The FSM loop to validate 

279 

280 Returns: 

281 List of validation errors found 

282 """ 

283 errors: list[ValidationError] = [] 

284 

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

286 path = f"parameters.{param_name}" 

287 

288 if param_spec.type not in VALID_PARAMETER_TYPES: 

289 errors.append( 

290 ValidationError( 

291 message=( 

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

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

294 ), 

295 path=path, 

296 ) 

297 ) 

298 

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

300 errors.append( 

301 ValidationError( 

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

303 path=path, 

304 ) 

305 ) 

306 

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

308 errors.append( 

309 ValidationError( 

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

311 path=path, 

312 ) 

313 ) 

314 

315 return errors 

316 

317 

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

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

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

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

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

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

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

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

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

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

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

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

330 return None 

331 

332 

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

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

335 

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

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

338 

339 Args: 

340 fsm: The parent FSM loop 

341 loop_dir: Directory to resolve child loop paths from 

342 

343 Returns: 

344 List of validation errors found 

345 """ 

346 errors: list[ValidationError] = [] 

347 

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

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

350 continue 

351 

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

353 try: 

354 from little_loops.cli.loop._helpers import resolve_loop_path 

355 

356 loop_path = resolve_loop_path(state.loop, loop_dir) 

357 child_fsm, _ = load_and_validate(loop_path) 

358 except Exception: 

359 continue 

360 

361 if not child_fsm.parameters: 

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

363 

364 path = f"states.{state_name}" 

365 

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

367 for key in state.with_: 

368 if key not in child_fsm.parameters: 

369 errors.append( 

370 ValidationError( 

371 message=( 

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

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

374 ), 

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

376 ) 

377 ) 

378 

379 # Required parameters not bound 

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

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

382 errors.append( 

383 ValidationError( 

384 message=( 

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

386 f"is not bound in 'with'" 

387 ), 

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

389 ) 

390 ) 

391 

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

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

394 if param_name not in child_fsm.parameters: 

395 continue 

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

397 continue 

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

399 if type_error: 

400 errors.append( 

401 ValidationError( 

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

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

404 ) 

405 ) 

406 

407 return errors 

408 

409 

410def _validate_fragment_bindings(fsm: FSMLoop, loop_dir: Path) -> list[ValidationError]: 

411 """Validate fragment with: bindings against fragment parameter contracts. 

412 

413 Called from load_and_validate (not validate_fsm) because fragment parameters 

414 are populated by resolve_fragments which runs before dataclass parsing. 

415 

416 Args: 

417 fsm: The FSM loop to validate 

418 loop_dir: Directory containing the loop file (unused; kept for API symmetry with 

419 _validate_with_bindings) 

420 

421 Returns: 

422 List of validation errors found 

423 """ 

424 # Runner-injected vars available at runtime but not at static analysis time 

425 RUNNER_INJECTED = {"run_dir", "loop_name", "started_at", "input_hash"} 

426 

427 errors: list[ValidationError] = [] 

428 

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

430 if not state.fragment_parameters: 

431 continue # No declared contract — nothing to cross-validate 

432 

433 path = f"states.{state_name}" 

434 

435 # Unknown with: keys (not declared by fragment) 

436 for key in state.fragment_bindings: 

437 if key not in state.fragment_parameters: 

438 errors.append( 

439 ValidationError( 

440 message=( 

441 f"'with.{key}' is not a declared parameter of fragment " 

442 f"'{state.fragment_name}'. " 

443 f"Declared: {', '.join(sorted(state.fragment_parameters))}" 

444 ), 

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

446 ) 

447 ) 

448 

449 # Required parameters not bound (whitelist runner-injected vars) 

450 for param_name, param_spec in state.fragment_parameters.items(): 

451 if param_spec.required and param_name not in state.fragment_bindings: 

452 if param_name in RUNNER_INJECTED: 

453 continue # Available at runtime; not a static error 

454 errors.append( 

455 ValidationError( 

456 message=( 

457 f"Required parameter '{param_name}' of fragment " 

458 f"'{state.fragment_name}' is not bound in 'with'" 

459 ), 

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

461 ) 

462 ) 

463 

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

465 for param_name, value in state.fragment_bindings.items(): 

466 if param_name not in state.fragment_parameters: 

467 continue 

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

469 continue 

470 type_error = _check_param_type(value, state.fragment_parameters[param_name]) 

471 if type_error: 

472 errors.append( 

473 ValidationError( 

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

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

476 ) 

477 ) 

478 

479 return errors 

480 

481 

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

483 """Validate state action configuration. 

484 

485 Args: 

486 state_name: Name of the state to validate 

487 state: The state configuration to validate 

488 

489 Returns: 

490 List of validation errors found 

491 """ 

492 errors: list[ValidationError] = [] 

493 path = f"states.{state_name}" 

494 

495 # append_to_messages must contain at least one ${...} interpolation expression 

496 if state.append_to_messages is not None: 

497 if "${" not in state.append_to_messages: 

498 errors.append( 

499 ValidationError( 

500 message=( 

501 "'append_to_messages' must contain a ${...} interpolation expression " 

502 f"(e.g. '${{captured.{state_name}.output}}')" 

503 ), 

504 path=f"{path}.append_to_messages", 

505 ) 

506 ) 

507 

508 # params field is only valid for mcp_tool states 

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

510 errors.append( 

511 ValidationError( 

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

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

514 ) 

515 ) 

516 

517 # loop and action are mutually exclusive 

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

519 errors.append( 

520 ValidationError( 

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

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

523 path=f"{path}", 

524 ) 

525 ) 

526 

527 # with: requires loop: to be set 

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

529 errors.append( 

530 ValidationError( 

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

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

533 ) 

534 ) 

535 

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

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

538 if not state.learning.targets and not state.learning.targets_csv: 

539 errors.append( 

540 ValidationError( 

541 message="type=learning requires non-empty 'learning.targets' or 'learning.targets_csv'", 

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

543 ) 

544 ) 

545 if state.learning.max_retries < 0: 

546 errors.append( 

547 ValidationError( 

548 message=( 

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

550 ), 

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

552 ) 

553 ) 

554 if state.on_yes is None: 

555 errors.append( 

556 ValidationError( 

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

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

559 ) 

560 ) 

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

562 errors.append( 

563 ValidationError( 

564 message=( 

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

566 "(target for refuted / retries_exhausted)" 

567 ), 

568 path=f"{path}", 

569 ) 

570 ) 

571 

572 # with: and context_passthrough are mutually exclusive 

573 if state.with_ and state.context_passthrough: 

574 errors.append( 

575 ValidationError( 

576 message=( 

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

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

579 "for legacy bulk passthrough, not both" 

580 ), 

581 path=f"{path}", 

582 ) 

583 ) 

584 

585 return errors 

586 

587 

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

589 """Validate state routing configuration. 

590 

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

592 

593 Args: 

594 state_name: Name of the state to validate 

595 state: The state configuration to validate 

596 

597 Returns: 

598 List of validation errors/warnings found 

599 """ 

600 errors: list[ValidationError] = [] 

601 path = f"states.{state_name}" 

602 

603 has_shorthand = ( 

604 state.on_yes is not None 

605 or state.on_no is not None 

606 or state.on_error is not None 

607 or state.on_partial is not None 

608 or state.on_blocked is not None 

609 or bool(state.extra_routes) 

610 ) 

611 has_route = state.route is not None 

612 

613 # Warn about conflicting definitions 

614 if has_shorthand and has_route: 

615 errors.append( 

616 ValidationError( 

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

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

619 path=path, 

620 severity=ValidationSeverity.WARNING, 

621 ) 

622 ) 

623 

624 # Check for no valid transition definition 

625 has_next = state.next is not None 

626 has_terminal = state.terminal 

627 has_loop = state.loop is not None 

628 

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

630 errors.append( 

631 ValidationError( 

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

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

634 path=path, 

635 ) 

636 ) 

637 

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

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

640 errors.append( 

641 ValidationError( 

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

643 path=path, 

644 ) 

645 ) 

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

647 errors.append( 

648 ValidationError( 

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

650 path=path, 

651 ) 

652 ) 

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

654 errors.append( 

655 ValidationError( 

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

657 path=path, 

658 ) 

659 ) 

660 

661 # Validate retryable_exit_codes: requires on_error; all codes must be positive ints 

662 if state.retryable_exit_codes is not None: 

663 if state.on_error is None: 

664 errors.append( 

665 ValidationError( 

666 message="'retryable_exit_codes' requires 'on_error' to also be set", 

667 path=path, 

668 ) 

669 ) 

670 for code in state.retryable_exit_codes: 

671 if not isinstance(code, int) or code < 1: 

672 errors.append( 

673 ValidationError( 

674 message=( 

675 f"'retryable_exit_codes' entries must be positive " 

676 f"integers, got {code!r}" 

677 ), 

678 path=f"{path}.retryable_exit_codes", 

679 ) 

680 ) 

681 break 

682 

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

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

685 errors.append( 

686 ValidationError( 

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

688 path=path, 

689 ) 

690 ) 

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

692 errors.append( 

693 ValidationError( 

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

695 path=path, 

696 ) 

697 ) 

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

699 errors.append( 

700 ValidationError( 

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

702 path=path, 

703 ) 

704 ) 

705 if ( 

706 state.rate_limit_backoff_base_seconds is not None 

707 and state.rate_limit_backoff_base_seconds < 1 

708 ): 

709 errors.append( 

710 ValidationError( 

711 message=( 

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

713 f"got {state.rate_limit_backoff_base_seconds}" 

714 ), 

715 path=path, 

716 ) 

717 ) 

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

719 errors.append( 

720 ValidationError( 

721 message=( 

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

723 f"got {state.rate_limit_max_wait_seconds}" 

724 ), 

725 path=path, 

726 ) 

727 ) 

728 if state.rate_limit_long_wait_ladder is not None: 

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

730 errors.append( 

731 ValidationError( 

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

733 path=path, 

734 ) 

735 ) 

736 else: 

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

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

739 errors.append( 

740 ValidationError( 

741 message=( 

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

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

744 ), 

745 path=path, 

746 ) 

747 ) 

748 

749 # Validate throttle config when present 

750 if state.throttle is not None: 

751 t = state.throttle 

752 fields = { 

753 "normal_max": t.normal_max, 

754 "warn_max": t.warn_max, 

755 "hard_max": t.hard_max, 

756 } 

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

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

759 errors.append( 

760 ValidationError( 

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

762 path=path, 

763 ) 

764 ) 

765 # Enforce ordering when all three are set 

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

767 errors.append( 

768 ValidationError( 

769 message=( 

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

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

772 ), 

773 path=path, 

774 ) 

775 ) 

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

777 errors.append( 

778 ValidationError( 

779 message=( 

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

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

782 ), 

783 path=path, 

784 ) 

785 ) 

786 

787 return errors 

788 

789 

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

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

792 

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

794 end with a .yaml extension. 

795 """ 

796 errors: list[ValidationError] = [] 

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

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

799 errors.append( 

800 ValidationError( 

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

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

803 ) 

804 ) 

805 return errors 

806 

807 

808def _validate_failure_terminal_action(fsm: FSMLoop) -> list[ValidationError]: 

809 """Warn when a failure-named terminal state has no diagnostic predecessor. 

810 

811 Failure terminals (failed, error, aborted) should have at least one 

812 predecessor state with an action or sub-loop that provides diagnostic 

813 output before termination. Otherwise the failure is silent — the 

814 executor calls _finish("terminal") before any action on the terminal 

815 itself can execute. 

816 

817 Severity is WARNING (not ERROR) so that existing loops with bare 

818 failure terminals continue to load, and test_terminal_only_state_valid 

819 (which filters by ERROR) passes without modification. 

820 """ 

821 FAILURE_TERMINAL_NAMES: frozenset[str] = frozenset({"failed", "error", "aborted"}) 

822 errors: list[ValidationError] = [] 

823 

824 terminal_states = fsm.get_terminal_states() 

825 failure_terminals = terminal_states & FAILURE_TERMINAL_NAMES 

826 

827 for ft_name in failure_terminals: 

828 has_diagnostic_predecessor = False 

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

830 if state_name == ft_name: 

831 continue 

832 if ft_name in state.get_referenced_states(): 

833 if state.action is not None or state.loop is not None: 

834 has_diagnostic_predecessor = True 

835 break 

836 

837 if not has_diagnostic_predecessor: 

838 errors.append( 

839 ValidationError( 

840 message=( 

841 f"Failure-named terminal state '{ft_name}' has no predecessor " 

842 "state with a diagnostic action. Add a non-terminal diagnostic " 

843 "state (e.g. 'diagnose') with an action or sub-loop that routes " 

844 f"to '{ft_name}'." 

845 ), 

846 path=f"states.{ft_name}", 

847 severity=ValidationSeverity.WARNING, 

848 ) 

849 ) 

850 

851 return errors 

852 

853 

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

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

856 

857 Performs comprehensive validation: 

858 - Initial state exists 

859 - All referenced states exist 

860 - At least one terminal state 

861 - Evaluator configurations are valid 

862 - Routing configurations are valid 

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

864 

865 Args: 

866 fsm: The FSM loop to validate 

867 

868 Returns: 

869 List of validation errors (empty if valid) 

870 """ 

871 errors: list[ValidationError] = [] 

872 defined_states = fsm.get_all_state_names() 

873 

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

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

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

877 if not fsm.description: 

878 errors.append( 

879 ValidationError( 

880 path="<root>", 

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

882 severity=ValidationSeverity.WARNING, 

883 ) 

884 ) 

885 

886 # Validate parameters block 

887 errors.extend(_validate_parameters(fsm)) 

888 

889 # Validate targets block (ENH-1552) 

890 errors.extend(_validate_targets(fsm)) 

891 

892 # Check initial state exists 

893 if fsm.initial not in defined_states: 

894 errors.append( 

895 ValidationError( 

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

897 path="initial", 

898 ) 

899 ) 

900 

901 # Check at least one terminal state 

902 terminal_states = fsm.get_terminal_states() 

903 if not terminal_states: 

904 errors.append( 

905 ValidationError( 

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

907 path="states", 

908 ) 

909 ) 

910 

911 # Validate each state 

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

913 # Check all referenced states exist 

914 refs = state.get_referenced_states() 

915 for ref in refs: 

916 # $current is a special token for retry 

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

918 errors.append( 

919 ValidationError( 

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

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

922 ) 

923 ) 

924 

925 # Validate action configuration 

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

927 

928 # Validate evaluator if present 

929 if state.evaluate is not None: 

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

931 

932 # Validate routing configuration 

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

934 

935 # Check numeric field ranges 

936 if fsm.max_iterations <= 0: 

937 errors.append( 

938 ValidationError( 

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

940 path="max_iterations", 

941 ) 

942 ) 

943 if fsm.max_edge_revisits <= 0: 

944 errors.append( 

945 ValidationError( 

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

947 path="max_edge_revisits", 

948 ) 

949 ) 

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

951 errors.append( 

952 ValidationError( 

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

954 path="backoff", 

955 ) 

956 ) 

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

958 errors.append( 

959 ValidationError( 

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

961 path="timeout", 

962 ) 

963 ) 

964 if fsm.llm.max_tokens <= 0: 

965 errors.append( 

966 ValidationError( 

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

968 path="llm.max_tokens", 

969 ) 

970 ) 

971 if fsm.llm.timeout <= 0: 

972 errors.append( 

973 ValidationError( 

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

975 path="llm.timeout", 

976 ) 

977 ) 

978 

979 # Check for unreachable states (warning only) 

980 reachable = _find_reachable_states(fsm) 

981 unreachable = defined_states - reachable 

982 for state_name in unreachable: 

983 errors.append( 

984 ValidationError( 

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

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

987 severity=ValidationSeverity.WARNING, 

988 ) 

989 ) 

990 

991 errors.extend(_validate_failure_terminal_action(fsm)) 

992 

993 errors.extend(_validate_meta_loop_evaluation(fsm)) 

994 

995 errors.extend(_validate_input_key_without_guard(fsm)) 

996 

997 errors.extend(_validate_artifact_isolation(fsm)) 

998 

999 errors.extend(_validate_harness_multimodal_evaluator_blind_spot(fsm)) 

1000 

1001 errors.extend(_validate_partial_route_dead_end(fsm)) 

1002 

1003 errors.extend(_validate_artifact_overwrite(fsm)) 

1004 

1005 errors.extend(_validate_zero_retry_counter(fsm)) 

1006 

1007 errors.extend(_validate_on_max_iterations(fsm, defined_states)) 

1008 

1009 errors.extend(_validate_circuit(fsm, defined_states)) 

1010 

1011 errors.extend(_validate_progress_paths_isolation(fsm)) 

1012 

1013 errors.extend(_validate_capture_reachability(fsm)) 

1014 

1015 return errors 

1016 

1017 

1018def _is_meta_loop(fsm: FSMLoop) -> bool: 

1019 """Return True if fsm is classified as a meta-loop. 

1020 

1021 A loop is meta if ANY of the following match: 

1022 1. Any state action string matches a harness-artifact path regex 

1023 (writes another loop YAML, skill, agent, command, or project config) 

1024 2. The loop's import list contains lib/benchmark.yaml 

1025 3. Any state action references yaml_state_editor or replace_action 

1026 """ 

1027 # Condition 2: imports lib/benchmark.yaml 

1028 if any(imp in _META_LOOP_IMPORT_TRIGGERS for imp in fsm.imports): 

1029 return True 

1030 # Conditions 1 and 3: scan action strings 

1031 for state in fsm.states.values(): 

1032 if state.action is None: 

1033 continue 

1034 for pattern in _META_LOOP_ACTION_PATTERNS: 

1035 if pattern.search(state.action): 

1036 return True 

1037 for token in _META_LOOP_ACTION_TOKENS: 

1038 if token in state.action: 

1039 return True 

1040 return False 

1041 

1042 

1043def _validate_meta_loop_evaluation(fsm: FSMLoop) -> list[ValidationError]: 

1044 """Validate meta-loop evaluation rules MR-1 and MR-2. 

1045 

1046 MR-1 (ERROR): meta-loop must have at least one non-LLM evaluator. 

1047 MR-2 (WARNING): meta-loop should reference a captured baseline in an evaluator. 

1048 

1049 Both rules are suppressed by ``meta_self_eval_ok: true`` at the loop top-level. 

1050 """ 

1051 errors: list[ValidationError] = [] 

1052 if fsm.meta_self_eval_ok or not _is_meta_loop(fsm): 

1053 return errors 

1054 

1055 # Collect all evaluator types used across all states 

1056 evaluator_types: set[str] = set() 

1057 for state in fsm.states.values(): 

1058 if state.evaluate is not None: 

1059 evaluator_types.add(state.evaluate.type) 

1060 

1061 # MR-1: must have at least one non-LLM evaluator 

1062 if not evaluator_types & NON_LLM_EVALUATOR_TYPES: 

1063 errors.append( 

1064 ValidationError( 

1065 message=( 

1066 "Loop modifies harness artifacts but has no non-LLM evaluator. " 

1067 "LLM self-grades on harness updates are unreliable (SHOR Table 1: " 

1068 "33-55% accuracy). Pair every check_semantic state with at least one " 

1069 "of: exit_code, output_numeric, convergence, diff_stall, action_stall, mcp_result. " 

1070 "Note: llm_structured and comparator both use the LLM and do not satisfy MR-1. " 

1071 "To suppress with justification, set `meta_self_eval_ok: true` at the " 

1072 "loop top-level." 

1073 ), 

1074 path="<root>", 

1075 severity=ValidationSeverity.ERROR, 

1076 ) 

1077 ) 

1078 

1079 # MR-2: should reference a captured baseline in a later evaluator 

1080 capture_names: set[str] = {state.capture for state in fsm.states.values() if state.capture} 

1081 if capture_names and not _has_baseline_reference(fsm, capture_names): 

1082 errors.append( 

1083 ValidationError( 

1084 message=( 

1085 "Meta-loop appears to lack a measure→propose→apply→re-measure " 

1086 "spine: no captured baseline value is referenced by a later evaluator. " 

1087 "Meta-loops should compare a post-change score against a pre-change " 

1088 "baseline (see loops/harness-optimize.yaml as reference template). " 

1089 "To suppress, set `meta_self_eval_ok: true`." 

1090 ), 

1091 path="<root>", 

1092 severity=ValidationSeverity.WARNING, 

1093 ) 

1094 ) 

1095 

1096 return errors 

1097 

1098 

1099# Regex patterns for detecting counter-increment actions. 

1100# Must contain a printf/echo writing to a file AND an arithmetic increment. 

1101_COUNTER_FILE_WRITE_RE = re.compile(r"(?:printf|echo)\s+.*>") 

1102_COUNTER_INCREMENT_RE = re.compile( 

1103 r"\$\(\(.*\+\s*1\s*\)\)" # $((N + 1)) or $((N+1)) 

1104 r"|\+\+" # C-style increment 

1105 r"|\+=1" # compound assignment 

1106 r"|awk\s+.*\+\+" # awk with increment 

1107) 

1108 

1109 

1110def _validate_zero_retry_counter(fsm: FSMLoop) -> list[ValidationError]: 

1111 """Detect counter + output_numeric combos that yield zero effective retries. 

1112 

1113 A common loop-authoring footgun: a state increments a counter file and then 

1114 evaluates ``output_numeric`` with ``operator: lt, target: 1`` against it. 

1115 After the first increment the counter is 1, ``1 < 1 == false``, so the 

1116 retry budget is 0 by construction. Author almost always intended target=2. 

1117 """ 

1118 errors: list[ValidationError] = [] 

1119 

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

1121 if not state.action or not state.evaluate: 

1122 continue 

1123 

1124 ev = state.evaluate 

1125 if ev.type != "output_numeric": 

1126 continue 

1127 if ev.operator is None or ev.target is None: 

1128 continue 

1129 

1130 # Must be a number-like target for numeric comparison 

1131 try: 

1132 target = float(ev.target) 

1133 except (ValueError, TypeError): 

1134 continue 

1135 

1136 if not _is_counter_action(state.action): 

1137 continue 

1138 

1139 # Check: after first increment (0→1), does operator(1, target) already fail? 

1140 op_fn = _NUMERIC_OPERATORS.get(ev.operator) 

1141 if op_fn is None: 

1142 continue 

1143 

1144 if not op_fn(1.0, target): 

1145 suggested_target = _suggested_target(ev.operator, target) 

1146 errors.append( 

1147 ValidationError( 

1148 message=( 

1149 f"Zero retry budget: operator={ev.operator} target={target} " 

1150 f"means the first post-increment value (1) already fails " 

1151 f"({ev.operator}(1, {target}) == False). " 

1152 f"Did you mean target={suggested_target}?" 

1153 ), 

1154 path=f"states.{state_name}.evaluate", 

1155 severity=ValidationSeverity.WARNING, 

1156 ) 

1157 ) 

1158 

1159 return errors 

1160 

1161 

1162def _is_counter_action(action: str) -> bool: 

1163 """Return True if the action string contains a counter-increment pattern.""" 

1164 return bool(_COUNTER_FILE_WRITE_RE.search(action) and _COUNTER_INCREMENT_RE.search(action)) 

1165 

1166 

1167def _suggested_target(operator: str, target: float) -> str: 

1168 """Suggest a target value that allows at least one retry.""" 

1169 # For lt/le with a too-low target, suggest target+1 so first post-increment passes 

1170 if operator in ("lt", "le"): 

1171 return str(int(target) + 1) 

1172 # For eq with target=0, suggest 1 so the counter can eventually match 

1173 if operator == "eq" and target == 0: 

1174 return "1" 

1175 # For other cases, suggest target+1 as a default nudge 

1176 return str(int(target) + 1) 

1177 

1178 

1179def _validate_harness_multimodal_evaluator_blind_spot(fsm: FSMLoop) -> list[ValidationError]: 

1180 """Warn when harness loops use LLM multimodal eval as sole gate to terminal. 

1181 

1182 LLMs can silently fall back to text-only analysis when reading images, 

1183 producing verdicts based on incomplete information. The output_contains 

1184 evaluator can verify the LLM wrote the pass string but not that it 

1185 actually processed the image. This is the same class of failure as MR-1 

1186 (LLM self-evaluation bias) applied to artifact evaluation rather than 

1187 harness modification. 

1188 

1189 Suppressed by ``meta_self_eval_ok: true`` at the loop top-level. 

1190 """ 

1191 errors: list[ValidationError] = [] 

1192 if fsm.meta_self_eval_ok or fsm.category != "harness": 

1193 return errors 

1194 

1195 terminal_states = fsm.get_terminal_states() 

1196 

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

1198 if state.action_type != "prompt" or not state.action: 

1199 continue 

1200 if state.evaluate is None or state.evaluate.type != "output_contains": 

1201 continue 

1202 if not any(p.search(state.action) for p in _MULTIMODAL_EVAL_PATTERNS): 

1203 continue 

1204 if state.on_yes not in terminal_states: 

1205 continue 

1206 errors.append( 

1207 ValidationError( 

1208 message=( 

1209 f"State '{state_name}' evaluates a screenshot/image via LLM " 

1210 "prompt and routes directly to a terminal on success. The " 

1211 "output_contains evaluator can verify the LLM wrote the pass " 

1212 "string but not that the LLM actually processed the image. " 

1213 "Consider adding a shell-action verification state (e.g., " 

1214 "functional smoke test) between scoring and the terminal." 

1215 ), 

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

1217 severity=ValidationSeverity.WARNING, 

1218 ) 

1219 ) 

1220 

1221 return errors 

1222 

1223 

1224def _find_shared_tmp_writes(fsm: FSMLoop) -> list[tuple[str, str]]: 

1225 """Return (state_name, matched_path) for every action referencing shared .loops/tmp/. 

1226 

1227 Scans `state.action` only. Prompts and sub-loop bindings can also reference 

1228 paths, but those are out of static-scan reach: action strings are the 

1229 place where loop YAMLs directly encode artifact paths. 

1230 """ 

1231 findings: list[tuple[str, str]] = [] 

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

1233 if not state.action: 

1234 continue 

1235 for match in _SHARED_TMP_PATH_RE.finditer(state.action): 

1236 findings.append((state_name, match.group(0))) 

1237 return findings 

1238 

1239 

1240def _validate_input_key_without_guard(fsm: FSMLoop) -> list[ValidationError]: 

1241 """Warn when a loop sets a custom input_key but omits required_inputs. 

1242 

1243 A loop that accepts a runtime input via a named key (e.g. input_key: description) 

1244 but doesn't declare required_inputs will silently proceed with an empty value if the 

1245 user forgets to pass one. Declaring required_inputs shifts that failure to start-time. 

1246 """ 

1247 if fsm.input_key == "input": 

1248 return [] 

1249 if fsm.required_inputs: 

1250 return [] 

1251 return [ 

1252 ValidationError( 

1253 message=( 

1254 f"Loop sets input_key: '{fsm.input_key}' but does not declare " 

1255 f"required_inputs. If this input is mandatory, add " 

1256 f"'required_inputs: [\"{fsm.input_key}\"]' to make the runner " 

1257 f"abort when no input is provided." 

1258 ), 

1259 path="required_inputs", 

1260 severity=ValidationSeverity.WARNING, 

1261 ) 

1262 ] 

1263 

1264 

1265def _validate_artifact_isolation(fsm: FSMLoop) -> list[ValidationError]: 

1266 """Validate rule MR-3: loops must isolate artifacts to ${context.run_dir}. 

1267 

1268 The runner injects ${context.run_dir} pointing at .loops/runs/<loop>-<ts>/ 

1269 and creates the folder before execution. Loops that write intermediate 

1270 state (queues, checkpoints, generated files) to shared .loops/tmp/ paths 

1271 will corrupt each other under concurrent runs (ll-parallel workers, retries, 

1272 repeated invocations). 

1273 

1274 Suppressed by `shared_state_ok: true` at the loop top-level for loops that 

1275 intentionally share state across runs. 

1276 """ 

1277 if fsm.shared_state_ok: 

1278 return [] 

1279 errors: list[ValidationError] = [] 

1280 for state_name, path in _find_shared_tmp_writes(fsm): 

1281 errors.append( 

1282 ValidationError( 

1283 message=( 

1284 f"State writes to shared '{path}' instead of " 

1285 "'${context.run_dir}/...'. Concurrent runs of this loop " 

1286 "(e.g., under ll-parallel) will corrupt each other's state. " 

1287 "Use the runner-injected `${context.run_dir}` for per-run " 

1288 "artifact paths, or set `shared_state_ok: true` at the loop " 

1289 "top-level if cross-run sharing is intentional." 

1290 ), 

1291 path=f"states.{state_name}.action", 

1292 severity=ValidationSeverity.WARNING, 

1293 ) 

1294 ) 

1295 return errors 

1296 

1297 

1298def _is_llm_judged(state: StateConfig) -> bool: 

1299 """Return True if this state will be graded by the default LLM judge. 

1300 

1301 Mirrors the action-mode detection in executor._action_mode() (not imported 

1302 here because that is runtime code). A state is LLM-judged when: 

1303 - it has no explicit evaluate block AND its action is a prompt or slash_command, OR 

1304 - it has an explicit evaluate block of type llm_structured or check_semantic. 

1305 """ 

1306 if state.evaluate is None: 

1307 # Heuristic: explicit action_type wins; fall back to leading "/" on action string. 

1308 action_type = state.action_type 

1309 if action_type in ("prompt", "slash_command"): 

1310 return True 

1311 if action_type is None and state.action and state.action.lstrip().startswith("/"): 

1312 return True 

1313 return False 

1314 return state.evaluate.type in ("llm_structured", "check_semantic") 

1315 

1316 

1317def _validate_partial_route_dead_end(fsm: FSMLoop) -> list[ValidationError]: 

1318 """Validate rule MR-4: LLM-judged states with only on_yes have a partial/no dead-end. 

1319 

1320 A state gated by the default LLM judge can receive yes/no/partial verdicts. 

1321 If only on_yes is mapped (and no on_no, on_partial, next, or route table with 

1322 a default exist), a partial or no verdict returns None from _route and silently 

1323 terminates the loop — the parent treats this as failed. 

1324 

1325 Suppressed by `partial_route_ok: true` at the loop top-level for the rare 

1326 case where dead-ending on a non-yes verdict is intentional. 

1327 """ 

1328 if fsm.partial_route_ok: 

1329 return [] 

1330 errors: list[ValidationError] = [] 

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

1332 if not _is_llm_judged(state): 

1333 continue 

1334 # States with an unconditional next: or a full route: table are safe. 

1335 if state.next is not None or state.route is not None: 

1336 continue 

1337 # Only flag when on_yes is set but at least one of on_no/on_partial is missing. 

1338 if state.on_yes is None: 

1339 continue 

1340 missing = [v for v in ("no", "partial") if getattr(state, f"on_{v}") is None] 

1341 if not missing: 

1342 continue 

1343 unrouted = " or ".join(f"`{v}`" for v in missing) 

1344 errors.append( 

1345 ValidationError( 

1346 message=( 

1347 f"[state: {state_name}] LLM-judged prompt routes only on_yes; " 

1348 f"a {unrouted} verdict has no route and will dead-end the loop " 

1349 "(parent reads this as failed). Add on_no/on_partial, use `next:` " 

1350 "for an unconditional handoff, or a `route:` table with a default. " 

1351 "Set `partial_route_ok: true` at the loop top-level to suppress " 

1352 "if intentional. (ENH-1917)" 

1353 ), 

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

1355 severity=ValidationSeverity.WARNING, 

1356 ) 

1357 ) 

1358 return errors 

1359 

1360 

1361def _validate_artifact_overwrite(fsm: FSMLoop) -> list[ValidationError]: 

1362 """Validate rule MR-5 (ENH-1957): harness loops should version artifacts per iteration. 

1363 

1364 A harness-category loop that iteratively generates and overwrites a flat artifact 

1365 path (e.g. ``${context.run_dir}/image.svg``) loses all intermediate versions. 

1366 Only the final iteration survives. This rule flags iterative generate→evaluate→generate 

1367 cycles that write to artifact paths without declaring versioning intent. 

1368 

1369 Suppressed by ``artifact_versioning: true`` (loop snapshots per-iteration artifacts) 

1370 or ``artifact_versioning_ok: true`` (intentional overwrite, e.g. artifact varies 

1371 by task). 

1372 """ 

1373 if fsm.artifact_versioning or fsm.artifact_versioning_ok: 

1374 return [] 

1375 if fsm.category not in ("harness",): 

1376 return [] 

1377 

1378 errors: list[ValidationError] = [] 

1379 

1380 # Find states that write to artifact paths (shell actions with file output) 

1381 writers: dict[str, set[str]] = {} # state_name -> set of artifact paths 

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

1383 if not state.action or state.action_type not in ("shell", None): 

1384 continue 

1385 # Skip sub-loop delegation states 

1386 if state.action_type == "loop": 

1387 continue 

1388 action = state.action 

1389 # Find run_dir-based artifact writes: ${context.run_dir}/<path> or $RUNDIR/<path> 

1390 import re 

1391 

1392 # Match patterns like: ${context.run_dir}/output.svg, $RUNDIR/image.png, > path 

1393 # We look for output redirections or cp/mv commands writing to run_dir 

1394 artifact_refs = set() 

1395 for pattern in ( 

1396 r'\$\{context\.run_dir\}/([^\s"\';&]+)', 

1397 r'\$\{captured\.[^}]+\}/([^\s"\';&]+)', 

1398 ): 

1399 for m in re.finditer(pattern, action): 

1400 artifact_refs.add(m.group(1)) 

1401 # Also detect explicit cp/mv writing to run_dir paths 

1402 for m in re.finditer(r'(?:cp|mv)\s+.*\s+\$\{context\.run_dir\}/([^\s"\';&]+)', action): 

1403 artifact_refs.add(m.group(1)) 

1404 if artifact_refs: 

1405 writers[state_name] = artifact_refs 

1406 

1407 if not writers: 

1408 return [] 

1409 

1410 # Detect iterative cycles: a writer state that is reachable from itself 

1411 # via a non-trivial path through other states (generate → evaluate → generate) 

1412 for state_name in writers: 

1413 refs = fsm.states[state_name].get_referenced_states() 

1414 # Check if this writer or its downstream states loop back to this writer 

1415 visited: set[str] = set() 

1416 to_visit = list(refs - {"$current"}) 

1417 while to_visit: 

1418 target = to_visit.pop() 

1419 if target in visited or target not in fsm.states: 

1420 continue 

1421 visited.add(target) 

1422 if target == state_name: 

1423 # Found a cycle: this writer state is reachable from itself 

1424 artifact_list = ", ".join(sorted(writers[state_name])) 

1425 errors.append( 

1426 ValidationError( 

1427 message=( 

1428 f"[state: {state_name}] Harness loop writes artifact(s) " 

1429 f"({artifact_list}) to a flat path in an iterative cycle " 

1430 f"({state_name} → ... → {state_name}). Per-iteration versions " 

1431 "are lost; only the final output survives. Add per-iteration " 

1432 "snapshots (see oracle generator-evaluator for pattern) and " 

1433 "declare `artifact_versioning: true`, or set " 

1434 "`artifact_versioning_ok: true` if intentional. (ENH-1957)" 

1435 ), 

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

1437 severity=ValidationSeverity.WARNING, 

1438 ) 

1439 ) 

1440 break 

1441 target_state = fsm.states.get(target) 

1442 if target_state is not None: 

1443 target_refs = target_state.get_referenced_states() 

1444 for r in target_refs: 

1445 if r != "$current" and r not in visited: 

1446 to_visit.append(r) 

1447 

1448 return errors 

1449 

1450 

1451def _has_baseline_reference(fsm: FSMLoop, capture_names: set[str]) -> bool: 

1452 """Return True if any evaluate block references a captured variable.""" 

1453 for state in fsm.states.values(): 

1454 ev = state.evaluate 

1455 if ev is None: 

1456 continue 

1457 # Check string fields that may interpolate captured values 

1458 candidates = [ev.previous, ev.source] 

1459 if isinstance(ev.target, str): 

1460 candidates.append(ev.target) 

1461 for field_val in candidates: 

1462 if not field_val: 

1463 continue 

1464 for name in capture_names: 

1465 if f"captured.{name}" in field_val: 

1466 return True 

1467 return False 

1468 

1469 

1470def _validate_on_max_iterations(fsm: FSMLoop, defined_states: set[str]) -> list[ValidationError]: 

1471 """Validate the top-level `on_max_iterations` field (ENH-1631). 

1472 

1473 Checks that the named state exists when `on_max_iterations` is set. 

1474 """ 

1475 errors: list[ValidationError] = [] 

1476 if fsm.on_max_iterations is None: 

1477 return errors 

1478 if fsm.on_max_iterations not in defined_states: 

1479 errors.append( 

1480 ValidationError( 

1481 message=( 

1482 f"on_max_iterations references unknown state " 

1483 f"'{fsm.on_max_iterations}' (must be a declared state)" 

1484 ), 

1485 path="on_max_iterations", 

1486 ) 

1487 ) 

1488 return errors 

1489 

1490 

1491def _validate_circuit(fsm: FSMLoop, defined_states: set[str]) -> list[ValidationError]: 

1492 """Validate the top-level `circuit:` block (FEAT-1637). 

1493 

1494 Checks: 

1495 - `circuit.repeated_failure.window` is a positive integer. 

1496 - `circuit.repeated_failure.on_repeated_failure` is either the special 

1497 token ``"abort"`` or the name of a declared state. 

1498 """ 

1499 errors: list[ValidationError] = [] 

1500 if fsm.circuit is None or fsm.circuit.repeated_failure is None: 

1501 return errors 

1502 

1503 rf = fsm.circuit.repeated_failure 

1504 if rf.window < 1: 

1505 errors.append( 

1506 ValidationError( 

1507 message=f"circuit.repeated_failure.window must be >= 1, got {rf.window}", 

1508 path="circuit.repeated_failure.window", 

1509 ) 

1510 ) 

1511 

1512 target = rf.on_repeated_failure 

1513 if target not in STALL_SPECIAL_TOKENS and target not in defined_states: 

1514 errors.append( 

1515 ValidationError( 

1516 message=( 

1517 f"circuit.repeated_failure.on_repeated_failure references " 

1518 f"unknown state '{target}' (must be a declared state or " 

1519 f'the literal "abort")' 

1520 ), 

1521 path="circuit.repeated_failure.on_repeated_failure", 

1522 ) 

1523 ) 

1524 

1525 return errors 

1526 

1527 

1528# Matches common interpolation prefixes used in loop YAML paths so we can 

1529# extract the portable relative component for action-string scanning. 

1530_INTERPOLATION_PREFIX_RE = re.compile(r"^\$\{[^}]+\}/") 

1531 

1532 

1533def _strip_interpolation_prefix(path: str) -> str: 

1534 """Return the path with any leading ${...}/ prefix removed.""" 

1535 return _INTERPOLATION_PREFIX_RE.sub("", path) 

1536 

1537 

1538def _validate_progress_paths_isolation(fsm: FSMLoop) -> list[ValidationError]: 

1539 """Warn when a state's action writes to a file listed in progress_paths (BUG-1767). 

1540 

1541 When a loop's own bookkeeping files appear in both progress_paths and the 

1542 state action strings, every append to those files resets the stall window, 

1543 silently disabling the BUG-1674 stall guard for that loop. Authors should 

1544 move such files to exclude_paths so the stall detector can still fire. 

1545 """ 

1546 if fsm.circuit is None or fsm.circuit.repeated_failure is None: 

1547 return [] 

1548 rf = fsm.circuit.repeated_failure 

1549 if not rf.progress_paths: 

1550 return [] 

1551 

1552 # Build a set of the relative path components we need to look for. 

1553 watched = {_strip_interpolation_prefix(p) for p in rf.progress_paths} 

1554 # Exclude paths that are already in exclude_paths — author acknowledged. 

1555 excluded = {_strip_interpolation_prefix(p) for p in rf.exclude_paths} 

1556 active_watched = watched - excluded 

1557 if not active_watched: 

1558 return [] 

1559 

1560 errors: list[ValidationError] = [] 

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

1562 if not state.action: 

1563 continue 

1564 for path_fragment in active_watched: 

1565 if path_fragment in state.action: 

1566 errors.append( 

1567 ValidationError( 

1568 message=( 

1569 f"State action references '{path_fragment}', which is also " 

1570 "listed in circuit.repeated_failure.progress_paths. Writes " 

1571 "to this file will reset the stall window every cycle, " 

1572 "silently disabling stall detection. Move it to " 

1573 "circuit.repeated_failure.exclude_paths to separate " 

1574 "bookkeeping files from real progress signals." 

1575 ), 

1576 path=f"states.{state_name}.action", 

1577 severity=ValidationSeverity.WARNING, 

1578 ) 

1579 ) 

1580 return errors 

1581 

1582 

1583def _dominated_by_any(fsm: FSMLoop, dominators: set[str], dominated: str) -> bool: 

1584 """Return True if the set ``dominators`` collectively dominates ``dominated``. 

1585 

1586 Group domination: every path from the initial state to ``dominated`` must 

1587 pass through at least one state in ``dominators``. Checked by removing all 

1588 dominator states from the graph and testing whether ``dominated`` is still 

1589 reachable from the initial state. 

1590 

1591 This generalizes single-state domination — used when a capture variable is 

1592 produced by more than one state on mutually-exclusive branches, where the 

1593 reference is safe as long as *some* capturing state runs on every path. 

1594 

1595 Args: 

1596 fsm: The FSM loop to analyze 

1597 dominators: Names of the states that should collectively dominate 

1598 dominated: Name of the state that should be dominated 

1599 

1600 Returns: 

1601 True if the dominators collectively dominate ``dominated`` 

1602 """ 

1603 if dominated in dominators: 

1604 return True 

1605 if dominated not in fsm.states: 

1606 return False 

1607 

1608 visited: set[str] = set() 

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

1610 

1611 while to_visit: 

1612 current = to_visit.popleft() 

1613 if current in visited or current not in fsm.states: 

1614 continue 

1615 if current in dominators: 

1616 continue # Block this node (simulate removal) 

1617 

1618 visited.add(current) 

1619 

1620 if current == dominated: 

1621 # Reached dominated without going through any dominator 

1622 return False 

1623 

1624 state = fsm.states[current] 

1625 for ref in state.get_referenced_states(): 

1626 if ref != "$current" and ref not in visited: 

1627 to_visit.append(ref) 

1628 

1629 # Dominated not reachable without the dominators → they dominate 

1630 return True 

1631 

1632 

1633def _dominates(fsm: FSMLoop, dominator: str, dominated: str) -> bool: 

1634 """Return True if dominator dominates dominated in the FSM graph. 

1635 

1636 A state D dominates S if every path from the initial state to S must pass 

1637 through D. Thin single-state wrapper around :func:`_dominated_by_any`. 

1638 """ 

1639 if dominator not in fsm.states: 

1640 return False 

1641 return _dominated_by_any(fsm, {dominator}, dominated) 

1642 

1643 

1644def _find_bypass_path_any(fsm: FSMLoop, dominators: set[str], dominated: str) -> list[str]: 

1645 """Find an example path from initial to dominated that bypasses all dominators. 

1646 

1647 Uses BFS to find the shortest path that avoids every state in ``dominators``. 

1648 Returns empty list if no bypass exists (should not happen when called after 

1649 :func:`_dominated_by_any` returns False). 

1650 """ 

1651 parent: dict[str, str] = {} 

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

1653 visited: set[str] = set() 

1654 

1655 while to_visit: 

1656 current = to_visit.popleft() 

1657 if current in visited or current not in fsm.states: 

1658 continue 

1659 if current in dominators: 

1660 continue 

1661 

1662 visited.add(current) 

1663 

1664 if current == dominated: 

1665 # Reconstruct path 

1666 path = [dominated] 

1667 while path[-1] in parent: 

1668 path.append(parent[path[-1]]) 

1669 path.reverse() 

1670 return path 

1671 

1672 state = fsm.states[current] 

1673 for ref in state.get_referenced_states(): 

1674 if ref != "$current" and ref not in visited: 

1675 if ref not in parent: 

1676 parent[ref] = current 

1677 to_visit.append(ref) 

1678 

1679 return [] 

1680 

1681 

1682def _find_bypass_path(fsm: FSMLoop, dominator: str, dominated: str) -> list[str]: 

1683 """Find an example path from initial to dominated that bypasses dominator. 

1684 

1685 Thin single-state wrapper around :func:`_find_bypass_path_any`. 

1686 """ 

1687 return _find_bypass_path_any(fsm, {dominator}, dominated) 

1688 

1689 

1690def _has_sub_loop_state(fsm: FSMLoop) -> bool: 

1691 """Return True if any state in the FSM has ``loop:`` set (delegates to a child loop). 

1692 

1693 Used by ENH-1961 to distinguish "capture lives in a sub-loop" from "capture is missing". 

1694 """ 

1695 return any(state.loop is not None for state in fsm.states.values()) 

1696 

1697 

1698def _validate_capture_reachability(fsm: FSMLoop) -> list[ValidationError]: 

1699 """Validate that ``${captured.*}`` references are dominated by their capturing states. 

1700 

1701 ENH-1961: For each state that references ``${captured.<var>.*}`` in its action 

1702 or evaluate source, checks that the capturing state dominates the referencing 

1703 state (i.e., all paths from the initial state pass through the capture state). 

1704 

1705 Emits: 

1706 - WARNING when a capture state does not dominate a referencing state 

1707 (the reference may crash at runtime on paths that bypass the capture). 

1708 - ERROR when a referenced capture variable has no capturing state at all 

1709 in this FSM (excluding sub-loop captures which live in child namespaces). 

1710 """ 

1711 errors: list[ValidationError] = [] 

1712 

1713 # Step 1: Build capture map (var_name → set of capturing state names). 

1714 # A variable may be captured by more than one state on mutually-exclusive 

1715 # branches (e.g. fifo_pop vs select_next dispatched by schedule_mode); the 

1716 # reference is safe as long as the *set* collectively dominates it. 

1717 capture_map: dict[str, set[str]] = {} 

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

1719 if state.capture: 

1720 capture_map.setdefault(state.capture, set()).add(state_name) 

1721 

1722 # Step 2: Build reference map (state_name → set of captured var names referenced) 

1723 reference_map: dict[str, set[str]] = {} 

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

1725 # Skip sub-loop delegation states — their action is a loop name, 

1726 # and captured vars belong to the child loop's namespace. 

1727 if state.loop is not None: 

1728 continue 

1729 

1730 refs: set[str] = set() 

1731 if state.action: 

1732 refs.update(_CAPTURED_REF_RE.findall(state.action)) 

1733 if state.evaluate is not None and state.evaluate.source: 

1734 refs.update(_CAPTURED_REF_RE.findall(state.evaluate.source)) 

1735 if refs: 

1736 reference_map[state_name] = refs 

1737 

1738 if not reference_map: 

1739 return errors 

1740 

1741 # Step 3: For each reference, check dominance of capturing state 

1742 for ref_state_name, ref_vars in reference_map.items(): 

1743 for var_name in ref_vars: 

1744 if var_name not in capture_map: 

1745 # Referenced capture variable has no capturing state in this FSM. 

1746 # ENH-1998: downgrade to WARNING (not silent skip) when sub-loops 

1747 # are present — the capture may live in a child namespace, but a 

1748 # typo'd name should still surface rather than go completely dark. 

1749 if _has_sub_loop_state(fsm): 

1750 errors.append( 

1751 ValidationError( 

1752 message=( 

1753 f"References ${{captured.{var_name}.*}} but no state in " 

1754 f"this loop captures '{var_name}'. " 

1755 f"If '{var_name}' is produced by a sub-loop, this may be " 

1756 f"intentional; otherwise add 'capture: {var_name}' to the " 

1757 f"state that produces this value." 

1758 ), 

1759 path=f"states.{ref_state_name}.action", 

1760 severity=ValidationSeverity.WARNING, 

1761 ) 

1762 ) 

1763 continue 

1764 # No sub-loops: this is genuinely missing. 

1765 errors.append( 

1766 ValidationError( 

1767 message=( 

1768 f"References ${{captured.{var_name}.*}} but no state in " 

1769 f"this loop captures '{var_name}'. Add 'capture: {var_name}' " 

1770 f"to the state that produces this value." 

1771 ), 

1772 path=f"states.{ref_state_name}.action", 

1773 severity=ValidationSeverity.ERROR, 

1774 ) 

1775 ) 

1776 continue 

1777 

1778 # Capturing states present in this FSM (shouldn't drop any normally) 

1779 cap_states = {s for s in capture_map[var_name] if s in fsm.states} 

1780 if not cap_states: 

1781 continue 

1782 

1783 # Group dominance check: do the capturing states collectively 

1784 # dominate ref_state_name (does at least one run on every path)? 

1785 if not _dominated_by_any(fsm, cap_states, ref_state_name): 

1786 bypass_path = _find_bypass_path_any(fsm, cap_states, ref_state_name) 

1787 path_str = " → ".join(bypass_path) if bypass_path else "unknown path" 

1788 

1789 if len(cap_states) == 1: 

1790 captured_by = f"state '{next(iter(cap_states))}' which may not" 

1791 else: 

1792 names = ", ".join(f"'{s}'" for s in sorted(cap_states)) 

1793 captured_by = f"states {names}, none of which" 

1794 

1795 errors.append( 

1796 ValidationError( 

1797 message=( 

1798 f"References ${{captured.{var_name}.*}} but '{var_name}' " 

1799 f"is captured by {captured_by} " 

1800 f"execute on all paths to '{ref_state_name}'. " 

1801 f"Path(s) bypassing capture: {path_str}" 

1802 ), 

1803 path=f"states.{ref_state_name}.action", 

1804 severity=ValidationSeverity.WARNING, 

1805 ) 

1806 ) 

1807 

1808 return errors 

1809 

1810 

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

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

1813 

1814 Uses breadth-first search to find all reachable states. Seeds the BFS 

1815 with the initial state plus top-level transition targets that act as 

1816 alternate entry points: ``on_max_iterations`` (fires when the iteration 

1817 cap is hit) and ``circuit.repeated_failure.on_repeated_failure`` (fires 

1818 when the circuit breaker trips). These are real edges the runtime can 

1819 take, so states reached only through them are not orphans. 

1820 

1821 Args: 

1822 fsm: The FSM loop to analyze 

1823 

1824 Returns: 

1825 Set of reachable state names 

1826 """ 

1827 reachable: set[str] = set() 

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

1829 if fsm.on_max_iterations is not None: 

1830 to_visit.append(fsm.on_max_iterations) 

1831 if fsm.circuit is not None and fsm.circuit.repeated_failure is not None: 

1832 target = fsm.circuit.repeated_failure.on_repeated_failure 

1833 if target not in STALL_SPECIAL_TOKENS: 

1834 to_visit.append(target) 

1835 

1836 while to_visit: 

1837 current = to_visit.popleft() 

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

1839 continue 

1840 

1841 reachable.add(current) 

1842 state = fsm.states[current] 

1843 refs = state.get_referenced_states() 

1844 

1845 for ref in refs: 

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

1847 to_visit.append(ref) 

1848 

1849 return reachable 

1850 

1851 

1852def is_runnable_loop(path: Path) -> bool: 

1853 """Cheap check for whether a YAML file is a runnable FSM loop definition. 

1854 

1855 Returns True iff the file parses as a YAML mapping with the required 

1856 top-level keys ``name``, ``initial``, and either ``states`` or ``flow`` 

1857 (the shorthand resolved by :func:`resolve_flow`). This matches the 

1858 required-fields gate in :func:`load_and_validate` (lines 885-894) so 

1859 "counted by the verifier" stays in sync with "runnable by ll-loop validate". 

1860 

1861 No fragment/inheritance resolution is performed — library fragments under 

1862 ``loops/lib/`` that omit ``initial`` or ``states`` correctly return False. 

1863 """ 

1864 try: 

1865 data = yaml.safe_load(path.read_text()) 

1866 except (OSError, yaml.YAMLError): 

1867 return False 

1868 if not isinstance(data, dict): 

1869 return False 

1870 has_flow = "states" in data or "flow" in data 

1871 return "name" in data and "initial" in data and has_flow 

1872 

1873 

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

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

1876 

1877 Args: 

1878 path: Path to the YAML file to load 

1879 

1880 Returns: 

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

1882 

1883 Raises: 

1884 FileNotFoundError: If the file doesn't exist 

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

1886 ValueError: If validation fails (contains error details) 

1887 """ 

1888 if not path.exists(): 

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

1890 

1891 with open(path) as f: 

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

1893 

1894 if not isinstance(data, dict): 

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

1896 

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

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

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

1900 # result for the subsequent `resolve_fragments` pass. 

1901 data = resolve_inheritance(data, path.parent) 

1902 

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

1904 data = resolve_flow(data) 

1905 

1906 # Check required fields before parsing 

1907 missing = [] 

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

1909 if field not in data: 

1910 missing.append(field) 

1911 if "states" not in data: 

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

1913 

1914 if missing: 

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

1916 

1917 # Check for unknown top-level keys before parsing 

1918 unknown_key_warnings: list[ValidationError] = [] 

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

1920 if unknown: 

1921 unknown_key_warnings.append( 

1922 ValidationError( 

1923 path="<root>", 

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

1925 severity=ValidationSeverity.WARNING, 

1926 ) 

1927 ) 

1928 

1929 # Resolve fragment libraries before parsing into dataclass 

1930 data = resolve_fragments(data, path.parent) 

1931 

1932 # Parse into dataclass 

1933 fsm = FSMLoop.from_dict(data) 

1934 

1935 # Validate 

1936 errors = validate_fsm(fsm) 

1937 

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

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

1940 errors.extend(_validate_fragment_bindings(fsm, path.parent)) 

1941 

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

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

1944 

1945 if error_list: 

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

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

1948 

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

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

1951 all_warnings = unknown_key_warnings + struct_warnings 

1952 for warning in all_warnings: 

1953 logger.warning(str(warning)) 

1954 

1955 return fsm, all_warnings